package github

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// PRRequestDir is where agents drop PR-open requests. An agent pushes its branch
// (the credential helper uses the App token, so the push is already the App
// identity) and then writes a request file here INSTEAD of running `gh pr
// create`. The hive's watcher opens the PR with the App token, so the PR is
// authored by the App bot — never the Copilot login user. This keeps PR
// authorship deterministic without touching Copilot's auth/entitlement.
const PRRequestDir = "/var/run/hive-metrics/pr-requests"

// prRequestDirForTest lets tests point the watcher at a temp dir. Empty means
// use PRRequestDir. Not for production use.
var prRequestDirForTest string

func prRequestDir() string {
	if prRequestDirForTest != "" {
		return prRequestDirForTest
	}
	return PRRequestDir
}

// prRequestPollInterval is how often the watcher scans PRRequestDir. PR opens
// are not latency-critical (the agent has already pushed and moved on), so a
// modest interval keeps the API/log noise low.
const prRequestPollInterval = 10 * time.Second

// PRRequest is the JSON an agent writes to PRRequestDir to ask the hive to open
// a PR on its behalf. Repo may be "owner/repo" or a bare repo name; Base
// defaults to "main".
type PRRequest struct {
	Repo   string `json:"repo"`
	Head   string `json:"head"`
	Base   string `json:"base,omitempty"`
	Title  string `json:"title"`
	Body   string `json:"body,omitempty"`
	Agent  string `json:"agent,omitempty"`
	IssueN []int  `json:"issues,omitempty"` // informational; the body already carries "Fixes #N"
}

// PRResponse is written back next to a consumed request (as <name>.result.json)
// so the agent — or an operator debugging — can see what happened.
type PRResponse struct {
	OK             bool   `json:"ok"`
	Number         int    `json:"number,omitempty"`
	URL            string `json:"url,omitempty"`
	AlreadyExisted bool   `json:"already_existed,omitempty"`
	Error          string `json:"error,omitempty"`
	At             string `json:"at"`
}

// PRRequestAuthorizer decides whether a PR-open request may proceed. It receives
// the agent NAME claimed in the request and the UID that OWNS the request file
// (from the file's stat), and returns nil to authorize or an error explaining
// the denial. This is where the two policy checks live, implemented by the
// caller (which has the manager + uid-map):
//
//  1. Forge-resistance: the fileUID must map to the claimed agent (an agent can
//     only speak for ITSELF — one agent, or any non-agent process, cannot drop a
//     request "as" another agent). The per-agent PR-request subdir is UID-owned,
//     so this is the same UID trust anchor the git credential helper uses.
//  2. ACMM write-gate: the agent must be push-capable at the hive's current ACMM
//     level (the same CanPush()/mode check that governs `gh pr create` today) —
//     an advisory-only agent must NOT be able to open a PR via this path.
//
// A nil authorizer DENIES everything (fail closed) — the watcher never opens a
// PR without an authorizer, so a wiring mistake cannot silently bypass policy.
type PRRequestAuthorizer func(agent string, fileUID int) error

// StartPRRequestWatcher runs a loop that opens PRs for request files dropped in
// PRRequestDir. It returns immediately; the loop runs until ctx is cancelled.
// A nil client (no GitHub creds) makes this a no-op: requests accumulate rather
// than being silently dropped, so the feature degrades to "nothing opens" not
// "opened as the wrong identity".
//
// authz enforces the per-agent ACMM write-gate + forge-resistance (see
// PRRequestAuthorizer). A nil authz fails closed (denies every request) — the
// watcher must never open a PR that hasn't been authorized against the same
// policy as the direct `gh pr create` path.
//
// nowFn is injectable for tests; pass nil for time.Now.
func (c *Client) StartPRRequestWatcher(ctx context.Context, authz PRRequestAuthorizer, nowFn func() time.Time) {
	if c == nil {
		return
	}
	c.prAuthz = authz
	if nowFn == nil {
		nowFn = time.Now
	}
	if err := os.MkdirAll(prRequestDir(), 0o777); err != nil {
		c.logger.Warn("pr-request watcher: cannot create request dir; disabled",
			slog.String("dir", prRequestDir()), slog.String("error", err.Error()))
		return
	}
	// Agents (UID >= 2001, in the shared "node" group) must be able to DROP request
	// files here — hive-open-pr runs AS the agent. MkdirAll is masked by umask to
	// 0755 (not group-writable), so an agent's write gets EACCES and, under the
	// hard switch, its PR silently fails to open. Force group-write + setgid (like
	// /data/beads) so agent-written files inherit the node group and the dir is
	// writable by every agent. The forge-check still holds: the watcher reads each
	// file's OWNING UID, which is the agent that wrote it — group-writability lets
	// them write, it does not let one agent forge another's ownership.
	if err := os.Chmod(prRequestDir(), 0o2775); err != nil {
		c.logger.Warn("pr-request watcher: could not set group-writable perms on request dir; agents may be unable to open PRs",
			slog.String("dir", prRequestDir()), slog.String("error", err.Error()))
	}
	go func() {
		t := time.NewTicker(prRequestPollInterval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				c.processPRRequests(ctx, nowFn)
			}
		}
	}()
	c.logger.Info("pr-request watcher started", slog.String("dir", prRequestDir()))
}

// processPRRequests handles one scan of the request dir. Exported-in-spirit for
// tests via ProcessPRRequestsOnce.
func (c *Client) processPRRequests(ctx context.Context, nowFn func() time.Time) {
	entries, err := os.ReadDir(prRequestDir())
	if err != nil {
		return
	}
	for _, e := range entries {
		name := e.Name()
		// Only consume request files; skip our own .result.json outputs.
		if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".result.json") {
			continue
		}
		path := filepath.Join(prRequestDir(), name)
		c.handleOnePRRequest(ctx, path, nowFn)
	}
}

// ProcessPRRequestsOnce runs a single scan+process pass. Test/CLI entry point.
func (c *Client) ProcessPRRequestsOnce(ctx context.Context) {
	if c == nil {
		return
	}
	c.processPRRequests(ctx, time.Now)
}

func (c *Client) handleOnePRRequest(ctx context.Context, path string, nowFn func() time.Time) {
	data, err := os.ReadFile(path)
	if err != nil {
		return // vanished between ReadDir and here — fine
	}
	var req PRRequest
	if err := json.Unmarshal(data, &req); err != nil {
		// A malformed request can never succeed; move it aside so it stops being
		// retried every tick, and leave a result explaining why.
		c.writePRResult(path, PRResponse{OK: false, Error: "invalid JSON: " + err.Error(), At: nowFn().UTC().Format(time.RFC3339)})
		_ = os.Rename(path, path+".bad")
		c.logger.Warn("pr-request watcher: bad request file quarantined",
			slog.String("path", path), slog.String("error", err.Error()))
		return
	}

	// AUTHORIZE before opening — the watcher must enforce the SAME policy as the
	// direct `gh pr create` path: the request's agent must own the file (an agent
	// can only speak for itself) AND be push-capable at the hive's ACMM level.
	// The owning UID comes from the file's stat, which the requester cannot forge
	// without actually running as that UID. A nil authorizer fails closed.
	fileUID := statUID(data, path)
	if c.prAuthz == nil {
		c.denyPRRequest(path, req, "no authorizer configured (fail closed)", nowFn)
		return
	}
	if err := c.prAuthz(req.Agent, fileUID); err != nil {
		c.denyPRRequest(path, req, err.Error(), nowFn)
		return
	}

	// Invocation-attribution trail (attribution.go): resolve what the hive
	// invoked for this agent, append the visible trailer to the PR body when
	// the toggle is on, and — below, on success — record the audit entry
	// unconditionally. This choke point covers every agent regardless of CLI,
	// because the proxy hard-denies direct POST /pulls.
	meta := c.attributionMeta(req.Agent)
	body := req.Body
	if c.attributionTrailerOn() {
		body = AppendTrailer(body, meta)
	}

	res, err := c.CreatePR(ctx, req.Repo, req.Head, req.Base, req.Title, body)
	resp := PRResponse{At: nowFn().UTC().Format(time.RFC3339)}
	if err != nil {
		// Leave the request in place so the next tick retries (transient API
		// errors, main not yet pushed, etc.), but record the last error.
		resp.OK = false
		resp.Error = err.Error()
		c.writePRResult(path, resp)
		c.logger.Warn("pr-request watcher: open failed, will retry",
			slog.String("repo", req.Repo), slog.String("head", req.Head), slog.String("error", err.Error()))
		return
	}
	resp.OK = true
	resp.Number = res.Number
	resp.URL = res.URL
	resp.AlreadyExisted = res.AlreadyExisted
	// Audit the creation UNCONDITIONALLY (not gated by the trailer toggle) —
	// this is the durable answer to "which backend/model produced this PR?".
	// A reused PR is recorded too (reused=true): the watcher may be
	// re-processing a request that partially succeeded, and the invocation
	// that produced the branch is the same.
	c.recordCreationAudit(AuditActionAgentPRCreated, meta,
		"repo", req.Repo,
		"number", strconv.Itoa(res.Number),
		"url", res.URL,
		"reused", strconv.FormatBool(res.AlreadyExisted))
	c.writePRResult(path, resp)
	// Success (or reuse of an existing PR) — consume the request so it isn't
	// reprocessed.
	_ = os.Remove(path)
	c.logger.Info("pr-request watcher: PR opened by App bot",
		slog.String("repo", req.Repo), slog.String("head", req.Head),
		slog.Int("number", res.Number), slog.Bool("reused", res.AlreadyExisted),
		slog.String("agent", req.Agent))
}

// denyPRRequest records an authorization failure and quarantines the request
// (renamed .denied) so it is not retried forever. A denied request is a policy
// event, not a transient error — retrying can never make an advisory agent
// push-capable or change who owns the file.
func (c *Client) denyPRRequest(path string, req PRRequest, reason string, nowFn func() time.Time) {
	c.writePRResult(path, PRResponse{OK: false, Error: "authorization denied: " + reason, At: nowFn().UTC().Format(time.RFC3339)})
	_ = os.Rename(path, path+".denied")
	c.logger.Warn("pr-request watcher: DENIED (policy)",
		slog.String("agent", req.Agent), slog.String("repo", req.Repo),
		slog.String("head", req.Head), slog.String("reason", reason))
}

// statUID returns the UID that owns the request file. On the (Linux) container
// this is a real UID that a forging process cannot fake without running as it.
// data is unused but kept in the signature so a future non-stat proof (e.g. an
// embedded signed token) can slot in without touching call sites.
func statUID(_ []byte, path string) int {
	fi, err := os.Stat(path)
	if err != nil {
		return -1
	}
	return fileOwnerUID(fi)
}

func (c *Client) writePRResult(reqPath string, resp PRResponse) {
	out := strings.TrimSuffix(reqPath, ".json") + ".result.json"
	if b, err := json.MarshalIndent(resp, "", "  "); err == nil {
		_ = os.WriteFile(out, b, 0o644)
	}
}

// WritePRRequest is a helper (used by tests and any in-process caller) to drop a
// well-formed request file into PRRequestDir.
func WritePRRequest(dir string, req PRRequest) (string, error) {
	if err := os.MkdirAll(dir, 0o777); err != nil {
		return "", err
	}
	b, err := json.MarshalIndent(req, "", "  ")
	if err != nil {
		return "", err
	}
	name := fmt.Sprintf("%s-%d.json", sanitizeAgentName(req.Agent), time.Now().UnixNano())
	path := filepath.Join(dir, name)
	if err := os.WriteFile(path, b, 0o644); err != nil {
		return "", err
	}
	return path, nil
}

func sanitizeAgentName(s string) string {
	if s == "" {
		return "agent"
	}
	var b strings.Builder
	for _, r := range s {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
			b.WriteRune(r)
		}
	}
	if b.Len() == 0 {
		return "agent"
	}
	return b.String()
}
