package github

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	gh "github.com/google/go-github/v72/github"
)

// Duplicate-PR guard.
//
// Background: a restart storm (pod relaunching the agent from scratch every
// few minutes) made a quality agent open nine near-identical PRs overnight
// against the same issue. Each fresh start had no memory of the PR it had
// just filed, saw the same open issue, and filed another.
//
// Agents open PRs by running `gh` inside their own shell, so the hive never
// observes the call and cannot intercept it. A prompt instruction ("check
// before opening a PR") is advisory and is exactly what already failed. The
// fix therefore removes the work from the offer: any issue already claimed by
// an open PR authored by this hive is filtered out of the actionable set
// before kick messages are built.

const (
	// claimLedgerTTL bounds how long a cached claim survives without being
	// re-confirmed by a live API call. It is deliberately much longer than a
	// typical kick cadence (hours) so a multi-hour GitHub outage still keeps
	// suppressing duplicates, but short enough that a claim whose PR vanished
	// without our noticing eventually releases the issue back for work.
	claimLedgerTTL = 72 * time.Hour

	// claimSearchPerPage is the page size used when listing open PRs per repo.
	// 100 is the GitHub API maximum and matches the other paginated listers in
	// this package.
	claimSearchPerPage = 100

	// claimSearchMaxPages caps pagination so a repo with a pathological number
	// of open PRs cannot stall an enumeration cycle indefinitely.
	claimSearchMaxPages = 10

	// ClaimLedgerPath is the on-PVC location of the persisted claim ledger.
	// /data is the hive's PersistentVolumeClaim mount, so the ledger survives
	// the pod restarts that caused the incident this guard exists to prevent.
	ClaimLedgerPath = "/data/pr-claims.json"

	// claimLedgerFileMode is the permission mode for the ledger file. It holds
	// no secrets (issue and PR numbers only), so it is world-readable like the
	// other operational state files under /data.
	claimLedgerFileMode = 0o644
)

// IssueClaim records that an open pull request authored by this hive already
// claims to address a specific issue.
type IssueClaim struct {
	// Repo is the issue's repository, in the same form used by config
	// (either "repo" or "owner/repo"), so it can be compared to Issue.Repo.
	Repo string `json:"repo"`
	// Issue is the claimed issue number.
	Issue int `json:"issue"`
	// PRNumber is the pull request making the claim.
	PRNumber int `json:"pr_number"`
	// PRRepo is the repository the claiming PR lives in. It may differ from
	// Repo when the PR uses the cross-repo `owner/repo#N` closing form.
	PRRepo string `json:"pr_repo"`
	// PRURL is the claiming pull request's HTML URL, logged on every
	// suppression so an operator can immediately see what claimed the issue.
	PRURL string `json:"pr_url"`
	// PRAuthor is the login that opened the claiming PR.
	PRAuthor string `json:"pr_author"`
	// ObservedAt is when this claim was last confirmed by a live API call.
	// It is the basis for TTL expiry.
	ObservedAt time.Time `json:"observed_at"`
}

// Key identifies the issue a claim covers.
func (c IssueClaim) Key() string {
	return claimKey(c.Repo, c.Issue)
}

func claimKey(repo string, issue int) string {
	return fmt.Sprintf("%s#%d", repo, issue)
}

// closingKeywords is GitHub's full set of issue-closing keywords. Matching is
// case-insensitive. Source: GitHub docs, "Linking a pull request to an issue".
var closingKeywords = []string{
	"close", "closes", "closed",
	"fix", "fixes", "fixed",
	"resolve", "resolves", "resolved",
}

// claimRefPattern matches a closing keyword followed by an issue reference in
// either the same-repo (`#123`) or cross-repo (`owner/repo#123`) form.
//
//	(?i)                        case-insensitive
//	\b(close|closes|...)\b      a closing keyword as a whole word
//	\s*:?\s+                    optional colon, then whitespace
//	(?:([\w.-]+/[\w.-]+))?#(\d+) optional owner/repo prefix, then #N
//
// Non-closing mentions ("see #12", "related to #12") deliberately do NOT match:
// only a PR that claims to close an issue should suppress work on it.
var claimRefPattern = regexp.MustCompile(
	`(?i)\b(` + strings.Join(closingKeywords, "|") + `)\b\s*:?\s+(?:([\w.-]+/[\w.-]+))?#(\d+)`)

// ParseClaimedIssues extracts every issue this text claims to close. defaultRepo
// supplies the repository for bare `#N` references. Results are de-duplicated
// and returned in first-seen order. A nil/empty text yields no claims.
func ParseClaimedIssues(text, defaultRepo string) []ClaimedRef {
	if text == "" {
		return nil
	}
	matches := claimRefPattern.FindAllStringSubmatch(text, -1)
	if len(matches) == 0 {
		return nil
	}
	seen := make(map[string]bool, len(matches))
	var refs []ClaimedRef
	for _, m := range matches {
		// m[2] is the optional owner/repo prefix, m[3] the issue number.
		if len(m) < 4 {
			continue
		}
		num, err := strconv.Atoi(m[3])
		if err != nil || num <= 0 {
			continue
		}
		repo := defaultRepo
		if m[2] != "" {
			repo = m[2]
		}
		key := claimKey(repo, num)
		if seen[key] {
			continue
		}
		seen[key] = true
		refs = append(refs, ClaimedRef{Repo: repo, Issue: num})
	}
	return refs
}

// ClaimedRef is a single parsed issue reference.
type ClaimedRef struct {
	Repo  string
	Issue int
}

// branchIssuePattern matches the branch-naming conventions agents use to encode
// the issue they are working on:
//
//	issue-423, issue/423, fix-issue-423, fix/423, 423-add-retry
//
// The number must be delimited (start of string or a -/_// separator, and end
// of string or a -/_// separator) so a version string like "bump-v2-1" or a
// SHA-ish suffix cannot be mistaken for an issue number.
var branchIssuePattern = regexp.MustCompile(`(?i)(?:^|[/_-])(?:issue[/_-]?|fix[/_-]|gh[/_-])?(\d+)(?:[/_-]|$)`)

// headRef returns the PR's head branch name, guarding the nested pointers.
func headRef(pr *gh.PullRequest) string {
	if pr == nil || pr.GetHead() == nil {
		return ""
	}
	return pr.GetHead().GetRef()
}

// issueFromBranchName recovers an issue number from a branch name. It is a
// heuristic, not a guarantee: it is only consulted when a PR carries no
// closing-keyword reference at all, and a false positive costs at most one
// suppressed kick for one cycle.
func issueFromBranchName(branch string) (int, bool) {
	if branch == "" {
		return 0, false
	}
	m := branchIssuePattern.FindStringSubmatch(branch)
	if len(m) < 2 {
		return 0, false
	}
	n, err := strconv.Atoi(m[1])
	if err != nil || n <= 0 {
		return 0, false
	}
	return n, true
}

// HiveIdentity describes which PR authors count as "this hive". A PR opened by
// a human contributor must never suppress agent work, so the guard only honours
// claims from the hive's own accounts.
type HiveIdentity struct {
	// AIAuthor is the GitHub account agents open PRs as (project.ai_author).
	AIAuthor string
	// AppLogin is the GitHub App bot login, e.g. "kubestellar-hive[bot]".
	// It is set when the hive authenticates as an installation rather than a
	// personal token, in which case PRs are authored by the bot account.
	AppLogin string
}

// Matches reports whether the given PR author login belongs to this hive.
// Comparison is case-insensitive because GitHub logins are.
func (h HiveIdentity) Matches(author string) bool {
	if author == "" {
		return false
	}
	if h.AIAuthor != "" && strings.EqualFold(author, h.AIAuthor) {
		return true
	}
	if h.AppLogin != "" && strings.EqualFold(author, h.AppLogin) {
		return true
	}
	return false
}

// IsZero reports whether no identity is configured at all. With no identity we
// cannot tell our own PRs from anyone else's, so the guard declines to attribute
// live claims (and falls back to the ledger) rather than suppressing work based
// on a stranger's PR.
func (h HiveIdentity) IsZero() bool {
	return h.AIAuthor == "" && h.AppLogin == ""
}

// FetchClaims lists open PRs authored by this hive across the client's
// configured repos and returns the issue claims parsed from their titles and
// bodies.
//
// A per-repo API failure is reported via err but the successfully-scanned repos
// are still returned, so the caller can merge partial results into the ledger
// instead of discarding everything.
func (c *Client) FetchClaims(ctx context.Context, identity HiveIdentity) ([]IssueClaim, error) {
	if c == nil || c.client == nil {
		return nil, fmt.Errorf("nil github client")
	}
	if identity.IsZero() {
		return nil, fmt.Errorf("no hive identity configured (project.ai_author unset and no GitHub App login)")
	}

	now := time.Now()
	var claims []IssueClaim
	var firstErr error

	for _, repo := range c.getRepos() {
		owner, repoName := c.splitRepo(repo)
		opts := &gh.PullRequestListOptions{
			State:       "open",
			ListOptions: gh.ListOptions{PerPage: claimSearchPerPage},
		}
		for page := 0; page < claimSearchMaxPages; page++ {
			prs, resp, err := c.client.PullRequests.List(ctx, owner, repoName, opts)
			if err != nil {
				if firstErr == nil {
					firstErr = fmt.Errorf("listing open PRs for %s/%s: %w", owner, repoName, err)
				}
				if c.logger != nil {
					c.logger.Warn("pr-claim scan failed for repo", "repo", repo, "error", err)
				}
				break
			}
			for _, pr := range prs {
				if pr == nil {
					continue
				}
				author := safeGetLogin(pr.GetUser())
				if !identity.Matches(author) {
					continue
				}
				// Title and body are both scanned: `Fixes #N` conventionally
				// lives in the body, but many agents put it in the title.
				text := pr.GetTitle() + "\n" + pr.GetBody()
				refs := ParseClaimedIssues(text, repo)

				// Secondary heuristic for PRs that reference no issue at all
				// (in the real incident, PR #443's body was literally "test").
				// Body parsing cannot see those, but agents conventionally
				// branch as issue-423 / fix-issue-423 / 423-some-slug, so the
				// head ref recovers the link. Only consulted when the body and
				// title yielded nothing, so an explicit `Fixes #N` always wins.
				if len(refs) == 0 {
					if n, ok := issueFromBranchName(headRef(pr)); ok {
						refs = []ClaimedRef{{Repo: repo, Issue: n}}
					}
				}

				for _, ref := range refs {
					claims = append(claims, IssueClaim{
						Repo:       ref.Repo,
						Issue:      ref.Issue,
						PRNumber:   pr.GetNumber(),
						PRRepo:     repo,
						PRURL:      pr.GetHTMLURL(),
						PRAuthor:   author,
						ObservedAt: now,
					})
				}
			}
			if resp == nil || resp.NextPage == 0 {
				break
			}
			opts.ListOptions.Page = resp.NextPage
		}
	}

	return claims, firstErr
}

// ClaimLedger is the persisted issue→PR claim mapping. It is the fail-closed
// half of the guard: when GitHub is unreachable (the real incident logged
// `401 Bad credentials`), a live-only check would see zero claims and happily
// duplicate. The ledger keeps the last known claim set instead.
type ClaimLedger struct {
	path   string
	logger *slog.Logger
	// claims is keyed by "repo#issue".
	claims map[string]IssueClaim
	// ttl bounds entry lifetime; overridable for tests.
	ttl time.Duration
	// now is the clock, overridable for tests.
	now func() time.Time
}

// ledgerFile is the on-disk shape of the ledger.
type ledgerFile struct {
	SavedAt time.Time    `json:"saved_at"`
	Claims  []IssueClaim `json:"claims"`
}

// NewClaimLedger creates an empty ledger backed by path. Use LoadClaimLedger to
// read existing state from disk.
func NewClaimLedger(path string, logger *slog.Logger) *ClaimLedger {
	if path == "" {
		path = ClaimLedgerPath
	}
	if logger == nil {
		logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
	}
	return &ClaimLedger{
		path:   path,
		logger: logger,
		claims: make(map[string]IssueClaim),
		ttl:    claimLedgerTTL,
		now:    time.Now,
	}
}

// LoadClaimLedger reads the ledger from disk, dropping entries past the TTL.
// A missing file is not an error — it yields an empty ledger, which is the
// correct state on a hive's very first boot.
func LoadClaimLedger(path string, logger *slog.Logger) (*ClaimLedger, error) {
	l := NewClaimLedger(path, logger)
	data, err := os.ReadFile(l.path)
	if err != nil {
		if os.IsNotExist(err) {
			return l, nil
		}
		return l, fmt.Errorf("reading claim ledger %s: %w", l.path, err)
	}
	var file ledgerFile
	if err := json.Unmarshal(data, &file); err != nil {
		// A corrupt ledger must not wedge startup. Report it and continue with
		// an empty one; the next successful fetch repopulates it.
		return l, fmt.Errorf("parsing claim ledger %s: %w", l.path, err)
	}
	cutoff := l.now().Add(-l.ttl)
	for _, c := range file.Claims {
		if c.Issue <= 0 || c.Repo == "" {
			continue
		}
		if c.ObservedAt.Before(cutoff) {
			continue
		}
		l.claims[c.Key()] = c
	}
	return l, nil
}

// SetTTL overrides the entry lifetime. Intended for tests.
func (l *ClaimLedger) SetTTL(d time.Duration) {
	if l == nil || d <= 0 {
		return
	}
	l.ttl = d
}

// SetClock overrides the ledger's time source. Intended for tests.
func (l *ClaimLedger) SetClock(fn func() time.Time) {
	if l == nil || fn == nil {
		return
	}
	l.now = fn
}

// Claims returns the current claim set, sorted for stable output.
func (l *ClaimLedger) Claims() []IssueClaim {
	if l == nil {
		return nil
	}
	out := make([]IssueClaim, 0, len(l.claims))
	for _, c := range l.claims {
		out = append(out, c)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Repo != out[j].Repo {
			return out[i].Repo < out[j].Repo
		}
		return out[i].Issue < out[j].Issue
	})
	return out
}

// Lookup returns the claim covering the given issue, if any.
func (l *ClaimLedger) Lookup(repo string, issue int) (IssueClaim, bool) {
	if l == nil {
		return IssueClaim{}, false
	}
	c, ok := l.claims[claimKey(repo, issue)]
	return c, ok
}

// Reconcile merges a freshly-fetched claim set into the ledger.
//
// When live is authoritative (a complete, successful scan), claims that were
// present before but absent now are dropped: their PR was closed, merged, or
// its body edited, so the issue is released back for work. That is what makes
// a closed-without-merge PR re-open its issue.
//
// When live is NOT authoritative (the API call failed, wholly or partly), the
// previous claims are retained and only refreshed with whatever did come back.
// This is the fail-closed path: suppressing one legitimate kick for a cycle is
// cheap; nine duplicate PRs are not.
func (l *ClaimLedger) Reconcile(live []IssueClaim, authoritative bool) {
	if l == nil {
		return
	}
	if authoritative {
		next := make(map[string]IssueClaim, len(live))
		for _, c := range live {
			if c.Issue <= 0 || c.Repo == "" {
				continue
			}
			next[c.Key()] = c
		}
		l.claims = next
		return
	}
	for _, c := range live {
		if c.Issue <= 0 || c.Repo == "" {
			continue
		}
		l.claims[c.Key()] = c
	}
	l.prune()
}

// prune drops entries older than the TTL. It runs on the non-authoritative
// path so a permanently-failing API cannot pin a stale claim forever.
func (l *ClaimLedger) prune() {
	cutoff := l.now().Add(-l.ttl)
	for k, c := range l.claims {
		if c.ObservedAt.Before(cutoff) {
			delete(l.claims, k)
		}
	}
}

// Save writes the ledger to disk atomically (write temp, then rename), matching
// the convention used by the other /data state writers in this repo.
func (l *ClaimLedger) Save() error {
	if l == nil {
		return nil
	}
	data, err := json.MarshalIndent(ledgerFile{
		SavedAt: l.now(),
		Claims:  l.Claims(),
	}, "", "  ")
	if err != nil {
		return fmt.Errorf("marshaling claim ledger: %w", err)
	}
	tmp := l.path + ".tmp"
	if err := os.WriteFile(tmp, data, claimLedgerFileMode); err != nil {
		return fmt.Errorf("writing temp claim ledger: %w", err)
	}
	if err := os.Rename(tmp, l.path); err != nil {
		return fmt.Errorf("renaming claim ledger: %w", err)
	}
	return nil
}

// FilterClaimedIssues removes from result every issue an open hive-authored PR
// already claims, logging each suppression with the claiming PR's URL.
//
// It mutates result in place (Issues.Items, Issues.Count, Issues.SLAViolations)
// and returns the number of issues suppressed. A nil result or nil ledger is a
// no-op, so callers need no extra guards.
func FilterClaimedIssues(result *ActionableResult, ledger *ClaimLedger, logger *slog.Logger) int {
	if result == nil || ledger == nil || len(ledger.claims) == 0 {
		return 0
	}
	kept := make([]Issue, 0, len(result.Issues.Items))
	suppressed := 0
	for _, issue := range result.Issues.Items {
		claim, ok := ledger.Lookup(issue.Repo, issue.Number)
		if !ok {
			kept = append(kept, issue)
			continue
		}
		suppressed++
		if logger != nil {
			logger.Info("suppressing issue already claimed by an open hive PR",
				"repo", issue.Repo,
				"issue", issue.Number,
				"issue_title", issue.Title,
				"claimed_by_pr", claim.PRNumber,
				"pr_url", claim.PRURL,
				"pr_author", claim.PRAuthor,
			)
		}
	}
	if suppressed == 0 {
		return 0
	}

	slaViolations := 0
	for _, issue := range kept {
		if issue.AgeMinutes > slaThresholdMinutes {
			slaViolations++
		}
	}
	result.Issues.Items = kept
	result.Issues.Count = len(kept)
	result.Issues.SLAViolations = slaViolations
	return suppressed
}

// ApplyDuplicatePRGuard is the single entry point wired into the enumeration
// cycle. It fetches live claims, reconciles them into the persisted ledger,
// saves the ledger, and filters the actionable set.
//
// On a fetch failure it does NOT clear the ledger — it keeps the last known
// claim set and filters with that (fail closed). It returns the number of
// issues suppressed.
func ApplyDuplicatePRGuard(
	ctx context.Context,
	client *Client,
	ledger *ClaimLedger,
	identity HiveIdentity,
	result *ActionableResult,
	logger *slog.Logger,
) int {
	if ledger == nil || result == nil {
		return 0
	}

	live, err := client.FetchClaims(ctx, identity)
	authoritative := err == nil
	if err != nil && logger != nil {
		logger.Warn("duplicate-PR guard: claim fetch failed, falling back to persisted ledger (fail closed)",
			"error", err,
			"cached_claims", len(ledger.claims),
		)
	}
	ledger.Reconcile(live, authoritative)

	if saveErr := ledger.Save(); saveErr != nil && logger != nil {
		logger.Warn("duplicate-PR guard: failed to persist claim ledger", "error", saveErr)
	}

	suppressed := FilterClaimedIssues(result, ledger, logger)
	if logger != nil {
		logger.Info("duplicate-PR guard applied",
			"claims", len(ledger.claims),
			"suppressed", suppressed,
			"authoritative", authoritative,
		)
	}
	return suppressed
}
