package subagent

import (
	"context"
	"errors"
	"fmt"
	"os"
	"sync"
	"time"

	"github.com/whyiyhw/seek/internal/cache"
	"github.com/whyiyhw/seek/internal/paths"
	"github.com/whyiyhw/seek/internal/permission"
	"github.com/whyiyhw/seek/internal/sysprompt"
)

// defaultMaxConcurrent caps the number of active subagents the LLM
// can have running at once. Set deliberately low — the LLM can
// re-spawn after one completes (via the failed reason=too_many_subagents
// path), and the cap exists to prevent runaway parallelism that
// would N× the API rate limit hit.
//
// See docs/prd/feature-subagent.md §8 risk "并发 spawn N 个子" for
// the design rationale.
const defaultMaxConcurrent = 3

// Runner is the LLM-execution side of subagent spawning. In
// production cmd/seek wires it to a function that builds a fresh
// pkg/agent.Agent + calls Prompt with the supplied prompt; in unit
// tests it's a stub that simulates summary / error / cancellation
// paths.
//
// Contract — Runner MUST:
//
//   - Respect ctx cancellation (return ctx.Err() promptly when ctx
//     is Done) so Manager.Kill / parent turn cancellation work.
//   - Record every turn's Usage on the supplied Tracker so the
//     parent's transitive Cumulative() captures the child cost.
//   - Return a non-empty Summary on success, OR return an error.
//     Empty summary + nil error is a contract violation.
//
// Runner MUST NOT:
//
//   - Mutate the supplied Policy (use it only for Check calls).
//   - Hold references to RunnerJob fields after returning.
//   - Panic — panics in Runner crash the seek process. Defensive
//     recover() lives in the production wrapper, not here.
type Runner func(ctx context.Context, job RunnerJob) (RunnerResult, error)

// RunnerJob is the immutable bundle Manager hands to Runner. All
// fields are set up by Manager before Spawn calls the Runner: child
// Policy is already restricted via Policy.Spawn, child Tracker is
// already AdoptChild'd by the parent, the system prompt is already
// composed via sysprompt.ComposeSubagent, the session dir is
// already mkdir'd.
type RunnerJob struct {
	SubSid       string
	SystemPrompt string
	UserPrompt   string
	// SessionDir is the per-subagent directory where Runner writes
	// its transcript.jsonl (and any plan / checkpoint artefacts
	// generated by tools the subagent calls). See PRD §3.3.
	SessionDir string
	Tracker    *cache.Tracker
	Policy     *permission.Policy
	// ToolNames is the filtered tool allow-list per the template.
	// Runner uses this to construct the child tools.Registry
	// (typically by re-registering the parent's tool instances
	// keyed by name).
	ToolNames []string
}

// RunnerResult is what Runner returns on success. Summary is the
// subagent's final assistant message body (NOT yet wrapped in the
// [agent: completed] wire format — that's Manager's job). Turns is
// the count of LLM turns the subagent took; goes into the wire-
// format footer.
type RunnerResult struct {
	Summary string
	Tokens  Tokens
	Turns   int
}

// ManagerOpts configures a new Manager.
//
// ProjectAbsPath / ParentTracker / ParentPolicy are bound at
// construction — the path is immutable, and the pointers themselves
// don't change (Tracker / Policy own their own state mutation under
// their own locks).
//
// ParentSid / ProjectSection / SkillManifest / ParentToolNames are
// supplied as func() closures, not values, because:
//
//   - ParentSid: /compact forks a new session with a new ID; the
//     closure pattern (typed by main.go's planBridge.sessionID) ensures
//     subagents spawned after /compact use the live ID.
//   - ProjectSection: stable today, but the closure form keeps the
//     contract uniform with the rest.
//   - SkillManifest: skill_commit + /new hot-reloads the manifest;
//     subagents spawned after the reload must see the new manifest.
//   - ParentToolNames: the parent's Registry is built in stages
//     (initial set → agent.New → plan/propose added) so capturing a
//     snapshot at Manager construction would miss tools added later.
//     reg.Names() is also where the agent tool itself appears — the
//     template Filter strips it universally so the recursion guard
//     stays intact.
//
// MaxConcurrent defaults to 3 when zero. Runner is required.
type ManagerOpts struct {
	ProjectAbsPath string
	ParentTracker  *cache.Tracker
	ParentPolicy   *permission.Policy
	MaxConcurrent  int
	Runner         Runner

	// Now is the time source used for sub_sid generation and event
	// timestamps. Optional — falls back to time.Now if nil.
	// Tests inject a stub so sub-sids are reproducible.
	Now func() time.Time

	// SessionDate is the parent's fixed session date string (e.g.
	// "Wednesday, 2026-06-03"), captured once at session start and
	// passed verbatim into every subagent's system prompt. It is a
	// static value, NOT derived from Now(): it must stay byte-identical
	// to the parent's prompt (the §3.6.1 header-inheritance invariant),
	// and recomputing it from Now() at Spawn time would drift if the
	// session spans midnight. Empty in tests that don't exercise the
	// prompt body.
	SessionDate string

	// Dynamic accessors — called at each Spawn so RebuildAgent,
	// skill hot-reload, and session compaction (fork-new-SID) pick
	// up fresh values. See struct doc above for the rationale per
	// field. All are required.
	ParentSidFn       func() string
	ProjectSectionFn  func() string
	SkillManifestFn   func() string
	ParentToolNamesFn func() []string
}

// Manager owns the subagents.jsonl index + the in-flight map for
// one (project, parent-session) pair. Methods are concurrent-safe;
// the LLM can issue parallel `agent` tool calls in one turn and
// each Spawn runs in its own goroutine.
type Manager struct {
	opts ManagerOpts

	mu     sync.Mutex
	active map[string]*inFlight
}

// inFlight is the per-spawn mutable state that lives only between
// the `started` event and the terminal event. cancel terminates
// the Runner; killedByUser distinguishes Manager.Kill (explicit
// user action → `killed` event) from generic ctx.Canceled (parent
// turn cancelled → `failed reason=canceled` event).
type inFlight struct {
	cancel       context.CancelFunc
	killedByUser bool
}

// NewManager constructs a Manager. Returns an error only for
// programmer errors (nil dependencies, missing parent sid) — the
// goal is fail-loud on misuse, not gracefully degrade.
func NewManager(opts ManagerOpts) (*Manager, error) {
	if opts.ProjectAbsPath == "" {
		return nil, errors.New("subagent: NewManager: ProjectAbsPath required")
	}
	if opts.ParentTracker == nil {
		return nil, errors.New("subagent: NewManager: ParentTracker required")
	}
	if opts.ParentPolicy == nil {
		return nil, errors.New("subagent: NewManager: ParentPolicy required")
	}
	if opts.Runner == nil {
		return nil, errors.New("subagent: NewManager: Runner required")
	}
	if opts.ParentSidFn == nil {
		return nil, errors.New("subagent: NewManager: ParentSidFn required")
	}
	if opts.ProjectSectionFn == nil {
		return nil, errors.New("subagent: NewManager: ProjectSectionFn required")
	}
	if opts.SkillManifestFn == nil {
		return nil, errors.New("subagent: NewManager: SkillManifestFn required")
	}
	if opts.ParentToolNamesFn == nil {
		return nil, errors.New("subagent: NewManager: ParentToolNamesFn required")
	}
	if opts.MaxConcurrent == 0 {
		opts.MaxConcurrent = defaultMaxConcurrent
	}
	if opts.Now == nil {
		opts.Now = func() time.Time { return time.Now().UTC() }
	}
	return &Manager{
		opts:   opts,
		active: make(map[string]*inFlight),
	}, nil
}

// SpawnArgs is the per-call input shape. ParentTurn carries the
// current turn index from the caller's perspective (typically the
// parent agent loop's turn counter) so the index event can be
// cross-referenced with the parent transcript.
type SpawnArgs struct {
	Description string
	Prompt      string
	Type        Type
	ParentTurn  int
	// WorktreePath is set when isolation:"worktree" is in effect
	// (the agent tool handles the worktree create call BEFORE
	// invoking Spawn). Empty for isolation:"none". Goes into the
	// started event for the /worktrees panel's cross-reference.
	WorktreePath string
}

// Spawn launches one subagent and blocks until it terminates.
// Returns a Result whose Summary field is ALWAYS a well-formed
// wire-format string (success path = "[agent: completed] ...",
// any failure path = "[agent: failed reason=...] ...") — the
// caller can return it verbatim as a tool result.
//
// Concurrency model:
//
//   - Validation and the budget gate run under m.mu briefly.
//   - The Runner runs with m.mu released; it can take seconds to
//     minutes (LLM generation time).
//   - Terminal-event emission re-acquires m.mu only to mutate
//     m.active; the appendEvent call to the index is serialised
//     globally via indexLock.
//
// Spawn is sync, but the LLM can call the `agent` tool multiple
// times in one turn → multiple Spawns running in parallel
// goroutines. The MaxConcurrent gate caps simultaneous in-flights.
func (m *Manager) Spawn(ctx context.Context, args SpawnArgs) Result {
	// Step 1: validate args. Failures here produce a wire-format
	// result with no index event — the spawn never happened.
	if !args.Type.IsValid() {
		return failResult("", "spawn_error", fmt.Sprintf("unknown subagent_type %q", args.Type))
	}
	if args.Description == "" {
		return failResult("", "spawn_error", "description required")
	}
	if args.Prompt == "" {
		return failResult("", "spawn_error", "prompt required")
	}

	// Step 2: capacity gate. Held BRIEFLY under m.mu — the Runner
	// path runs lock-free, so a slow subagent doesn't block other
	// Spawn calls from running their own capacity check.
	m.mu.Lock()
	if len(m.active) >= m.opts.MaxConcurrent {
		n := len(m.active)
		m.mu.Unlock()
		return failResult("", "too_many_subagents",
			fmt.Sprintf("%d active, retry after one completes", n))
	}
	m.mu.Unlock()

	// Step 3: build child Policy via monotonic-only Spawn. The
	// subagent's cwd defaults to the parent's; isolation:"worktree"
	// (when wired in M11.1) will override this to the worktree
	// path BEFORE Spawn is called — Manager doesn't know about
	// worktree paths directly.
	tmpl, _ := TemplateFor(args.Type) // ok by IsValid check above
	cwd := m.opts.ParentPolicy.Cwd()
	if args.WorktreePath != "" {
		cwd = args.WorktreePath
	}
	childPolicy, err := m.opts.ParentPolicy.Spawn(cwd, tmpl.Restriction)
	if err != nil {
		return failResult("", "spawn_error", err.Error())
	}

	// Step 4: create + adopt child Tracker. The parent's
	// CumulativeCost from this point on transitively includes the
	// child's Record calls; AdoptChild is safe to call on a fresh
	// Tracker per the contract in cache.AdoptChild's doc.
	childTracker := cache.New()
	m.opts.ParentTracker.AdoptChild(childTracker)

	// Step 5: allocate sub_sid + session directory. Resolve the
	// parent sid via the supplied closure so /compact-forked sessions
	// pick up the new ID. Empty result is a programmer error (the
	// host program should not allow Spawn with no active session).
	parentSid := m.opts.ParentSidFn()
	if parentSid == "" {
		return failResult("", "spawn_error", "no active parent session (host did not seed ParentSidFn)")
	}
	now := m.opts.Now()
	subSid := newSubSid(now)
	sessionDir, err := paths.SubagentSessionDir(m.opts.ProjectAbsPath, parentSid, subSid)
	if err != nil {
		return failResult(subSid, "spawn_error", err.Error())
	}
	if err := os.MkdirAll(sessionDir, 0o755); err != nil {
		return failResult(subSid, "spawn_error", "mkdir session dir: "+err.Error())
	}

	// Step 6: compose system prompt. Header is byte-identical to
	// the parent's segments 1-3 (sysprompt.Compose guarantees
	// determinism for the same inputs); the trailing role + summary
	// hint differs per template. Project section + skill manifest
	// are resolved fresh each spawn so skill_commit + /new is
	// reflected in the next subagent's prompt.
	systemPrompt := sysprompt.ComposeSubagent(sysprompt.Header{
		Cwd:            cwd,
		ProjectSection: m.opts.ProjectSectionFn(),
		SkillManifest:  m.opts.SkillManifestFn(),
		Date:           m.opts.SessionDate,
	}, tmpl.Role(args.Description))

	// Step 7: emit `started` event BEFORE registering in active.
	// If the append fails we abort without leaving an orphan in
	// the map.
	indexPath, err := paths.SubagentsIndex(m.opts.ProjectAbsPath)
	if err != nil {
		return failResult(subSid, "spawn_error", err.Error())
	}
	if err := appendEvent(indexPath, event{
		Kind:         "started",
		SubSid:       subSid,
		ParentSid:    parentSid,
		ParentTurn:   args.ParentTurn,
		TS:           now,
		Type:         args.Type,
		Description:  args.Description,
		WorktreePath: args.WorktreePath,
	}); err != nil {
		return failResult(subSid, "spawn_error", "write index: "+err.Error())
	}

	// Step 8: register in m.active under a child ctx we own.
	runCtx, cancel := context.WithCancel(ctx)
	inf := &inFlight{cancel: cancel}
	m.mu.Lock()
	m.active[subSid] = inf
	m.mu.Unlock()

	// Step 9: run. This blocks. Recover from panics so a buggy
	// Runner can't take down the seek process — convert panic to
	// a failed event with reason=spawn_error.
	var (
		runResult RunnerResult
		runErr    error
	)
	func() {
		defer func() {
			if r := recover(); r != nil {
				runErr = fmt.Errorf("runner panic: %v", r)
			}
		}()
		runResult, runErr = m.opts.Runner(runCtx, RunnerJob{
			SubSid:       subSid,
			SystemPrompt: systemPrompt,
			UserPrompt:   args.Prompt,
			SessionDir:   sessionDir,
			Tracker:      childTracker,
			Policy:       childPolicy,
			ToolNames:    tmpl.Filter(m.opts.ParentToolNamesFn()),
		})
	}()
	cancel() // release any goroutines depending on runCtx

	// Step 10: pop from active under mu, capturing the killed
	// flag if set.
	m.mu.Lock()
	killed := inf.killedByUser
	delete(m.active, subSid)
	m.mu.Unlock()

	// Step 11: classify outcome → terminal event + wire-format
	// result. Order matters: explicit kill takes precedence over
	// ctx.Canceled detection because Kill sets the flag THEN
	// cancels, so a Kill is observable both ways.
	endTS := m.opts.Now()

	switch {
	case runErr == nil && runResult.Summary != "":
		// Happy path.
		tokens := runResult.Tokens
		_ = appendEvent(indexPath, event{
			Kind: "completed", SubSid: subSid, TS: endTS, Tokens: &tokens,
		})
		return Result{
			SubSid:  subSid,
			Summary: FormatCompleted(subSid, runResult.Summary, runResult.Turns, tokens),
			Status:  StatusCompleted,
			Tokens:  tokens,
		}

	case killed:
		_ = appendEvent(indexPath, event{
			Kind: "killed", SubSid: subSid, TS: endTS,
		})
		return Result{
			SubSid:  subSid,
			Summary: FormatFailed(subSid, "killed", "terminated by user via /agents kill"),
			Status:  StatusKilled,
		}

	case errors.Is(runErr, context.Canceled):
		_ = appendEvent(indexPath, event{
			Kind: "failed", SubSid: subSid, TS: endTS, Reason: "canceled",
		})
		return Result{
			SubSid:  subSid,
			Summary: FormatFailed(subSid, "canceled", "parent turn cancelled"),
			Status:  StatusFailed,
		}

	case errors.Is(runErr, context.DeadlineExceeded):
		_ = appendEvent(indexPath, event{
			Kind: "failed", SubSid: subSid, TS: endTS, Reason: "timeout",
		})
		return Result{
			SubSid:  subSid,
			Summary: FormatFailed(subSid, "timeout", "deadline exceeded"),
			Status:  StatusFailed,
		}

	default:
		// Catch-all: Runner returned an error we don't classify.
		reason := "spawn_error"
		hint := "runner error"
		if runErr != nil {
			hint = runErr.Error()
		} else {
			// runErr == nil but Summary == "" — Runner contract
			// violation. Surface as spawn_error so the LLM can
			// retry; production crash report happens in a future
			// hook.
			hint = "runner returned empty summary with no error (contract violation)"
		}
		_ = appendEvent(indexPath, event{
			Kind: "failed", SubSid: subSid, TS: endTS, Reason: reason,
		})
		return Result{
			SubSid:  subSid,
			Summary: FormatFailed(subSid, reason, hint),
			Status:  StatusFailed,
		}
	}
}

// Kill cancels the in-flight subagent identified by subSid. Sets
// killedByUser BEFORE invoking cancel so the Spawn goroutine
// observes the flag when it wakes up on ctx.Canceled. Returns
// ErrUnknownSubagent if the subagent isn't currently active
// (already completed, or never existed). Idempotent — calling
// Kill twice in rapid succession is harmless.
func (m *Manager) Kill(subSid string) error {
	m.mu.Lock()
	inf, ok := m.active[subSid]
	if !ok {
		m.mu.Unlock()
		return ErrUnknownSubagent
	}
	inf.killedByUser = true
	m.mu.Unlock()
	// Cancel outside the lock — cancel's listeners may try to
	// re-enter m.mu (via the Spawn goroutine's bookkeeping).
	inf.cancel()
	return nil
}

// ErrUnknownSubagent is returned by Kill when the sub_sid isn't in
// the active map. Callers (TUI, CLI) surface this as "no such
// active subagent" — the failure case is benign.
var ErrUnknownSubagent = errors.New("subagent: unknown or no longer active")

// ListFilter selects which folded Subagent records List returns.
// Empty Status / ParentSid mean "any". Zero value returns all.
type ListFilter struct {
	Status    Status
	ParentSid string
}

// List returns the folded state of every subagent recorded in the
// index, optionally filtered. Reads the index fresh on each call —
// no in-memory cache — so the result reflects events written
// concurrently by other goroutines (or other seek processes
// sharing the same project directory, in theory).
func (m *Manager) List(f ListFilter) ([]Subagent, error) {
	indexPath, err := paths.SubagentsIndex(m.opts.ProjectAbsPath)
	if err != nil {
		return nil, err
	}
	events, err := readEvents(indexPath)
	if err != nil {
		return nil, err
	}
	folded := foldEvents(events)
	if f.Status == "" && f.ParentSid == "" {
		return folded, nil
	}
	out := make([]Subagent, 0, len(folded))
	for _, s := range folded {
		if f.Status != "" && s.Status != f.Status {
			continue
		}
		if f.ParentSid != "" && s.ParentSid != f.ParentSid {
			continue
		}
		out = append(out, s)
	}
	return out, nil
}

// ActiveCount returns the number of currently in-flight subagents.
// Used by the status bar ("⤴ N agents" badge per PRD §4.3) and by
// the agent tool's capacity check display.
func (m *Manager) ActiveCount() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.active)
}

// failResult is a small helper for the early-exit paths that
// haven't allocated a sub_sid yet (validation failures). subSid
// may be empty — FormatFailed handles that gracefully.
func failResult(subSid, reason, hint string) Result {
	return Result{
		SubSid:  subSid,
		Summary: FormatFailed(subSid, reason, hint),
		Status:  StatusFailed,
	}
}
