package github

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"path"
	"strconv"
	"strings"

	"github.com/aoagents/agent-orchestrator/backend/internal/domain"
	"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// ciFailureLogTailLines is the number of trailing lines of a failed job's
// log we splice into the observation. 20 lines is enough to catch the
// usual "X tests failed" tail without bloating the per-PR row.
const ciFailureLogTailLines = 20

// ProviderOptions configures a Provider. Production code typically sets
// Token; tests inject a pre-built Client pointed at httptest.
type ProviderOptions struct {
	Client     *Client
	HTTPClient *http.Client
	Token      TokenSource
	// SkipTokenPreflight defers token validation until the first provider call.
	// Daemon wiring uses this so gh-token shell-out never blocks readiness.
	SkipTokenPreflight bool
	RESTBase           string
	GraphQLURL         string
	UserAgent          string
	Logger             *slog.Logger
}

// Provider observes one GitHub pull request and returns a normalized
// ports.PRObservation for the PR Manager to persist. There is no polling
// loop in v1 — the loop is a follow-up PR (#35); this adapter is the
// observation primitive that loop will call.
type Provider struct {
	client *Client
	logger *slog.Logger
}

// NewProvider returns a Provider. If opts.Client is supplied it is used
// verbatim; otherwise a Client is built from the other options. When a
// Token source is supplied it is exercised once so missing credentials
// surface at daemon startup rather than at first observation, unless
// SkipTokenPreflight is set. Tests that want an unauthenticated fake pass
// opts.Client directly.
func NewProvider(opts ProviderOptions) (*Provider, error) {
	if opts.Client == nil && opts.Token != nil && !opts.SkipTokenPreflight {
		if _, err := opts.Token.Token(context.Background()); err != nil {
			return nil, err
		}
	}
	c := opts.Client
	if c == nil {
		c = NewClient(ClientOptions{
			HTTPClient: opts.HTTPClient,
			Token:      opts.Token,
			RESTBase:   opts.RESTBase,
			GraphQLURL: opts.GraphQLURL,
			UserAgent:  opts.UserAgent,
		})
	}
	logger := opts.Logger
	if logger == nil {
		logger = slog.Default()
	}
	return &Provider{client: c, logger: logger}, nil
}

// SCMCredentialsAvailable checks whether this provider can obtain a token. The
// SCM observer calls it lazily during the first poll that has SCM subjects, so
// daemon readiness is not blocked by shelling out to gh auth token and idle
// daemons do not warn about missing credentials.
func (p *Provider) SCMCredentialsAvailable(ctx context.Context) (bool, error) {
	if p.client == nil || p.client.tokens == nil {
		return true, nil
	}
	if _, err := p.client.tokens.Token(ctx); err != nil {
		if errors.Is(err, ErrNoToken) {
			return false, nil
		}
		return false, err
	}
	return true, nil
}

// Observe fetches the current state of one PR by its github.com URL and
// returns a normalized ports.PRObservation. Any required network call
// failing yields Fetched=false (caller must not infer "PR closed" from a
// failed observation).
func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservation, error) {
	out := ports.PRObservation{URL: prURL}
	owner, repo, number, err := parsePRURL(prURL)
	if err != nil {
		return out, err
	}
	out.Number = number

	rest, err := p.fetchRESTPull(ctx, owner, repo, number)
	if err != nil {
		// Network/auth/rate-limit failures must surface as Fetched:false.
		// Stable terminal states like 404 also surface that way — the PR
		// Manager keeps the prior row rather than fabricating closed/merged.
		return out, scmObserveError(err)
	}

	out.Draft = rest.Draft
	out.Merged = rest.Merged || (rest.MergedAt != "")
	out.Closed = strings.EqualFold(rest.State, "closed") && !out.Merged

	gq, err := p.fetchGraphQL(ctx, owner, repo, number)
	if err != nil {
		return out, scmObserveError(err)
	}

	out.CI = ciSummaryFromGraphQL(gq)
	out.Review = reviewDecisionFromGraphQL(gq)
	out.Mergeability = mergeabilityFromGraphQL(gq, rest, out.CI, out.Review)
	out.Checks = checksFromGraphQL(gq, rest.Head.SHA)
	out.Comments = commentsFromGraphQL(gq)

	// Log-tail enrichment is best-effort: a job-log fetch failure must not
	// flip the observation to Fetched:false, because we already have the
	// authoritative CI summary from GraphQL. Stamp a one-liner instead.
	for i := range out.Checks {
		if !isFailingCheckStatus(out.Checks[i].Status) {
			continue
		}
		jobID := jobIDForCheck(gq, out.Checks[i].Name)
		if jobID == 0 {
			continue
		}
		log, fetchErr := p.fetchJobLogTail(ctx, owner, repo, jobID)
		if fetchErr != nil {
			out.Checks[i].LogTail = fmt.Sprintf("<log fetch failed: %s>", scrubError(fetchErr))
			continue
		}
		out.Checks[i].LogTail = tailLines(log, ciFailureLogTailLines)
	}

	out.Fetched = true
	return out, nil
}

func scmObserveError(err error) error {
	if errors.Is(err, ErrNotFound) {
		return fmt.Errorf("%w: %w", ports.ErrSCMPRNotFound, err)
	}
	return err
}

// ---------------------------------------------------------------------------
// REST: pull payload
// ---------------------------------------------------------------------------

type restPull struct {
	State    string `json:"state"`
	Draft    bool   `json:"draft"`
	Merged   bool   `json:"merged"`
	MergedAt string `json:"merged_at"`
	Head     struct {
		SHA string `json:"sha"`
	} `json:"head"`
	Mergeable      *bool  `json:"mergeable"`
	MergeableState string `json:"mergeable_state"`
}

func (p *Provider) fetchRESTPull(ctx context.Context, owner, repo string, number int) (restPull, error) {
	resp, err := p.client.doREST(ctx, http.MethodGet, repoPath(owner, repo, "pulls", strconv.Itoa(number)), nil, nil)
	if err != nil {
		return restPull{}, err
	}
	if len(resp.Body) == 0 {
		return restPull{}, errors.New("github scm: empty pull response")
	}
	var pull restPull
	if err := json.Unmarshal(resp.Body, &pull); err != nil {
		return restPull{}, fmt.Errorf("github scm: decode pull: %w", err)
	}
	return pull, nil
}

// ---------------------------------------------------------------------------
// GraphQL: the heavy lift
// ---------------------------------------------------------------------------

// graphQLCheckContextLimit caps how many statusCheckRollup contexts we
// request in one GraphQL hop. 100 is GitHub's documented per-page max
// for the contexts connection. When the rollup has MORE than this many
// contexts the response surfaces pageInfo.hasNextPage=true and
// ciSummaryFromGraphQL is conservative (see the "CIUnknown on
// hasNextPage when not already CIFailing" branch — a partial visible
// set could hide a failure, so we degrade the verdict rather than
// risk reporting a broken PR as passing).
const graphQLCheckContextLimit = 100

// prObservationQuery is the GraphQL query (derived from PR #28, credited
// to @whoisasx) that pulls everything we need in one round trip:
//   - reviewDecision (APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / null)
//   - mergeable + mergeStateStatus (DIRTY / BLOCKED / UNSTABLE / CLEAN / ...)
//   - latest commit's statusCheckRollup (CheckRuns + StatusContexts) so we
//     can derive a CIState without an extra REST hop, and so that bot vs
//     human is detected via __typename on review comments.
const prObservationQuery = `query($owner:String!,$repo:String!,$number:Int!){
  repository(owner:$owner,name:$repo){
    pullRequest(number:$number){
      number
      url
      state
      isDraft
      merged
      closed
      mergeable
      mergeStateStatus
      reviewDecision
      headRefOid
      commits(last:1){ nodes{ commit{
        oid
        statusCheckRollup{
          state
          contexts(first:CONTEXT_LIMIT){
            nodes{
              __typename
              ... on CheckRun  { name status conclusion detailsUrl url databaseId }
              ... on StatusContext { context state targetUrl }
            }
            pageInfo{ hasNextPage }
          }
        }
      } } }
      reviewThreads(last:100){ nodes{
        id
        isResolved
        comments(first:100){ nodes{
          id
          body
          path
          line
          url
          author{ login __typename }
        } }
      } }
    }
  }
}`

func (p *Provider) fetchGraphQL(ctx context.Context, owner, repo string, number int) (map[string]any, error) {
	q := strings.Replace(prObservationQuery, "CONTEXT_LIMIT", strconv.Itoa(graphQLCheckContextLimit), 1)
	data, err := p.client.doGraphQL(ctx, q, map[string]any{"owner": owner, "repo": repo, "number": number})
	if err != nil {
		return nil, err
	}
	repoData, _ := data["repository"].(map[string]any)
	pr, _ := repoData["pullRequest"].(map[string]any)
	if pr == nil {
		return nil, fmt.Errorf("%w: pull request not found in graphql response", ErrNotFound)
	}
	return pr, nil
}

// ---------------------------------------------------------------------------
// REST: per-job log tail
// ---------------------------------------------------------------------------

func (p *Provider) fetchJobLogTail(ctx context.Context, owner, repo string, jobID int64) (string, error) {
	logPath := repoPath(owner, repo, "actions", "jobs", strconv.FormatInt(jobID, 10), "logs")
	body, err := p.client.fetchPlainText(ctx, logPath)
	if err != nil {
		return "", err
	}
	return string(body), nil
}

// ---------------------------------------------------------------------------
// Projection helpers
// ---------------------------------------------------------------------------

// ciSummaryFromGraphQL maps the per-PR status rollup onto domain.CIState.
// If ANY visible context concluded failure-class we return CIFailing.
// Otherwise any pending context wins over passing. An empty rollup is
// CIUnknown. When the rollup is paginated (pageInfo.hasNextPage=true)
// the verdict is conservative: a known failure is still safe — failures
// don't get un-failed by more pages — but passing/pending/unknown
// verdicts could hide a failing context on the next page, so we degrade
// them all to CIUnknown rather than risk reporting a broken PR as ready.
func ciSummaryFromGraphQL(pr map[string]any) domain.CIState {
	roll := statusRollup(pr)
	if roll == nil {
		return domain.CIUnknown
	}
	contexts, _ := roll["contexts"].(map[string]any)
	rawNodes := nodes(contexts["nodes"])
	if len(rawNodes) == 0 {
		// GitHub returns a top-level "state" on the rollup even when the
		// nodes list is empty (e.g. SUCCESS / FAILURE / PENDING). Honor it
		// rather than returning CIUnknown for an otherwise-decided PR.
		return mapRollupState(str(roll["state"]))
	}
	pending, passing := false, false
	for _, n := range rawNodes {
		st := checkStatusFromGraphQL(n)
		switch st {
		case domain.PRCheckFailed, domain.PRCheckCancelled:
			return domain.CIFailing
		case domain.PRCheckQueued, domain.PRCheckInProgress:
			pending = true
		case domain.PRCheckPassed:
			passing = true
		}
	}
	if pageInfoHasMore(contexts) {
		return domain.CIUnknown
	}
	switch {
	case pending:
		return domain.CIPending
	case passing:
		return domain.CIPassing
	default:
		return domain.CIUnknown
	}
}

// pageInfoHasMore reports whether the rollup contexts have a next page
// the current request didn't fetch. We treat a missing pageInfo block
// as "no more" (older API shapes that don't expose pagination simply
// return everything in one page).
func pageInfoHasMore(contexts map[string]any) bool {
	pi, ok := contexts["pageInfo"].(map[string]any)
	if !ok {
		return false
	}
	return boolv(pi["hasNextPage"])
}

func mapRollupState(s string) domain.CIState {
	switch strings.ToUpper(strings.TrimSpace(s)) {
	case "SUCCESS":
		return domain.CIPassing
	case "FAILURE", "ERROR":
		return domain.CIFailing
	case "PENDING", "EXPECTED":
		return domain.CIPending
	default:
		return domain.CIUnknown
	}
}

// reviewDecisionFromGraphQL normalizes the GraphQL reviewDecision enum
// onto the domain vocabulary. Re-implemented inline because the helper
// referenced in the task brief lived against types that no longer exist.
func reviewDecisionFromGraphQL(pr map[string]any) domain.ReviewDecision {
	switch strings.ToUpper(strings.TrimSpace(str(pr["reviewDecision"]))) {
	case "APPROVED":
		return domain.ReviewApproved
	case "CHANGES_REQUESTED":
		return domain.ReviewChangesRequest
	case "REVIEW_REQUIRED":
		return domain.ReviewRequired
	default:
		return domain.ReviewNone
	}
}

// mergeabilityFromGraphQL composes the merge verdict from three signals:
// the REST mergeable/rebaseable booleans, the GraphQL mergeStateStatus,
// and the already-derived CIState + ReviewDecision. The rules follow the
// spec table in doc.go.
func mergeabilityFromGraphQL(pr map[string]any, rest restPull, ci domain.CIState, review domain.ReviewDecision) domain.Mergeability {
	// REST's mergeable_state is the tiebreaker: GraphQL's
	// mergeStateStatus enum (DIRTY / BLOCKED / UNSTABLE / CLEAN /
	// UNKNOWN) is the primary; if it is empty we fall back to the
	// REST string (lowercase: "dirty" / "blocked" / "unstable" /
	// "clean" / "behind" / "unknown") uppercased so the same switch
	// covers both shapes. The REST API does NOT expose a
	// `merge_state_status` field — earlier revs of this code chased
	// that ghost; we use mergeable_state instead.
	state := strings.ToUpper(strings.TrimSpace(firstNonEmpty(str(pr["mergeStateStatus"]), rest.MergeableState)))
	rawMergeable := strings.ToUpper(strings.TrimSpace(str(pr["mergeable"])))

	switch state {
	case "DIRTY":
		return domain.MergeConflicting
	case "BLOCKED":
		return domain.MergeBlocked
	case "UNSTABLE":
		return domain.MergeUnstable
	}
	if rawMergeable == "CONFLICTING" {
		return domain.MergeConflicting
	}

	if rest.Draft || boolv(pr["isDraft"]) {
		return domain.MergeBlocked
	}
	if review == domain.ReviewChangesRequest || review == domain.ReviewRequired {
		return domain.MergeBlocked
	}
	if ci == domain.CIFailing {
		return domain.MergeBlocked
	}

	// REST's mergeable_state ("clean" / "blocked" / "behind" / "dirty" / "unstable"
	// / "draft" / "unknown") backs up the GraphQL view when GitHub hasn't
	// computed the rollup yet.
	switch strings.ToLower(strings.TrimSpace(rest.MergeableState)) {
	case "clean":
		if rawMergeable == "MERGEABLE" || (rest.Mergeable != nil && *rest.Mergeable) {
			return domain.MergeMergeable
		}
	case "dirty":
		return domain.MergeConflicting
	case "blocked":
		return domain.MergeBlocked
	case "unstable":
		return domain.MergeUnstable
	}

	if rawMergeable == "MERGEABLE" && state == "CLEAN" {
		return domain.MergeMergeable
	}
	return domain.MergeUnknown
}

// checksFromGraphQL projects each context node into a PRCheckObservation.
// StatusContext (commit-status) and CheckRun (Actions) are both flattened
// into the same slice because downstream consumers don't distinguish.
func checksFromGraphQL(pr map[string]any, headSHA string) []ports.PRCheckObservation {
	roll := statusRollup(pr)
	contexts, _ := roll["contexts"].(map[string]any)
	rawNodes := nodes(contexts["nodes"])
	if len(rawNodes) == 0 {
		return nil
	}
	out := make([]ports.PRCheckObservation, 0, len(rawNodes))
	for _, n := range rawNodes {
		typ := str(n["__typename"])
		var name, urlOut string
		switch typ {
		case "CheckRun":
			name = str(n["name"])
			urlOut = firstNonEmpty(str(n["detailsUrl"]), str(n["url"]))
		case "StatusContext":
			name = str(n["context"])
			urlOut = str(n["targetUrl"])
		default:
			continue
		}
		if name == "" {
			continue
		}
		out = append(out, ports.PRCheckObservation{
			Name:       name,
			CommitHash: headSHA,
			Status:     checkStatusFromGraphQL(n),
			URL:        urlOut,
		})
	}
	return out
}

// commentsFromGraphQL flattens unresolved review threads into one comment
// per node, dropping bot authors entirely (the spec keeps Resolved=false
// always since we filter resolved threads out client-side).
func commentsFromGraphQL(pr map[string]any) []ports.PRCommentObservation {
	threads, _ := pr["reviewThreads"].(map[string]any)
	rawNodes := nodes(threads["nodes"])
	if len(rawNodes) == 0 {
		return nil
	}
	var out []ports.PRCommentObservation
	for _, th := range rawNodes {
		if boolv(th["isResolved"]) {
			continue
		}
		comments, _ := th["comments"].(map[string]any)
		for _, cn := range nodes(comments["nodes"]) {
			author, _ := cn["author"].(map[string]any)
			if isBotAuthor(author) {
				continue
			}
			out = append(out, ports.PRCommentObservation{
				ID:       str(cn["id"]),
				Author:   str(author["login"]),
				File:     str(cn["path"]),
				Line:     int(num(cn["line"])),
				Body:     str(cn["body"]),
				Resolved: false,
			})
		}
	}
	return out
}

// isBotAuthor uses ONLY GitHub's typed signal (__typename or User.Type
// === "Bot"). The strings.Contains(login, "bot") fallback from PR #28
// was deliberately dropped — aa-18 flagged it as a false-positive
// magnet (logins like "robothon", "lambot123" tripped it).
func isBotAuthor(author map[string]any) bool {
	if strings.EqualFold(str(author["__typename"]), "Bot") {
		return true
	}
	if strings.EqualFold(str(author["type"]), "Bot") {
		return true
	}
	return false
}

// jobIDForCheck looks up the Actions job ID for a check by name, so we
// can call /actions/jobs/{job_id}/logs. StatusContext rows have no job
// ID (they're commit statuses, not Actions runs); those return 0 and
// the log fetch is skipped for them.
func jobIDForCheck(pr map[string]any, name string) int64 {
	roll := statusRollup(pr)
	contexts, _ := roll["contexts"].(map[string]any)
	for _, n := range nodes(contexts["nodes"]) {
		if str(n["__typename"]) != "CheckRun" {
			continue
		}
		if str(n["name"]) != name {
			continue
		}
		return int64(num(n["databaseId"]))
	}
	return 0
}

// statusRollup extracts the commits[0].commit.statusCheckRollup blob
// from the GraphQL pullRequest payload. Nil when the PR has no commits
// or GitHub hasn't computed the rollup yet.
func statusRollup(pr map[string]any) map[string]any {
	commits, _ := pr["commits"].(map[string]any)
	for _, n := range nodes(commits["nodes"]) {
		commit, _ := n["commit"].(map[string]any)
		roll, _ := commit["statusCheckRollup"].(map[string]any)
		if roll != nil {
			return roll
		}
	}
	return nil
}

// checkStatusFromGraphQL maps the (status, conclusion) tuple of one node
// onto the domain enum. Failure-class conclusions always win — pending
// status with a final conclusion of "failure" is still a failed check.
func checkStatusFromGraphQL(n map[string]any) domain.PRCheckStatus {
	typ := str(n["__typename"])
	if typ == "StatusContext" {
		switch strings.ToUpper(strings.TrimSpace(str(n["state"]))) {
		case "SUCCESS":
			return domain.PRCheckPassed
		case "FAILURE", "ERROR":
			return domain.PRCheckFailed
		case "PENDING", "EXPECTED":
			return domain.PRCheckInProgress
		default:
			return domain.PRCheckUnknown
		}
	}
	conclusion := strings.ToUpper(strings.TrimSpace(str(n["conclusion"])))
	status := strings.ToUpper(strings.TrimSpace(str(n["status"])))
	switch conclusion {
	case "SUCCESS", "NEUTRAL":
		return domain.PRCheckPassed
	case "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE":
		return domain.PRCheckFailed
	case "CANCELLED":
		return domain.PRCheckCancelled
	case "SKIPPED", "STALE":
		return domain.PRCheckSkipped
	}
	switch status {
	case "QUEUED", "PENDING", "REQUESTED", "WAITING":
		return domain.PRCheckQueued
	case "IN_PROGRESS":
		return domain.PRCheckInProgress
	case "COMPLETED":
		// Completed without a conclusion is unusual but reachable — treat
		// it as unknown so the caller does not over-trust an absent state.
		return domain.PRCheckUnknown
	}
	return domain.PRCheckUnknown
}

func isFailingCheckStatus(s domain.PRCheckStatus) bool {
	return s == domain.PRCheckFailed || s == domain.PRCheckCancelled
}

// ---------------------------------------------------------------------------
// URL + path helpers
// ---------------------------------------------------------------------------

// parsePRURL accepts both the canonical github.com web URL and the API
// pulls URL. Returns owner, repo, number or an error wrapping ErrNotFound
// for shapes we don't recognise (so the caller surfaces them like any
// other "PR isn't on GitHub" outcome).
func parsePRURL(prURL string) (string, string, int, error) {
	if prURL == "" {
		return "", "", 0, fmt.Errorf("%w: empty PR url", ErrNotFound)
	}
	u, err := url.Parse(prURL)
	if err != nil {
		return "", "", 0, fmt.Errorf("%w: parse url: %w", ErrNotFound, err)
	}
	host := strings.ToLower(u.Host)
	// Accept github.com (web) and api.github.com (REST/GraphQL). GitHub
	// Enterprise hosts must end in .github.com or .ghe.io (GitHub's own
	// dedicated TLDs); anything else looks like a bad URL or a different
	// SCM and is rejected.
	switch {
	case host == "":
		// Allow path-only URLs (parsePRURL is also exercised via API
		// paths without a host in some tests).
	case host == "github.com", host == "www.github.com", host == "api.github.com":
		// canonical
	case strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io"):
		// enterprise
	default:
		return "", "", 0, fmt.Errorf("%w: host %q is not a github host", ErrNotFound, host)
	}
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	// Web form: /owner/repo/pull/123
	if len(parts) >= 4 && (parts[2] == "pull" || parts[2] == "pulls") {
		owner, repo := parts[0], parts[1]
		n, err := strconv.Atoi(parts[3])
		if err != nil || n <= 0 {
			return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[3])
		}
		return owner, repo, n, nil
	}
	// API form: /repos/owner/repo/pulls/123
	if len(parts) >= 5 && parts[0] == "repos" && parts[3] == "pulls" {
		owner, repo := parts[1], parts[2]
		n, err := strconv.Atoi(parts[4])
		if err != nil || n <= 0 {
			return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[4])
		}
		return owner, repo, n, nil
	}
	return "", "", 0, fmt.Errorf("%w: not a github PR url: %s", ErrNotFound, prURL)
}

func repoPath(owner, repo string, elems ...string) string {
	all := append([]string{"repos", owner, repo}, elems...)
	for i := range all {
		all[i] = url.PathEscape(all[i])
	}
	return "/" + path.Join(all...)
}

// ---------------------------------------------------------------------------
// Small JSON-ish accessors
// ---------------------------------------------------------------------------

func nodes(v any) []map[string]any {
	a, ok := v.([]any)
	if !ok {
		return nil
	}
	out := make([]map[string]any, 0, len(a))
	for _, item := range a {
		if m, ok := item.(map[string]any); ok {
			out = append(out, m)
		}
	}
	return out
}

func str(v any) string {
	if s, ok := v.(string); ok {
		return s
	}
	return ""
}

func boolv(v any) bool {
	if b, ok := v.(bool); ok {
		return b
	}
	return false
}

func num(v any) float64 {
	switch t := v.(type) {
	case float64:
		return t
	case int:
		return float64(t)
	case int64:
		return float64(t)
	case json.Number:
		f, _ := t.Float64()
		return f
	default:
		return 0
	}
}

func firstNonEmpty(a, b string) string {
	if strings.TrimSpace(a) != "" {
		return a
	}
	return b
}

func tailLines(s string, n int) string {
	s = strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n")
	if s == "" {
		return ""
	}
	lines := strings.Split(s, "\n")
	if len(lines) > n {
		lines = lines[len(lines)-n:]
	}
	return strings.Join(lines, "\n")
}

// scrubError keeps the error message single-line so the LogTail field
// stays a tidy one-liner instead of leaking multi-line API payloads
// into the PR row.
func scrubError(err error) string {
	if err == nil {
		return ""
	}
	msg := err.Error()
	msg = strings.ReplaceAll(msg, "\n", " ")
	msg = strings.ReplaceAll(msg, "\r", " ")
	return strings.TrimSpace(msg)
}
