package activity

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/agsbridge"
	activitystore "github.com/tidbcloud/AgentTeam/internal/store/resource/activity"
)

// AGSHTTPResourceActivitySource adapts the embedded AGS HTTP data plane to the
// resource activity projector source contract. It is not a general AGS client
// and does not own repository lifecycle or authorization decisions.
type AGSHTTPResourceActivitySource struct {
	Handler             http.Handler
	AuthHeader          string
	AuthorizationHeader string
	PageSize            int
}

// ListActivityEvents reads one bounded AGS event stream for the projector. An
// unsupported event kind yields an empty batch rather than widening the query.
func (s AGSHTTPResourceActivitySource) ListActivityEvents(ctx context.Context, repo ResourceActivityDataPlaneRepository, eventKind string, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	if s.Handler == nil {
		return ResourceActivityDataPlaneBatch{}, fmt.Errorf("resource activity source handler is nil")
	}
	switch eventKind {
	case "commits":
		return s.listCommits(ctx, repo, opts)
	case "issues":
		return s.listIssues(ctx, repo, opts)
	case "pulls":
		return s.listPulls(ctx, repo, opts)
	case "reviews":
		return s.listPullReviews(ctx, repo, opts)
	case "tags":
		return s.listTags(ctx, repo, opts)
	case "releases":
		return s.listReleases(ctx, repo, opts)
	case activitystore.ProjectorKindWiki:
		return s.listWiki(ctx, repo, opts)
	default:
		return ResourceActivityDataPlaneBatch{}, nil
	}
}

func (s AGSHTTPResourceActivitySource) listCommits(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	values := url.Values{}
	if ref := strings.TrimSpace(repo.DefaultRef); ref != "" {
		values.Set("sha", ref)
	}
	payload, err := fetchPagedAGS[agsCommit](ctx, s, repo, "/commits", values, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	events := make([]ResourceActivityDataPlaneEvent, 0, len(payload))
	for _, commit := range payload {
		sha := strings.TrimSpace(commit.SHA)
		if sha == "" {
			continue
		}
		occurred := firstOptionalTime(commit.Commit.Author.Date, commit.Commit.Committer.Date)
		if occurred.IsZero() {
			continue
		}
		message := strings.TrimSpace(commit.Commit.Message)
		actorID := actorID(commit.Author, commit.Commit.Author.Name)
		actorName := actorName(commit.Author, commit.Commit.Author.Name)
		actorLogin := firstNonEmpty(strings.TrimSpace(commit.Author.Login), strings.TrimSpace(commit.Author.UserName))
		gitDisplay := GitActorActivityDisplay(actorID, actorName, "", actorLogin)
		ref := strings.TrimSpace(repo.DefaultRef)
		events = append(events, WithDataPlaneActivityDisplay(repo, ResourceActivityDataPlaneEvent{
			SourceEventID: sourceEventID(repo, "commit", ref, sha),
			EventType:     "commit_pushed",
			ActorType:     ActivityActorTypeUser,
			ActorID:       actorID,
			ActorName:     actorName,
			SummaryKey:    "commit_pushed",
			TargetKind:    "commit",
			TargetID:      sha,
			TargetURL:     strings.TrimSpace(commit.HTMLURL),
			OccurredAt:    occurred,
			Details: map[string]any{
				"sha":     shortSHA(sha),
				"message": firstLine(message),
				"ref":     ref,
			},
		}, gitDisplay))
	}
	return ResourceActivityDataPlaneBatch{Events: events}, nil
}

func (s AGSHTTPResourceActivitySource) listIssues(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	values := url.Values{}
	values.Set("state", "all")
	payload, err := fetchPagedAGS[agsIssue](ctx, s, repo, "/issues", values, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	known := decodeStringMapState(opts.StateJSON, "issues")
	next := make(map[string]string, len(known)+len(payload))
	events := make([]ResourceActivityDataPlaneEvent, 0, len(payload)*2)
	for _, issue := range payload {
		if issue.Number <= 0 || issue.PullRequest != nil {
			continue
		}
		key := strconv.Itoa(issue.Number)
		state := normalizeOpenClosed(issue.State)
		if state == "" {
			state = "open"
		}
		next[key] = state
		title := strings.TrimSpace(issue.Title)
		details := map[string]any{"number": issue.Number, "title": title, "state": state}
		if _, ok := known[key]; !ok && issue.CreatedAt != nil && !issue.CreatedAt.IsZero() {
			events = append(events, ResourceActivityDataPlaneEvent{
				SourceEventID: sourceEventID(repo, "issue", key, "opened"),
				EventType:     "issue_opened",
				ActorType:     ActivityActorTypeUser,
				ActorID:       actorID(issue.User, ""),
				ActorName:     actorName(issue.User, ""),
				SummaryKey:    "issue_opened",
				Details:       details,
				TargetKind:    "issue",
				TargetID:      key,
				TargetURL:     strings.TrimSpace(issue.HTMLURL),
				OccurredAt:    issue.CreatedAt.UTC(),
			})
		}
		if state == "closed" {
			occurred := firstOptionalTime(issue.ClosedAt, issue.UpdatedAt)
			if !occurred.IsZero() {
				if known[key] != "closed" || !hasKnownState(known, key) {
					events = append(events, ResourceActivityDataPlaneEvent{
						SourceEventID: sourceEventID(repo, "issue", key, "closed", unixNanoString(occurred)),
						EventType:     "issue_closed",
						ActorType:     ActivityActorTypeUser,
						ActorID:       actorID(issue.User, ""),
						ActorName:     actorName(issue.User, ""),
						SummaryKey:    "issue_closed",
						Details:       details,
						TargetKind:    "issue",
						TargetID:      key,
						TargetURL:     strings.TrimSpace(issue.HTMLURL),
						OccurredAt:    occurred,
					})
				}
			}
		} else if hasKnownState(known, key) && known[key] == "closed" {
			occurred := firstOptionalTime(issue.UpdatedAt, issue.CreatedAt)
			if !occurred.IsZero() {
				events = append(events, ResourceActivityDataPlaneEvent{
					SourceEventID: sourceEventID(repo, "issue", key, "reopened", unixNanoString(occurred)),
					EventType:     "issue_reopened",
					ActorType:     ActivityActorTypeUser,
					ActorID:       actorID(issue.User, ""),
					ActorName:     actorName(issue.User, ""),
					SummaryKey:    "issue_reopened",
					Details:       details,
					TargetKind:    "issue",
					TargetID:      key,
					TargetURL:     strings.TrimSpace(issue.HTMLURL),
					OccurredAt:    occurred,
				})
			}
		}
	}
	return ResourceActivityDataPlaneBatch{Events: events, StateJSON: encodeStringMapState("issues", next)}, nil
}

func (s AGSHTTPResourceActivitySource) listPulls(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	pulls, err := s.fetchPulls(ctx, repo, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	known := decodeStringMapState(opts.StateJSON, "pulls")
	next := make(map[string]string, len(known)+len(pulls))
	events := make([]ResourceActivityDataPlaneEvent, 0, len(pulls)*2)
	for _, pull := range pulls {
		if pull.Number <= 0 {
			continue
		}
		key := strconv.Itoa(pull.Number)
		state := pullState(pull)
		next[key] = state
		title := strings.TrimSpace(pull.Title)
		details := map[string]any{"number": pull.Number, "title": title, "state": state}
		if _, ok := known[key]; !ok && pull.CreatedAt != nil && !pull.CreatedAt.IsZero() {
			events = append(events, pullActivityEvent(repo, pull, "pr_opened", "opened", pull.CreatedAt.UTC(), details))
		}
		if state == "merged" {
			occurred := firstOptionalTime(pull.MergedAt, pull.ClosedAt, pull.UpdatedAt)
			if !occurred.IsZero() && known[key] != "merged" {
				events = append(events, pullActivityEvent(repo, pull, "pr_merged", "merged", occurred, details))
			}
		} else if state == "closed" {
			occurred := firstOptionalTime(pull.ClosedAt, pull.UpdatedAt)
			if !occurred.IsZero() && known[key] != "closed" {
				events = append(events, pullActivityEvent(repo, pull, "pr_closed", "closed", occurred, details))
			}
		} else if hasKnownState(known, key) && (known[key] == "closed" || known[key] == "merged") {
			occurred := firstOptionalTime(pull.UpdatedAt, pull.CreatedAt)
			if !occurred.IsZero() {
				events = append(events, pullActivityEvent(repo, pull, "pr_reopened", "reopened", occurred, details))
			}
		}
	}
	return ResourceActivityDataPlaneBatch{Events: events, StateJSON: encodeStringMapState("pulls", next)}, nil
}

func (s AGSHTTPResourceActivitySource) listPullReviews(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	pulls, err := s.fetchPulls(ctx, repo, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	events := make([]ResourceActivityDataPlaneEvent, 0)
	for _, pull := range pulls {
		if pull.Number <= 0 {
			continue
		}
		var reviews []agsPullReview
		if err := s.get(ctx, repo, "/pulls/"+strconv.Itoa(pull.Number)+"/reviews", &reviews); err != nil {
			if errorsIsNotFound(err) {
				continue
			}
			return ResourceActivityDataPlaneBatch{}, err
		}
		for _, review := range reviews {
			occurred := firstOptionalTime(review.SubmittedAt, review.UpdatedAt)
			if review.ID == 0 || occurred.IsZero() {
				continue
			}
			number := pull.Number
			events = append(events, ResourceActivityDataPlaneEvent{
				SourceEventID: sourceEventID(repo, "pull", strconv.Itoa(number), "review", strconv.FormatInt(review.ID, 10)),
				EventType:     "pr_reviewed",
				ActorType:     ActivityActorTypeUser,
				ActorID:       actorID(review.User, ""),
				ActorName:     actorName(review.User, ""),
				SummaryKey:    "pr_reviewed",
				Details: map[string]any{
					"number": number,
					"title":  strings.TrimSpace(pull.Title),
					"state":  strings.TrimSpace(review.State),
				},
				TargetKind: "pull",
				TargetID:   strconv.Itoa(number),
				TargetURL:  firstNonEmpty(strings.TrimSpace(review.HTMLURL), strings.TrimSpace(pull.HTMLURL)),
				OccurredAt: occurred,
			})
		}
	}
	return ResourceActivityDataPlaneBatch{Events: events}, nil
}

func (s AGSHTTPResourceActivitySource) listTags(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	payload, err := fetchPagedAGS[agsTag](ctx, s, repo, "/tags", nil, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	known := decodeStringSetState(opts.StateJSON, "tags")
	next := map[string]struct{}{}
	now := opts.Now
	if now.IsZero() {
		now = time.Now().UTC()
	}
	events := make([]ResourceActivityDataPlaneEvent, 0, len(payload))
	for _, tag := range payload {
		name := strings.TrimSpace(tag.Name)
		if name == "" {
			continue
		}
		next[name] = struct{}{}
		if _, ok := known[name]; !ok {
			sha := strings.TrimSpace(tag.Commit.SHA)
			events = append(events, ResourceActivityDataPlaneEvent{
				SourceEventID: sourceEventID(repo, "tag", name, "created", firstNonEmpty(sha, "unknown")),
				EventType:     "tag_created",
				ActorType:     ActivityActorTypeSystem,
				SummaryKey:    "tag_created",
				Details:       map[string]any{"tagName": name, "sha": shortSHA(sha)},
				TargetKind:    "tag",
				TargetID:      name,
				TargetURL:     firstNonEmpty(tag.HTMLURL, tag.TarballURL, tag.ZipballURL),
				OccurredAt:    now,
			})
		}
	}
	for name := range known {
		if _, ok := next[name]; ok {
			continue
		}
		events = append(events, ResourceActivityDataPlaneEvent{
			SourceEventID: sourceEventID(repo, "tag", name, "deleted"),
			EventType:     "tag_deleted",
			ActorType:     ActivityActorTypeSystem,
			SummaryKey:    "tag_deleted",
			Details:       map[string]any{"tagName": name},
			TargetKind:    "tag",
			TargetID:      name,
			OccurredAt:    now,
		})
	}
	return ResourceActivityDataPlaneBatch{Events: events, StateJSON: encodeStringSetState("tags", next)}, nil
}

func (s AGSHTTPResourceActivitySource) listReleases(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	payload, err := fetchPagedAGS[agsRelease](ctx, s, repo, "/releases", nil, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	events := make([]ResourceActivityDataPlaneEvent, 0, len(payload))
	for _, release := range payload {
		id := release.ID
		occurred := firstOptionalTime(release.PublishedAt, release.CreatedAt)
		if id == 0 || occurred.IsZero() {
			continue
		}
		releaseName := firstNonEmpty(release.Name, release.TagName, strconv.FormatInt(id, 10))
		events = append(events, ResourceActivityDataPlaneEvent{
			SourceEventID: sourceEventID(repo, "release", strconv.FormatInt(id, 10), "published"),
			EventType:     "release_published",
			ActorType:     ActivityActorTypeUser,
			ActorID:       actorID(release.Author, ""),
			ActorName:     actorName(release.Author, ""),
			SummaryKey:    "release_published",
			Details:       map[string]any{"releaseName": releaseName, "tagName": strings.TrimSpace(release.TagName)},
			TargetKind:    "release",
			TargetID:      strconv.FormatInt(id, 10),
			TargetURL:     strings.TrimSpace(release.HTMLURL),
			OccurredAt:    occurred,
		})
	}
	return ResourceActivityDataPlaneBatch{Events: events}, nil
}

func (s AGSHTTPResourceActivitySource) listWiki(ctx context.Context, repo ResourceActivityDataPlaneRepository, opts ResourceActivityDataPlaneListOptions) (ResourceActivityDataPlaneBatch, error) {
	values := url.Values{}
	values.Set("recursive", "true")
	payload, err := fetchPagedAGS[agsWikiPage](ctx, s, repo, "/wiki/pages", values, opts.Limit)
	if err != nil {
		return ResourceActivityDataPlaneBatch{}, err
	}
	known := decodeStringMapState(opts.StateJSON, "pages")
	next := make(map[string]string, len(known)+len(payload))
	events := make([]ResourceActivityDataPlaneEvent, 0, len(payload)*2)
	now := opts.Now
	if now.IsZero() {
		now = time.Now().UTC()
	}
	for _, page := range payload {
		slug := strings.TrimSpace(page.Slug)
		if slug == "" {
			continue
		}
		sha := strings.TrimSpace(page.SHA)
		if sha == "" {
			sha = unixNanoString(firstOptionalTime(page.UpdatedAt))
		}
		state := firstNonEmpty(sha, strings.TrimSpace(page.Title))
		next[slug] = state
		occurred := firstOptionalTime(page.UpdatedAt)
		if occurred.IsZero() {
			occurred = now
		}
		details := map[string]any{"slug": slug, "title": strings.TrimSpace(page.Title), "sha": strings.TrimSpace(page.SHA)}
		if prev, ok := known[slug]; !ok {
			events = append(events, ResourceActivityDataPlaneEvent{
				SourceEventID: sourceEventID(repo, activitystore.ProjectorKindWiki, slug, "created", state),
				EventType:     "wiki_page_created",
				ActorType:     ActivityActorTypeUser,
				SummaryKey:    "wiki_page_created",
				Details:       details,
				TargetKind:    "wiki_page",
				TargetID:      slug,
				TargetURL:     strings.TrimSpace(page.HTMLURL),
				OccurredAt:    occurred,
			})
		} else if prev != state {
			events = append(events, ResourceActivityDataPlaneEvent{
				SourceEventID: sourceEventID(repo, activitystore.ProjectorKindWiki, slug, "updated", state),
				EventType:     "wiki_page_updated",
				ActorType:     ActivityActorTypeUser,
				SummaryKey:    "wiki_page_updated",
				Details:       details,
				TargetKind:    "wiki_page",
				TargetID:      slug,
				TargetURL:     strings.TrimSpace(page.HTMLURL),
				OccurredAt:    occurred,
			})
		}
	}
	for slug := range known {
		if _, ok := next[slug]; ok {
			continue
		}
		events = append(events, ResourceActivityDataPlaneEvent{
			SourceEventID: sourceEventID(repo, activitystore.ProjectorKindWiki, slug, "deleted"),
			EventType:     "wiki_page_deleted",
			ActorType:     ActivityActorTypeSystem,
			SummaryKey:    "wiki_page_deleted",
			Details:       map[string]any{"slug": slug},
			TargetKind:    "wiki_page",
			TargetID:      slug,
			OccurredAt:    now,
		})
	}
	return ResourceActivityDataPlaneBatch{Events: events, StateJSON: encodeStringMapState("pages", next)}, nil
}

func (s AGSHTTPResourceActivitySource) fetchPulls(ctx context.Context, repo ResourceActivityDataPlaneRepository, limit int) ([]agsPull, error) {
	values := url.Values{}
	values.Set("state", "all")
	return fetchPagedAGS[agsPull](ctx, s, repo, "/pulls", values, limit)
}

func fetchPagedAGS[T any](ctx context.Context, source AGSHTTPResourceActivitySource, repo ResourceActivityDataPlaneRepository, path string, params url.Values, limit int) ([]T, error) {
	max := limit
	if max <= 0 {
		max = 500
	}
	pageSize := source.pageSize(max)
	out := make([]T, 0, minInt(max, pageSize))
	for page := 1; len(out) < max; page++ {
		values := cloneURLValues(params)
		values.Set("page", strconv.Itoa(page))
		values.Set("limit", strconv.Itoa(pageSize))
		values.Set("per_page", strconv.Itoa(pageSize))
		var payload []T
		if err := source.get(ctx, repo, path+"?"+values.Encode(), &payload); err != nil {
			return nil, err
		}
		if len(payload) == 0 {
			break
		}
		remaining := max - len(out)
		if len(payload) > remaining {
			payload = payload[:remaining]
		}
		out = append(out, payload...)
		if len(payload) < pageSize {
			break
		}
	}
	return out, nil
}

func (s AGSHTTPResourceActivitySource) get(ctx context.Context, repo ResourceActivityDataPlaneRepository, suffix string, out any) error {
	path := "/api/v3/repos/" + url.PathEscape(repo.RepoOwner) + "/" + url.PathEscape(repo.RepoName) + suffix
	rec := agsbridge.DoJSONRequest(ctx, s.Handler, s.AuthHeader, s.AuthorizationHeader, http.MethodGet, path, nil)
	switch rec.Code {
	case http.StatusOK:
	case http.StatusNotFound:
		return fmt.Errorf("%w: %s", ErrResourceActivityProjectorNotFound, repo.RepoFullName)
	case http.StatusTooManyRequests:
		return fmt.Errorf("%w: %s", ErrResourceActivityProjectorRateLimited, repo.RepoFullName)
	default:
		return fmt.Errorf("resource activity source %s: status %d", repo.RepoFullName, rec.Code)
	}
	if out == nil {
		return nil
	}
	if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
		return fmt.Errorf("decode resource activity source %s: %w", repo.RepoFullName, err)
	}
	return nil
}

func (s AGSHTTPResourceActivitySource) pageSize(limit int) int {
	pageSize := s.PageSize
	if pageSize <= 0 {
		pageSize = limit
	}
	if pageSize <= 0 {
		pageSize = 100
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return pageSize
}

func cloneURLValues(values url.Values) url.Values {
	out := url.Values{}
	for key, vals := range values {
		for _, value := range vals {
			out.Add(key, value)
		}
	}
	return out
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

type agsUser struct {
	ID       int64  `json:"id"`
	Login    string `json:"login"`
	UserName string `json:"username"`
	FullName string `json:"full_name"`
	Name     string `json:"name"`
}

type agsCommit struct {
	SHA     string  `json:"sha"`
	HTMLURL string  `json:"html_url"`
	Author  agsUser `json:"author"`
	Commit  struct {
		Message string `json:"message"`
		Author  struct {
			Name string     `json:"name"`
			Date *time.Time `json:"date"`
		} `json:"author"`
		Committer struct {
			Name string     `json:"name"`
			Date *time.Time `json:"date"`
		} `json:"committer"`
	} `json:"commit"`
}

type agsIssue struct {
	Number      int        `json:"number"`
	Title       string     `json:"title"`
	State       string     `json:"state"`
	HTMLURL     string     `json:"html_url"`
	User        agsUser    `json:"user"`
	CreatedAt   *time.Time `json:"created_at"`
	UpdatedAt   *time.Time `json:"updated_at"`
	ClosedAt    *time.Time `json:"closed_at"`
	PullRequest *struct{}  `json:"pull_request"`
}

type agsPull struct {
	Number    int        `json:"number"`
	Title     string     `json:"title"`
	State     string     `json:"state"`
	HTMLURL   string     `json:"html_url"`
	User      agsUser    `json:"user"`
	Merged    bool       `json:"merged"`
	CreatedAt *time.Time `json:"created_at"`
	UpdatedAt *time.Time `json:"updated_at"`
	ClosedAt  *time.Time `json:"closed_at"`
	MergedAt  *time.Time `json:"merged_at"`
}

type agsPullReview struct {
	ID          int64      `json:"id"`
	State       string     `json:"state"`
	HTMLURL     string     `json:"html_url"`
	User        agsUser    `json:"user"`
	SubmittedAt *time.Time `json:"submitted_at"`
	UpdatedAt   *time.Time `json:"updated_at"`
}

type agsTag struct {
	Name       string `json:"name"`
	HTMLURL    string `json:"html_url"`
	TarballURL string `json:"tarball_url"`
	ZipballURL string `json:"zipball_url"`
	Commit     struct {
		SHA string `json:"sha"`
	} `json:"commit"`
}

type agsRelease struct {
	ID          int64      `json:"id"`
	TagName     string     `json:"tag_name"`
	Name        string     `json:"name"`
	HTMLURL     string     `json:"html_url"`
	Author      agsUser    `json:"author"`
	CreatedAt   *time.Time `json:"created_at"`
	PublishedAt *time.Time `json:"published_at"`
}

type agsWikiPage struct {
	Slug      string     `json:"slug"`
	Title     string     `json:"title"`
	SHA       string     `json:"sha"`
	HTMLURL   string     `json:"html_url"`
	UpdatedAt *time.Time `json:"updated_at"`
}

func pullActivityEvent(repo ResourceActivityDataPlaneRepository, pull agsPull, eventType, sourceSuffix string, occurred time.Time, details map[string]any) ResourceActivityDataPlaneEvent {
	number := strconv.Itoa(pull.Number)
	return ResourceActivityDataPlaneEvent{
		SourceEventID: sourceEventID(repo, "pull", number, sourceSuffix, unixNanoString(occurred)),
		EventType:     eventType,
		ActorType:     ActivityActorTypeUser,
		ActorID:       actorID(pull.User, ""),
		ActorName:     actorName(pull.User, ""),
		SummaryKey:    eventType,
		Details:       details,
		TargetKind:    "pull",
		TargetID:      number,
		TargetURL:     strings.TrimSpace(pull.HTMLURL),
		OccurredAt:    occurred,
	}
}

func pullState(pull agsPull) string {
	state := normalizeOpenClosed(pull.State)
	if pull.Merged || pull.MergedAt != nil {
		return "merged"
	}
	if state == "" {
		return "open"
	}
	return state
}

func actorID(user agsUser, fallback string) string {
	if user.Login != "" {
		return strings.TrimSpace(user.Login)
	}
	if user.UserName != "" {
		return strings.TrimSpace(user.UserName)
	}
	if user.ID > 0 {
		return strconv.FormatInt(user.ID, 10)
	}
	return strings.TrimSpace(fallback)
}

func actorName(user agsUser, fallback string) string {
	return firstNonEmpty(user.FullName, user.Name, user.Login, user.UserName, fallback)
}

func firstOptionalTime(values ...*time.Time) time.Time {
	for _, value := range values {
		if value != nil && !value.IsZero() {
			return value.UTC()
		}
	}
	return time.Time{}
}

func normalizeOpenClosed(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "open", "opened":
		return "open"
	case "closed", "merged":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return ""
	}
}

func hasKnownState(values map[string]string, key string) bool {
	_, ok := values[key]
	return ok
}

func decodeStringMapState(raw string, field string) map[string]string {
	var payload map[string]map[string]string
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
		return map[string]string{}
	}
	values := payload[field]
	if values == nil {
		return map[string]string{}
	}
	out := make(map[string]string, len(values))
	for key, value := range values {
		key = strings.TrimSpace(key)
		value = strings.TrimSpace(value)
		if key != "" && value != "" {
			out[key] = value
		}
	}
	return out
}

func encodeStringMapState(field string, values map[string]string) string {
	if len(values) == 0 {
		return "{}"
	}
	payload := map[string]map[string]string{field: values}
	encoded, err := json.Marshal(payload)
	if err != nil {
		return "{}"
	}
	return string(encoded)
}

func decodeStringSetState(raw string, field string) map[string]struct{} {
	var payload map[string][]string
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
		return map[string]struct{}{}
	}
	out := map[string]struct{}{}
	for _, value := range payload[field] {
		if trimmed := strings.TrimSpace(value); trimmed != "" {
			out[trimmed] = struct{}{}
		}
	}
	return out
}

func encodeStringSetState(field string, values map[string]struct{}) string {
	if len(values) == 0 {
		return "{}"
	}
	list := make([]string, 0, len(values))
	for value := range values {
		list = append(list, value)
	}
	sort.Strings(list)
	encoded, err := json.Marshal(map[string][]string{field: list})
	if err != nil {
		return "{}"
	}
	return string(encoded)
}

func shortSHA(value string) string {
	value = strings.TrimSpace(value)
	if len(value) <= 12 {
		return value
	}
	return value[:12]
}

func firstLine(value string) string {
	value = strings.TrimSpace(value)
	if idx := strings.IndexByte(value, '\n'); idx >= 0 {
		return strings.TrimSpace(value[:idx])
	}
	return value
}

func unixNanoString(value time.Time) string {
	if value.IsZero() {
		return "0"
	}
	return strconv.FormatInt(value.UTC().UnixNano(), 10)
}

func errorsIsNotFound(err error) bool {
	return errors.Is(err, ErrResourceActivityProjectorNotFound)
}
