use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;

mod hash_trip;

#[derive(Parser)]
#[command(
    name = "apm",
    about = "Agent Project Manager",
    version,
    disable_help_subcommand = true,
    help_template = "\
Agent Project Manager — a git-native ticket system for human+AI teams.

{usage-heading} {usage}

Setup:
  init           Initialize apm in the current repository
  agents         Manage agent wrappers (list, new, test, eject)
  help           Show help for a topic (commands, config, workflow, ticket)

Ticket management:
  new            Create a new ticket
  list           List tickets
  show           Show a ticket
  set            Set a field on a ticket
  spec           Read or write individual spec sections
  close          Force-close a ticket from any state
  assign         Assign a ticket to an owner

Workflow:
  review         Review a ticket and transition state (supervisor)
  next           Return the highest-priority actionable ticket
  start          Claim a ticket and provision its worktree (agent)
  prompt         Print the system prompt that would be used for a ticket
  state          Transition a ticket's state (low-level)
  work           Orchestrate workers: dispatch in a loop
  sync           Sync with remote (poll events, detect merges)

Epics:
  epic           Manage epics
  refresh-epic   Pull default-branch updates into an epic branch

Maintenance:
  worktrees      List or remove permanent git worktrees
  clean          Remove worktrees and branches for closed tickets
  workers        List and manage running worker processes
  validate       Validate config and ticket integrity
  archive        Move closed ticket files to the archive directory
  version        Print version and build type

Server:
  register       Generate a one-time password for device registration
  sessions       List active sessions
  revoke         Revoke sessions

{options}
"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum EpicCommand {
    /// Create a new epic branch
    New {
        /// Title for the epic
        title: String,
    },
    /// Open a PR from the epic branch to the default branch
    Close {
        /// Epic ID (4–8 char hex prefix)
        id: String,
    },
    /// List all epics with derived state and ticket counts
    List,
    /// Show an epic and its tickets
    Show {
        /// Epic ID (4–8 char hex prefix)
        id: String,
        /// Skip automatic git fetch before reading data
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Set a field on an epic (owner)
    Set {
        /// Epic ID (4–8 char hex prefix)
        id: String,
        /// Field to update: owner
        field: String,
        /// New value (use "-" to clear)
        value: String,
    },
}

#[derive(Subcommand)]
enum WorkersCommand {
    /// Show permission-denial diagnostics for a worker transcript
    Diag {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        id: String,
    },
}

#[derive(Subcommand)]
enum AgentsCommand {
    /// List available wrappers (built-in and project-defined)
    List,
    /// Scaffold a new custom wrapper under .apm/agents/<name>/
    New {
        /// Name for the new wrapper
        name: String,
        /// Overwrite if the directory already exists
        #[arg(long)]
        force: bool,
    },
    /// Smoke-test a wrapper with a synthetic ticket
    Test {
        /// Wrapper name to test
        name: String,
    },
    /// Extract a built-in wrapper to a project script under .apm/agents/<name>/
    Eject {
        /// Built-in wrapper name to eject (e.g. claude)
        name: String,
    },
    /// Show the resolved agent, role, model, and manifest for a ticket
    Resolve {
        /// Ticket ID or unambiguous prefix
        ticket_id: String,
        /// Emit JSON instead of the human-readable table
        #[arg(long)]
        json: bool,
    },
}

#[derive(Subcommand)]
enum Command {
    /// Initialize apm in the current repository
    #[command(long_about = "Initialize apm in the current repository.

Creates the .apm/ directory containing:
  * config.toml        — project config
  * workflow.toml      — state-machine definition
  * ticket.toml        — ticket template
  * agents.md          — agent onboarding instructions
  * apm.spec-writer.md — spec-writer agent manual
  * apm.worker.md      — worker agent manual

Also installs git hooks (.git/hooks/post-merge, post-checkout) so apm can
detect branch merges automatically.

Unless --no-claude is passed, adds apm commands to .claude/settings.json
so that Claude Code's allow list does not prompt for every apm call.

Pass --yes (or -y) to skip the [y/N] confirmation prompts. This is implied
when stdin is not a terminal.

Use --migrate if you have an existing root-level apm.toml and apm.agents.md
that need to be moved into .apm/.")]
    Init {
        /// Skip updating .claude/settings.json allow list
        #[arg(long)]
        no_claude: bool,
        /// Migrate root-level apm.toml -> .apm/config.toml and apm.agents.md -> .apm/agents.md
        #[arg(long)]
        migrate: bool,
        /// Generate .apm/Dockerfile.apm-worker and print build instructions
        #[arg(long)]
        with_docker: bool,
        /// Suppress non-error output
        #[arg(long)]
        quiet: bool,
        /// Add allow-list entries without prompting
        #[arg(long, short = 'y')]
        yes: bool,
    },
    /// List tickets
    #[command(long_about = "List tickets (read-only query).

All filter flags are combinable. By default, tickets in terminal states
(closed, etc.) are hidden; pass --all to include them.

Examples:
  apm list                          # all non-closed tickets
  apm list --state ready            # only tickets awaiting an agent
  apm list --unassigned             # no agent assigned yet
  apm list --actionable agent       # tickets an agent can act on now
  apm list --all                    # everything including closed
  apm list --mine                   # only your tickets
  apm list --author alice           # only tickets by alice")]
    List {
        /// Filter by state (e.g. new, ready, in_progress, implemented, closed)
        #[arg(long)]
        state: Option<String>,
        /// Show only tickets with no agent assigned
        #[arg(long)]
        unassigned: bool,
        /// Include terminal-state tickets (e.g. closed)
        #[arg(long)]
        all: bool,
        /// Show only tickets actionable by this actor (agent, supervisor, engineer)
        #[arg(long, value_name = "ACTOR")]
        actionable: Option<String>,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Show only tickets authored by the current user
        #[arg(long)]
        mine: bool,
        /// Show only tickets authored by USERNAME
        #[arg(long, value_name = "USERNAME", conflicts_with = "mine")]
        author: Option<String>,
        /// Show only tickets owned by USERNAME (owner field)
        #[arg(long, value_name = "USERNAME", conflicts_with = "mine")]
        owner: Option<String>,
    },
    /// Show a ticket
    #[command(long_about = "Show the full content of a ticket.

Reads the ticket file directly from its branch blob in the git object store,
so the working tree does not need to be checked out on that branch.

By default, `apm show` fetches the latest remote state first. Pass
--no-aggressive to skip the fetch (faster for scripts or offline use).

The ticket ID can be supplied as:
  * a plain integer (e.g. 42 → pads to 0042)
  * a 4+ char hex prefix (e.g. 00ab)
  * the full 8-char hex ID")]
    Show {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Open the ticket in $VISUAL / $EDITOR (falls back to vi) instead of printing to stdout
        #[arg(long)]
        edit: bool,
    },
    /// Create a new ticket
    #[command(long_about = "Create a new ticket and its branch.

Creates a ticket Markdown file on a new branch (ticket/<id>-<slug>) and
opens $EDITOR so you can fill in the spec immediately.

Agents must always pass --no-edit to skip the interactive editor:
  apm new --no-edit \"Short title\"

Use --side-note during implementation to capture an out-of-scope observation
without interrupting the current ticket:
  apm new --side-note \"Spotted issue\" --context \"What was observed\"

After creating a ticket the typical next step is:
  apm state <id> in_design   # claim the spec for writing")]
    New {
        /// Short title for the ticket
        #[arg(value_name = "TITLE")]
        title: String,
        /// Skip opening $EDITOR after creation
        #[arg(long)]
        no_edit: bool,
        /// Mark this ticket as a side-note (out-of-scope observation)
        #[arg(long)]
        side_note: bool,
        /// Context to insert into a ticket section
        #[arg(long)]
        context: Option<String>,
        /// Section to route --context into (defaults to first tickets.sections entry or "Problem")
        #[arg(long)]
        context_section: Option<String>,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Section name to pre-populate (repeat paired with --set)
        #[arg(long, value_name = "NAME")]
        section: Vec<String>,
        /// Content for the section named by the preceding --section (repeat paired with --section)
        #[arg(long, value_name = "TEXT")]
        set: Vec<String>,
        /// Epic ID (8 hex chars); ticket branch will be created from epic/<ID>-* tip
        #[arg(long, value_name = "ID")]
        epic: Option<String>,
        /// Comma-separated ticket IDs this ticket depends on (repeatable)
        #[arg(long, value_name = "IDS")]
        depends_on: Vec<String>,
    },
    /// Transition a ticket's state
    #[command(long_about = "Transition a ticket to a new state.

Valid target states depend on the ticket's current state. The allowed
transitions are defined in .apm/workflow.toml under [[workflow.states]].
Illegal transitions are rejected with an error.

Run `apm show <id>` first to check the current state, then choose a
target from the edges listed for that state in .apm/workflow.toml.

Use --force to bypass the transition rules (escape hatch for stuck tickets).
The target state must still exist in the config; document-level validations
(spec completeness, unchecked criteria) are still enforced.

Examples:
  apm state 42 in_design       # claim a new ticket for spec writing
  apm state 42 specd           # submit spec for supervisor review
  apm state 42 implemented     # mark implementation done (open PR first)
  apm state 42 new --force     # reset a stuck in_design ticket
  apm state 42 ready --force   # reset a stuck in_progress ticket")]
    State {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Target state (e.g. in_design, specd, ready, in_progress, implemented, closed)
        #[arg(value_name = "STATE")]
        state: String,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Bypass transition rules (escape hatch for stuck tickets)
        #[arg(long)]
        force: bool,
    },
    /// Set a field on a ticket
    #[command(long_about = "Set a metadata field on a ticket.

Valid field names:
  priority    — integer; higher = picked first by `apm next`
  effort      — integer 1-10; implementation scale estimate
  risk        — integer 1-10; technical risk estimate
  title       — short human-readable summary
  agent       — agent wrapper to use for this ticket; must match an agent
                configured in config.toml ([workers].default or any
                state's worker_profile in workflow.toml). Use \"-\" to clear.
  branch      — override the ticket's branch name (use \"-\" to clear)
  depends_on  — comma-separated list of blocker IDs (use \"-\" to clear)

Examples:
  apm set 42 priority 5
  apm set 42 agent pi
  apm set 42 agent -               # clear agent field
  apm set 42 depends_on abc123     # single blocker
  apm set 42 depends_on \"abc123,def456\"  # multiple blockers
  apm set 42 depends_on -          # clear depends_on")]
    Set {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Field to update: priority, effort, risk, title, agent, branch, depends_on
        #[arg(value_name = "FIELD")]
        field: String,
        /// New value for the field (use "-" to clear agent/branch)
        #[arg(value_name = "VALUE")]
        value: String,
        /// Skip automatic git fetch/push
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Claim a ticket and provision its worktree for manual work
    #[command(long_about = "Claim a ticket and provision its permanent worktree.

Sets the ticket's agent field to $APM_AGENT_NAME and transitions state to
in_progress, then provisions (or reuses) a permanent git worktree for the
ticket branch. Prints the worktree path so the caller can cd into it or use
`git -C <path>` for all subsequent git operations.

Without --spawn, the command only claims the ticket and sets up the worktree.
No worker process is launched — the engineer works in the worktree manually.
Use this when you want to implement the ticket yourself.

With --spawn, a background Claude Code subprocess is launched in the worktree.
The subprocess receives the project allow list by default; add -P to also pass
--dangerously-skip-permissions. Worker output is written to
.apm-worker.log in the worktree directory.

--next auto-selects the highest-priority actionable ticket; mutually
exclusive with an explicit ID.

Examples:
  apm start 42                   # claim ticket 42 for manual work
  apm start --next               # claim the top-priority ticket for manual work
  apm start --spawn 42           # hand ticket 42 to a background agent
  apm start --spawn --next -P    # background agent, skip permissions")]
    Start {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer); omit when using --next
        id: Option<String>,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Launch a claude worker subprocess in the background
        #[arg(long)]
        spawn: bool,
        /// Pass --dangerously-skip-permissions to the worker (use with --spawn)
        #[arg(long, short = 'P')]
        skip_permissions: bool,
        /// Auto-select the highest-priority actionable ticket
        #[arg(long)]
        next: bool,
    },
    /// Return the highest-priority actionable ticket
    #[command(long_about = "Return the highest-priority ticket actionable right now.

Considers only tickets in states that the current actor can act on (agent
by default). Selects by priority descending, then by id ascending as a
tiebreaker.

Returns nothing (exit 0, empty output) when there is no actionable ticket.

--json outputs the result as a JSON object — useful in agent startup loops:
  apm next --json   # {\"id\": \"0042\", \"title\": \"...\", \"state\": \"ready\", ...}

Typical agent startup sequence:
  apm sync
  apm next --json   # check for work
  apm start --next  # claim and provision in one step")]
    Next {
        /// Output result as JSON instead of human-readable text
        #[arg(long)]
        json: bool,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Sync with remote (poll events, detect merges)
    #[command(long_about = "Fetch from remote and reconcile the local ticket cache.

What sync does:
  1. git fetch (unless --offline)
  2. Detects ticket branches that have been merged into main
  3. For each merged branch, closes the ticket immediately; use --auto-close
     to skip the confirmation prompt in CI
  4. Updates the local branch cache

Run sync at the start of each agent session to ensure local state reflects
what has happened on the remote since last time.

Examples:
  apm sync                    # interactive, fetch from remote
  apm sync --offline          # re-process local branches only
  apm sync --auto-close       # close all merged tickets silently
  apm sync --quiet            # suppress non-error output")]
    Sync {
        /// Skip git fetch; re-process local branches only
        #[arg(long)]
        offline: bool,
        /// Suppress non-error output
        #[arg(long)]
        quiet: bool,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Automatically close merged/stale tickets without prompting
        #[arg(long)]
        auto_close: bool,
        /// Push the default branch to origin if it is ahead (no prompt)
        #[arg(long)]
        push_default: bool,
        /// Push all ahead ticket/* and epic/* branches to origin (no prompt)
        #[arg(long)]
        push_refs: bool,
    },
    /// Assign a ticket to an owner
    #[command(
        long_about = "Set the owner field on any ticket, regardless of its current state.

Use this to assign a ticket to a user or agent, or to clear the owner field.

Ownership gates dispatcher pickup: `apm work`, `apm start --next`, and the UI
dispatch loop only pick up tickets whose owner matches the current user's identity.
Tickets with no owner are never auto-dispatched. Assign a ticket before running
the dispatch loop.

Examples:
  apm assign 42 alice        # assign ticket 42 to alice
  apm assign 42 -            # clear the owner field"
    )]
    Assign {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Username to assign (use \"-\" to clear)
        #[arg(value_name = "USERNAME")]
        username: String,
        /// Skip automatic git fetch/push
        #[arg(long)]
        no_aggressive: bool,
        /// Override the ownership check (prompts for confirmation if ticket has an existing owner)
        #[arg(long)]
        force: bool,
    },
    /// List or remove permanent git worktrees
    #[command(long_about = "Manage permanent git worktrees for ticket branches.

APM uses permanent worktrees (in the apm--worktrees/ sibling directory by
default) so that agents can work on a ticket branch without disturbing the
main working tree. These worktrees survive `apm sync` and are reused across
sessions.

Examples:
  apm worktrees              # list all known worktrees
  apm worktrees --remove 42  # remove the worktree for ticket 42")]
    Worktrees {
        /// Remove the worktree for the given ticket ID
        #[arg(long, value_name = "ID")]
        remove: Option<String>,
    },
    /// Supervisor: edit ticket spec and optionally transition state
    #[command(
        long_about = "Supervisor command: review and edit a ticket spec, then transition state.

Opens $EDITOR on the ticket file so the supervisor can read the spec, leave
feedback in amendment-request boxes, or update acceptance criteria. After
the editor closes, prompts for a state transition unless --to is supplied.

Common review flows:
  apm review 42 --to specd      # approve spec as-is
  apm review 42 --to ammend     # request changes (fill in amendment boxes first)
  apm review 42 --to ready      # approve and queue for implementation
  apm review 42 --to implemented  # accept implementation

--to skips the interactive prompt — useful in scripts or when the transition
is already decided before opening the editor."
    )]
    Review {
        /// Ticket ID to review (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Transition to this state after editing (skips interactive prompt)
        #[arg(long, value_name = "STATE")]
        to: Option<String>,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Validate config and ticket integrity
    #[command(
        long_about = "Validate .apm/config.toml correctness and full ticket integrity.

Config checks:
  * .apm/ TOML files parse without errors
  * All state transitions reference states that exist in the config
  * completion 'pr' or 'merge' requires [git_host] with a provider
  * instruction file paths exist on disk

Ticket integrity checks:
  * Every ticket's branch field matches its actual branch name
  * dependency-rule violations per completion strategy
  * Unknown state values (state not in config.workflow.states)
  * ID/filename mismatches
  * in_progress/implemented tickets missing a branch field
  * Branches already merged into the default branch with ticket still open
  * Missing ## Spec or ## History sections
  * Document-structure validation errors
  * in_design/in_progress tickets whose worktree directory is absent

--fix repairs branch-field mismatches automatically and closes tickets
whose branch has already been merged. Missing worktrees are reported but
never auto-recreated.

--json outputs the full results as JSON — useful in CI pipelines:
  apm validate --json | jq '.errors'

--config-only skips all per-ticket and filesystem checks, including
merged-branch and worktree checks."
    )]
    Validate {
        /// Auto-fix repairable issues (branch field mismatches)
        #[arg(long)]
        fix: bool,
        /// Output results as JSON
        #[arg(long)]
        json: bool,
        /// Run only config cross-checks, skip ticket integrity checks
        #[arg(long)]
        config_only: bool,
        /// Skip automatic git fetch before reading ticket data
        #[arg(long)]
        no_aggressive: bool,
        /// Show per-transition agent resolution audit
        #[arg(long)]
        verbose: bool,
    },
    /// Internal git hook dispatcher (used by .git/hooks/*)
    #[command(name = "_hook", hide = true)]
    Hook {
        /// Name of the git hook being dispatched (e.g. post-merge)
        #[arg(value_name = "HOOK")]
        hook_name: String,
        /// Extra args passed by git (remote, url) — ignored
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        _extra: Vec<String>,
    },
    /// PreToolUse hook: enforce worktree write isolation (called by Claude Code)
    #[command(name = "path-guard", hide = true)]
    PathGuard,
    /// Manage agent wrappers (list, new, test, eject)
    Agents {
        #[command(subcommand)]
        command: AgentsCommand,
    },
    /// Orchestrate workers: dispatch apm start --next --spawn in a loop
    #[command(
        long_about = "Orchestration loop: repeatedly dispatch agents until no work remains.

Calls `apm start --next --spawn` in a loop, launching one Claude subprocess
per actionable ticket, until `apm next` returns null (no more tickets).

--dry-run prints the ticket IDs that would be started without actually
spawning any subprocesses — useful to preview the work queue.

-P passes --dangerously-skip-permissions to every spawned worker.

--daemon keeps the process alive after the queue is exhausted, polling at
--interval seconds (default 30) and dispatching new workers as slots open
or tickets become actionable. Ctrl-C stops the daemon; already-running
workers continue independently.

Example:
  apm work --dry-run           # preview
  apm work                     # run with normal permissions
  apm work -P                  # run with skipped permissions
  apm work --daemon            # run forever, poll every 30s
  apm work --daemon --interval 60  # poll every 60s"
    )]
    Work {
        /// Pass --dangerously-skip-permissions to spawned workers
        #[arg(long, short = 'P')]
        skip_permissions: bool,
        /// Print which tickets would be started without dispatching
        #[arg(long)]
        dry_run: bool,
        /// Keep running after the queue is exhausted; re-check as slots open
        #[arg(long, short = 'd')]
        daemon: bool,
        /// Poll interval in seconds when running as a daemon (default: 30)
        #[arg(long, default_value = "30")]
        interval: u64,
        /// Restrict dispatching to tickets in this epic (8-char ID)
        #[arg(long, value_name = "EPIC_ID")]
        epic: Option<String>,
    },
    /// Move a ticket into or out of an epic
    #[command(long_about = "Move an existing ticket into (or out of) an epic.

Rebases the ticket's branch onto the target epic's branch tip, then updates
the ticket's `epic` and `target_branch` frontmatter fields in place. The
ticket keeps its original ID and branch name; only the branch base changes.

Move into an epic:
  apm move <ticket> <epic_id>

Move between epics:
  apm move <ticket> <epic_id_2>

Remove from epic (rebase onto main):
  apm move <ticket> -

Both <ticket> and <epic_id> accept 4–8 char hex prefixes. Use \"-\" as the
second argument to detach from any current epic.")]
    Move {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "TICKET")]
        ticket: String,
        /// Epic ID prefix, or \"-\" to remove from any epic (rebase onto main)
        #[arg(value_name = "EPIC")]
        target: String,
    },
    /// Force-close a ticket from any state (supervisor only)
    #[command(long_about = "Force-close a ticket from any state (supervisor only).

Bypasses the normal state machine and closes the ticket immediately,
regardless of current state. An optional reason is appended to the ticket's
## History section for the record.

This is an escape hatch for tickets that are abandoned, duplicated, or
otherwise need to be removed from the active queue without following the
normal flow. Prefer the standard `apm state <id> closed` transition when
the ticket has been properly resolved.

Example:
  apm close 42 --reason \"duplicate of #38\"")]
    Close {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        id: String,
        /// Optional reason appended to the history entry
        #[arg(long)]
        reason: Option<String>,
        /// Skip automatic git fetch/push
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Move closed ticket files to the archive directory
    #[command(
        long_about = "Move terminal-state ticket files from tickets/ to the configured archive_dir.

Requires `archive_dir` under the [tickets] section of .apm/config.toml:

  [tickets]
  archive_dir = \"archive/tickets\"

Examples:
  apm archive                        # archive all closed tickets
  apm archive --dry-run              # preview which files would be moved
  apm archive --older-than 30d       # archive only tickets updated >30 days ago
  apm archive --older-than 2026-01-01  # ISO date threshold"
    )]
    Archive {
        /// Print which files would be moved without modifying any branches
        #[arg(long)]
        dry_run: bool,
        /// Only archive tickets whose updated_at is older than this threshold (e.g. \"30d\" or \"2026-01-01\")
        #[arg(long, value_name = "THRESHOLD")]
        older_than: Option<String>,
    },
    /// Remove worktrees and (optionally) ticket branches for closed tickets
    #[command(
        long_about = "Remove worktrees (and optionally branches) for terminal-state tickets.

Default (no flags): removes worktrees only. Branches are never touched
without --branches. APM only ever deletes branches matching `ticket/*`
or `epic/*` — never any other branch.

  apm clean                              # remove worktrees only
  apm clean --dry-run                    # preview worktree removals
  apm clean --branches                   # also delete local + remote ticket/* branches
  apm clean --branches --older-than 30d  # only tickets last updated >30d ago
  apm clean --branches --older-than 2026-01-01  # ISO date threshold
  apm clean --untracked                  # also remove untracked non-temp files
  apm clean --force                      # bypass merge/divergence checks

Known temp files (.apm-worker.pid, .apm-worker.log, pr-body.md, body.md,
ac.txt) are always removed automatically without needing --untracked."
    )]
    Clean {
        /// Print what would be removed without modifying anything
        #[arg(long)]
        dry_run: bool,
        /// Skip per-branch confirmation prompts
        #[arg(long, short = 'y')]
        yes: bool,
        /// Bypass merge and divergence checks; always prompts before each removal
        #[arg(long)]
        force: bool,
        /// Also delete local + remote ticket/* branches (default: worktrees only)
        #[arg(long)]
        branches: bool,
        /// Filter to tickets last updated before this threshold: "30d" or "YYYY-MM-DD"
        #[arg(long, value_name = "THRESHOLD")]
        older_than: Option<String>,
        /// Remove untracked non-temp files from worktrees before removal
        #[arg(long)]
        untracked: bool,
        /// Also clean local and remote branches for "done" epics
        #[arg(long)]
        epics: bool,
    },
    /// List and manage running worker processes
    Workers {
        /// Tail the worker log for the given ticket ID
        #[arg(long, value_name = "ID")]
        log: Option<String>,
        /// Kill the worker for the given ticket ID
        #[arg(long, value_name = "ID")]
        kill: Option<String>,
        #[command(subcommand)]
        subcmd: Option<WorkersCommand>,
    },
    /// Manage epics
    Epic {
        #[command(subcommand)]
        command: EpicCommand,
    },
    /// Pull default-branch updates into an epic branch
    RefreshEpic {
        /// Epic ID (4–8 char hex prefix)
        id: String,
        /// Perform a local merge of the default branch into the epic branch
        #[arg(long, conflicts_with_all = ["pr", "auto_mode"])]
        merge: bool,
        /// Open or update a PR from the default branch into the epic branch
        #[arg(long, conflicts_with_all = ["merge", "auto_mode"])]
        pr: bool,
        /// Merge locally when clean, fall back to PR when there are conflicts
        #[arg(long = "auto", conflicts_with_all = ["merge", "pr"])]
        auto_mode: bool,
    },
    /// Read or write individual spec sections of a ticket
    #[command(long_about = "Read or write individual sections of a ticket's spec.

--section alone reads the named section and prints it to stdout:
  apm spec 42 --section Problem

--section combined with --set writes new content to that section (use \"-\"
to read the new content from stdin):
  apm spec 42 --section Approach --set \"New approach text\"
  echo \"text\" | apm spec 42 --section Approach --set -

--check validates that all required sections defined in .apm/config.toml are
present and non-empty:
  apm spec 42 --check

--mark checks off the first unchecked item in --section whose text contains
the given substring:
  apm spec 42 --section \"Acceptance criteria\" --mark \"output is JSON\"")]
    Spec {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: String,
        /// Section name (e.g. "Problem", "Approach")
        #[arg(long)]
        section: Option<String>,
        /// New content for the section; use "-" to read from stdin
        #[arg(long, allow_hyphen_values = true)]
        set: Option<String>,
        /// Read new section content from this file
        #[arg(long, value_name = "PATH", conflicts_with = "set")]
        set_file: Option<String>,
        /// Check that all required sections are non-empty
        #[arg(long)]
        check: bool,
        /// Mark the first unchecked item matching this text in --section as done
        #[arg(long)]
        mark: Option<String>,
        /// Append content to the section without replacing existing content
        #[arg(long, allow_hyphen_values = true, conflicts_with_all = ["set", "set_file", "append_file", "add_task"])]
        append: Option<String>,
        /// Read content to append from this file
        #[arg(long, value_name = "PATH", conflicts_with_all = ["set", "set_file", "append", "add_task"])]
        append_file: Option<String>,
        /// Append a new unchecked task item (`- [ ] <text>`) to a tasks-typed section
        #[arg(long, conflicts_with_all = ["set", "set_file", "append", "append_file"])]
        add_task: Option<String>,
        /// Skip automatic git fetch/push
        #[arg(long)]
        no_aggressive: bool,
    },
    /// Generate a one-time password for device registration (requires apm-server)
    Register {
        /// Username to register (defaults to GitHub username)
        username: Option<String>,
    },
    /// List active sessions (requires apm-server)
    Sessions,
    /// Revoke sessions (requires apm-server)
    Revoke {
        /// Username whose sessions to revoke (required unless --all)
        #[arg(value_name = "USERNAME")]
        username: Option<String>,
        /// Only revoke sessions matching this device hint
        #[arg(long, value_name = "HINT")]
        device: Option<String>,
        /// Revoke all sessions for all users
        #[arg(long, conflicts_with = "device")]
        all: bool,
    },
    /// Print the full worker context (system prompt + user message) for a ticket
    #[command(long_about = "Print the complete worker context that would be used if the ticket's\n\
current command:start transition fired, then exit 0. The ticket is not\n\
modified and no worker is spawned.\n\
\n\
By default, both parts are printed with section headers:\n\
\n\
  === system ===\n\
  <system prompt>\n\
\n\
  === user ===\n\
  <user message>\n\
\n\
Use --system or --message to narrow to one part (no headers).\n\
\n\
SYSTEM PROMPT — three layers, concatenated in order:\n\
\n\
  Layer 1 — Role instructions (cascade, highest priority wins)\n\
    0. .apm/agents/<agent>/apm.<role>.md     (per-agent file)\n\
    1. .apm/agents/claude/apm.<role>.md      (claude fallback for non-claude agents)\n\
    2. built-in default for known agents\n\
    3. error — no instructions found\n\
\n\
    Place role instructions in .apm/agents/<agent>/apm.<role>.md for\n\
    agent-specific overrides, or .apm/agents/claude/apm.<role>.md as\n\
    the shared default for all agents without a specific file.\n\
\n\
  Layer 2 — Project context\n\
    The file at [agents].project in config.toml (typically .apm/project.md).\n\
    Omitted if the key is not set or the path is empty.\n\
\n\
  Layer 3 — APM system knowledge\n\
    Generated by `apm instructions --role <role>` (dynamic, role-scoped).\n\
    Covers: state machine, ticket format, session identity, and command reference.\n\
\n\
USER MESSAGE — dependency bundle + ticket:\n\
\n\
  If the ticket has deps, a dependency context bundle is prepended:\n\
  approach sections, commit history, and transitive dep summaries.\n\
  Followed by the agent/role prefix and the serialised ticket content.\n\
\n\
Without a ticket ID, --agent and --role determine the mode:\n\
  • Both provided: print the system prompt for that agent+role. All three\n\
    layers are included when both --agent and --role are provided.\n\
  • Either missing: print the agents and roles discovered in .apm/agents/.\n\
\n\
--agent and --role override the resolved values for inspection only;\n\
they do not affect the ticket or any config.\n\
\n\
--explain prints a compact provenance table instead of the prompt text:\n\
\n\
  System prompt for claude/worker — 3 layers composed:\n\
\n\
    1  .apm/agents/claude/apm.worker.md\n\
    2  .apm/project.md\n\
    3  apm instructions (dynamic)\n\
\n\
  If a fallback file was used, a note appears inline under layer 1:\n\
    1  .apm/agents/claude/apm.worker.md\n\
       (fallback — .apm/agents/myagent/apm.worker.md not found)\n\
\n\
Exits non-zero with a clear message when no instructions can be resolved.\n\
\n\
Examples:\n\
  apm prompt                                          # list available agents and roles\n\
  apm prompt --agent claude --role coder             # base prompt for claude/coder\n\
  apm prompt --agent claude --role coder --explain   # provenance without a ticket\n\
  apm prompt ba121f45                                 # full context (system + user)\n\
  apm prompt ba121f45 --system                        # system prompt only\n\
  apm prompt ba121f45 --message                       # user message only\n\
  apm prompt ba121f45 --agent pi                      # inspect pi-agent prompt\n\
  apm prompt ba121f45 --role spec-writer              # inspect spec-writer role\n\
  apm prompt ba121f45 --explain                       # show layer provenance")]
    Prompt {
        /// Ticket ID (8-char hex, 4+ char prefix, or plain integer)
        #[arg(value_name = "ID")]
        id: Option<String>,
        /// Override the resolved agent for inspection (does not affect the ticket)
        #[arg(long)]
        agent: Option<String>,
        /// Override the resolved role for inspection (does not affect the ticket)
        #[arg(long)]
        role: Option<String>,
        /// Print system prompt only (no user message, no section headers)
        #[arg(long, conflicts_with = "message")]
        system: bool,
        /// Print user message only (no system prompt, no section headers)
        #[arg(long, conflicts_with = "system")]
        message: bool,
        /// Print provenance table instead of prompt text
        #[arg(long)]
        explain: bool,
    },
    /// Output APM system knowledge for agents: state machine, ticket format, session identity, and command reference
    Instructions {
        /// Ticket ID — substitutes <id> placeholders in command output
        ticket_id: Option<String>,
        /// Scope output to this role (e.g. worker, spec-writer); omit for role listing
        #[arg(long, value_name = "ROLE")]
        role: Option<String>,
    },
    /// Print version and build type
    Version,
    /// Show help for a topic: commands, config, workflow, ticket
    Help {
        /// Topic to display (commands, config, workflow, ticket); omit for overview
        topic: Option<String>,
    },
}

pub fn repo_root() -> Result<PathBuf> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .context("git not found")?;
    if !output.status.success() {
        anyhow::bail!("not inside a git repository");
    }
    Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    // path-guard is called by Claude Code's PreToolUse hook and must not depend
    // on repo_root(). Dispatch it early before any git or config I/O.
    if matches!(cli.command, Command::PathGuard) {
        cmd::path_guard::run();
        return Ok(());
    }

    let root = repo_root()?;
    if let Ok(ref config) = apm_core::config::Config::load(&root) {
        if config.logging.enabled {
            let log_path = apm_core::logger::resolve_log_path(
                &config.project.name,
                config.logging.file.as_deref(),
            );
            if let Some(parent) = log_path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let agent = std::env::var("APM_AGENT_NAME").unwrap_or_else(|_| "apm".to_string());
            apm_core::logger::init(&root, &log_path, &agent);
        }
    }
    let args: Vec<String> = std::env::args().skip(1).collect();
    apm_core::logger::log("cmd", &args.join(" "));
    if !hash_trip::is_exempt_command(&cli.command) {
        match hash_trip::run(&root)? {
            hash_trip::HashTripOutcome::Clean | hash_trip::HashTripOutcome::PassedAndRefreshed => {}
            hash_trip::HashTripOutcome::Failed(issues) => {
                for (subject, msg) in &issues {
                    #[allow(clippy::print_stderr)]
                    {
                        eprintln!("  {}: {}", subject, msg);
                    }
                }
                if hash_trip::is_read_only_command(&cli.command) {
                    #[allow(clippy::print_stderr)]
                    {
                        eprintln!("warning: config has changed and apm validate is failing.");
                    }
                    #[allow(clippy::print_stderr)]
                    {
                        eprintln!("Run apm validate to see details and fix the issues.");
                    }
                } else {
                    #[allow(clippy::print_stderr)]
                    {
                        eprintln!("error: config has changed and validation is failing.");
                    }
                    #[allow(clippy::print_stderr)]
                    {
                        eprintln!("Mutating commands are blocked. Run apm validate to fix.");
                    }
                    std::process::exit(2);
                }
            }
        }
    }
    match cli.command {
        Command::Init {
            no_claude,
            migrate,
            with_docker,
            quiet,
            yes,
        } => cmd::init::run(&root, no_claude, migrate, with_docker, quiet, yes),
        Command::List {
            state,
            unassigned,
            all,
            actionable,
            no_aggressive,
            mine,
            author,
            owner,
        } => cmd::list::run(
            &root,
            state,
            unassigned,
            all,
            actionable,
            no_aggressive,
            mine,
            author,
            owner,
        ),
        Command::New {
            title,
            no_edit,
            side_note,
            context,
            context_section,
            no_aggressive,
            section,
            set,
            epic,
            depends_on,
        } => cmd::new::run(
            &root,
            title,
            no_edit,
            side_note,
            context,
            context_section,
            no_aggressive,
            section,
            set,
            epic,
            depends_on,
        ),
        Command::Show {
            id,
            no_aggressive,
            edit,
        } => cmd::show::run(&root, &id, no_aggressive, edit),
        Command::State {
            id,
            state,
            no_aggressive,
            force,
        } => cmd::state::run(&root, &id, state, no_aggressive, force),
        Command::Set {
            id,
            field,
            value,
            no_aggressive,
        } => cmd::set::run(&root, &id, field, value, no_aggressive),
        Command::Next {
            json,
            no_aggressive,
        } => cmd::next::run(&root, json, no_aggressive),
        Command::Start {
            id,
            no_aggressive,
            spawn,
            skip_permissions,
            next,
        } => match (next, id) {
            (true, Some(_)) => anyhow::bail!("--next and an explicit ID are mutually exclusive"),
            (true, None) => cmd::start::run_next(&root, no_aggressive, spawn, skip_permissions),
            (false, Some(id)) => {
                let caller_name = apm_core::config::resolve_caller_name();
                cmd::start::run(
                    &root,
                    &id,
                    no_aggressive,
                    spawn,
                    skip_permissions,
                    &caller_name,
                )
            }
            (false, None) => anyhow::bail!("provide a ticket ID or use --next"),
        },
        Command::Sync {
            offline,
            quiet,
            no_aggressive,
            auto_close,
            push_default,
            push_refs,
        } => cmd::sync::run(
            &root,
            offline,
            quiet,
            no_aggressive,
            auto_close,
            push_default,
            push_refs,
        ),
        Command::Assign {
            id,
            username,
            no_aggressive,
            force,
        } => cmd::assign::run(&root, &id, &username, no_aggressive, force),
        Command::Worktrees { remove } => cmd::worktrees::run(&root, remove.as_deref()),
        Command::Review {
            id,
            to,
            no_aggressive,
        } => cmd::review::run(&root, &id, to, no_aggressive),
        Command::Validate {
            fix,
            json,
            config_only,
            no_aggressive,
            verbose,
        } => cmd::validate::run(&root, fix, json, config_only, no_aggressive, verbose),
        Command::Hook { hook_name, .. } => {
            cmd::hook::run(&root, &hook_name);
            Ok(())
        }
        Command::PathGuard => unreachable!("handled before repo_root()"),
        Command::Agents {
            command: AgentsCommand::List,
        } => cmd::agents::run_list(&root),
        Command::Agents {
            command: AgentsCommand::New { name, force },
        } => cmd::agents::run_new(&root, &name, force),
        Command::Agents {
            command: AgentsCommand::Test { name },
        } => cmd::agents::run_test(&root, &name),
        Command::Agents {
            command: AgentsCommand::Eject { name },
        } => cmd::agents::run_eject(&root, &name),
        Command::Agents {
            command: AgentsCommand::Resolve { ticket_id, json },
        } => cmd::agents::run_resolve(&root, &ticket_id, json),
        Command::Work {
            skip_permissions,
            dry_run,
            daemon,
            interval,
            epic,
        } => cmd::work::run(&root, skip_permissions, dry_run, daemon, interval, epic),
        Command::Move { ticket, target } => cmd::move_ticket::run(&root, &ticket, &target),
        Command::Close {
            id,
            reason,
            no_aggressive,
        } => cmd::close::run(&root, &id, reason, no_aggressive),
        Command::Archive {
            dry_run,
            older_than,
        } => cmd::archive::run(&root, dry_run, older_than),
        Command::Clean {
            dry_run,
            yes,
            force,
            branches,
            older_than,
            untracked,
            epics,
        } => cmd::clean::run(
            &root, dry_run, yes, force, branches, older_than, untracked, epics,
        ),
        Command::Spec {
            id,
            section,
            set,
            set_file,
            check,
            mark,
            append,
            append_file,
            add_task,
            no_aggressive,
        } => cmd::spec::run(
            &root,
            &id,
            section,
            set,
            set_file,
            check,
            mark,
            append,
            append_file,
            add_task,
            no_aggressive,
        ),
        Command::Workers { log, kill, subcmd } => {
            if let Some(WorkersCommand::Diag { id }) = subcmd {
                cmd::workers::run_diag(&root, &id)
            } else {
                cmd::workers::run(&root, log.as_deref(), kill.as_deref())
            }
        }
        Command::Epic {
            command: EpicCommand::New { title },
        } => cmd::epic::run_new(&root, title),
        Command::Epic {
            command: EpicCommand::Close { id },
        } => cmd::epic::run_close(&root, &id),
        Command::Epic {
            command: EpicCommand::List,
        } => cmd::epic::run_list(&root),
        Command::Epic {
            command: EpicCommand::Show { id, no_aggressive },
        } => cmd::epic::run_show(&root, &id, no_aggressive),
        Command::Epic {
            command: EpicCommand::Set { id, field, value },
        } => cmd::epic::run_set(&root, &id, &field, &value),
        Command::RefreshEpic { id, merge, pr, auto_mode } => cmd::epic::run_refresh_epic(&root, &id, merge, pr, auto_mode),
        Command::Register { username } => {
            let inferred = username.is_none();
            let config = apm_core::config::Config::load(&root)?;
            let username = username.unwrap_or_else(|| {
                apm_core::config::try_github_username(&config.git_host)
                    .expect("could not detect GitHub username; pass one explicitly")
            });
            cmd::register::run(&root, &username, inferred)
        }
        Command::Sessions => cmd::sessions::run(&root),
        Command::Revoke {
            username,
            device,
            all,
        } => {
            if !all && username.is_none() {
                eprintln!("error: provide a username or use --all");
                std::process::exit(1);
            }
            cmd::revoke::run(&root, username.as_deref(), device.as_deref(), all)
        }
        Command::Prompt { id, agent, role, system, message, explain } => cmd::prompt::run(&root, id.as_deref(), agent, role, system, message, explain),
        Command::Instructions { ticket_id, role } => cmd::instructions::run(Cli::command(), &root, role.as_deref(), ticket_id.as_deref()),
        Command::Version => {
            cmd::version::run();
            Ok(())
        }
        Command::Help { topic } => cmd::help::run(topic.as_deref(), Cli::command()),
    }
}

use apm::cmd;
