use std::path::Path;
use std::process::Command;

use anyhow::{bail, Context, Result};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
use serde::Serialize;
use tracing::debug;

pub(crate) const KEY_FILES_SECTION_TITLE: &str = "Key Files for This Session";
pub(crate) const TRUNK_BRANCH_NAMES: &[&str] = &["main", "master", "develop"];

pub(crate) const REQUIRED_SECTIONS: &[&str] = &[
    "## Immediate Actions",
    "## Completed State",
    "## Operational Guardrails",
    "## Key Files for This Session",
    "## Definition of Done",
];

pub(crate) fn is_trunk_branch(branch: &str) -> bool {
    TRUNK_BRANCH_NAMES.contains(&branch)
}

#[derive(Serialize, Clone)]
pub(crate) struct GitState {
    pub(crate) head: String,
    pub(crate) branch: String,
    pub(crate) upstream: Option<String>,
    pub(crate) ahead: usize,
    pub(crate) behind: usize,
    pub(crate) clean: bool,
    pub(crate) staged_files: Vec<String>,
    pub(crate) unstaged_files: Vec<String>,
    pub(crate) untracked_files: Vec<String>,
    pub(crate) recent_commits: Vec<GitCommit>,
}

#[derive(Serialize, Clone)]
pub(crate) struct GitCommit {
    pub(crate) sha: String,
    pub(crate) subject: String,
}

#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy)]
pub(crate) enum BranchMode {
    RequireNamedBranch,
    AllowDetachedHead,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CheckoutContinuityAdvisory {
    DetachedHead,
    LandedBranch { trunk: String },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CheckoutStateStatus {
    NamedBranch,
    DetachedHead,
    LandedBranch,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct CheckoutStateView {
    pub(crate) status: CheckoutStateStatus,
    pub(crate) advisory: bool,
    pub(crate) summary: String,
}

impl CheckoutContinuityAdvisory {
    fn advisory_summary(&self, git: &GitState) -> String {
        match self {
            Self::DetachedHead => format!("checkout is detached at HEAD `{}`", git.head),
            Self::LandedBranch { trunk } => format!(
                "branch `{}` is already landed on local `origin/{trunk}`",
                git.branch
            ),
        }
    }

    pub(crate) fn start_alert_message(&self, git: &GitState) -> String {
        format!(
            "{}; `ccd start` preserves session continuity instead of auto-resetting the handoff, and live checkout context remains advisory only, so verify whether the current session should continue or be cleared deliberately",
            self.advisory_summary(git)
        )
    }

    pub(crate) fn doctor_message(&self, git: &GitState) -> String {
        format!(
            "{}; continuity analysis remains available and checkout context is derived live",
            self.advisory_summary(git)
        )
    }

    pub(crate) fn handoff_refresh_warning(&self, git: &GitState) -> String {
        match self {
            Self::DetachedHead => format!(
                "checkout is detached at HEAD `{}`; `ccd handoff refresh` reports live checkout context without mutating the handoff",
                git.head
            ),
            Self::LandedBranch { trunk } => format!(
                "branch `{}` is already landed on local `origin/{trunk}`; `ccd handoff refresh` reports live checkout context without mutating the handoff",
                git.branch
            ),
        }
    }
}

type ParsedStatus = (
    String,
    Option<String>,
    usize,
    usize,
    Vec<String>,
    Vec<String>,
    Vec<String>,
);

pub(crate) fn read_git_state(repo_root: &Path, branch_mode: BranchMode) -> Result<GitState> {
    let (head, status_output, log_output) = std::thread::scope(|s| {
        let h = s.spawn(|| git_output(repo_root, &["rev-parse", "--short", "HEAD"]));
        let st = s.spawn(|| git_output(repo_root, &["status", "--porcelain=v1", "--branch"]));
        let lg = s.spawn(|| git_output(repo_root, &["log", "--oneline", "-5"]));
        (
            h.join().expect("git rev-parse thread panicked"),
            st.join().expect("git status thread panicked"),
            lg.join().expect("git log thread panicked"),
        )
    });
    let head = head?;
    let status_output = status_output?;
    let log_output = log_output?;

    let (branch, upstream, ahead, behind, staged_files, unstaged_files, untracked_files) =
        parse_status(&status_output, branch_mode)?;
    let recent_commits = parse_recent_commits(&log_output);
    let clean = staged_files.is_empty() && unstaged_files.is_empty() && untracked_files.is_empty();

    Ok(GitState {
        head,
        branch,
        upstream,
        ahead,
        behind,
        clean,
        staged_files,
        unstaged_files,
        untracked_files,
        recent_commits,
    })
}

pub(crate) fn current_system_state_lines(
    git: Option<&GitState>,
    session_id: Option<&str>,
) -> Vec<String> {
    refresh_current_system_state(&[], git, session_id)
}

fn normalize_section_label(section: &str) -> &str {
    section.trim().trim_start_matches('#').trim()
}

fn find_section_bounds(contents: &str, section: &str) -> Option<(usize, usize)> {
    let section = normalize_section_label(section);
    let parser = Parser::new_ext(contents, Options::empty());
    let mut start = None;
    let mut heading_byte = 0;
    let mut in_h2 = false;

    for (event, range) in parser.into_offset_iter() {
        match event {
            Event::Start(Tag::Heading {
                level: HeadingLevel::H2,
                ..
            }) => {
                if let Some(s) = start {
                    return Some((s, range.start));
                }
                heading_byte = range.start;
                in_h2 = true;
            }
            Event::Text(text) if in_h2 => {
                in_h2 = false;
                if text.as_ref().starts_with(section) {
                    start = Some(heading_byte);
                }
            }
            _ => {
                in_h2 = false;
            }
        }
    }

    start.map(|value| (value, contents.len()))
}

pub(crate) fn has_section(contents: &str, section: &str) -> bool {
    find_section_bounds(contents, section).is_some()
}

pub(crate) fn checkout_continuity_advisory(
    repo_root: &Path,
    git: &GitState,
) -> Option<CheckoutContinuityAdvisory> {
    if git.branch == "HEAD" {
        return Some(CheckoutContinuityAdvisory::DetachedHead);
    }

    branch_merged_into_trunk(repo_root, &git.branch)
        .map(|trunk| CheckoutContinuityAdvisory::LandedBranch { trunk })
}

pub(crate) fn checkout_state_view(repo_root: &Path, git: &GitState) -> CheckoutStateView {
    match checkout_continuity_advisory(repo_root, git) {
        Some(CheckoutContinuityAdvisory::DetachedHead) => CheckoutStateView {
            status: CheckoutStateStatus::DetachedHead,
            advisory: true,
            summary: format!(
                "checkout is detached at HEAD `{}`; continuity analysis remains available and checkout snapshot drift is advisory only",
                git.head
            ),
        },
        Some(CheckoutContinuityAdvisory::LandedBranch { trunk }) => CheckoutStateView {
            status: CheckoutStateStatus::LandedBranch,
            advisory: true,
            summary: format!(
                "branch `{}` is already landed on local `origin/{trunk}`; continuity analysis remains available and checkout snapshot drift is advisory only",
                git.branch
            ),
        },
        None => CheckoutStateView {
            status: CheckoutStateStatus::NamedBranch,
            advisory: false,
            summary: format!(
                "branch `{}` uses standard continuity and checkout freshness rules",
                git.branch
            ),
        },
    }
}

pub(crate) fn extract_title(contents: &str) -> Result<String> {
    let parser = Parser::new_ext(contents, Options::empty());
    let mut in_h1 = false;

    for (event, _range) in parser.into_offset_iter() {
        match event {
            Event::Start(Tag::Heading {
                level: HeadingLevel::H1,
                ..
            }) => {
                in_h1 = true;
            }
            Event::Text(text) if in_h1 => {
                return Ok(text.to_string());
            }
            _ => {
                in_h1 = false;
            }
        }
    }

    bail!("handoff.md is missing a top-level title")
}

pub(crate) fn extract_numbered_section(contents: &str, section: &str) -> Vec<String> {
    extract_section_lines(contents, section)
        .into_iter()
        .filter_map(parse_numbered_item)
        .collect()
}

pub(crate) fn extract_bulleted_section(contents: &str, section: &str) -> Vec<String> {
    extract_section_lines(contents, section)
        .into_iter()
        .filter_map(|line| line.strip_prefix("- ").map(str::trim).map(str::to_owned))
        .collect()
}

fn refresh_current_system_state(
    current_lines: &[String],
    git: Option<&GitState>,
    session_id: Option<&str>,
) -> Vec<String> {
    let mut refreshed = Vec::new();
    if let Some(sid) = session_id {
        refreshed.push(format!("Session: `{sid}`"));
    }
    if let Some(git) = git {
        refreshed.push(format!("Branch: `{}`", git.branch));
        refreshed.push(render_head_line(git));
        refreshed.push(render_worktree_status_line(git));
    }

    refreshed.extend(
        current_lines
            .iter()
            .filter(|line| {
                matches!(
                    classify_current_system_state_line(line),
                    CurrentSystemStateLineKind::Other
                )
            })
            .cloned(),
    );

    refreshed
}

fn render_head_line(git: &GitState) -> String {
    match git.recent_commits.first() {
        Some(commit) => format!("HEAD: `{}` (`{}`)", git.head, commit.subject),
        None => format!("HEAD: `{}`", git.head),
    }
}

fn render_worktree_status_line(git: &GitState) -> String {
    let tracking_state = if git.branch == "HEAD" {
        "checkout is detached".to_owned()
    } else {
        match (git.upstream.as_deref(), git.ahead, git.behind) {
            (Some(upstream), 0, 0) => format!("`{}` is in sync with `{upstream}`", git.branch),
            (Some(upstream), ahead, 0) => {
                format!(
                    "`{}` is ahead of `{upstream}` by {ahead} commit(s)",
                    git.branch
                )
            }
            (Some(upstream), 0, behind) => {
                format!(
                    "`{}` is behind `{upstream}` by {behind} commit(s)",
                    git.branch
                )
            }
            (Some(upstream), ahead, behind) => format!(
                "`{}` is ahead of `{upstream}` by {ahead} commit(s) and behind by {behind} commit(s)",
                git.branch
            ),
            (None, _, _) => format!("`{}` has no configured upstream", git.branch),
        }
    };

    if git.clean {
        return format!("The tracked worktree is clean and {tracking_state}.");
    }

    let mut changes = Vec::new();
    if !git.staged_files.is_empty() {
        changes.push(format!("staged: {}", git.staged_files.join(", ")));
    }
    if !git.unstaged_files.is_empty() {
        changes.push(format!("unstaged: {}", git.unstaged_files.join(", ")));
    }
    if !git.untracked_files.is_empty() {
        changes.push(format!("untracked: {}", git.untracked_files.join(", ")));
    }

    if changes.is_empty() {
        format!("The tracked worktree is dirty and {tracking_state}.")
    } else {
        format!(
            "The tracked worktree is dirty ({}) and {tracking_state}.",
            changes.join("; ")
        )
    }
}

enum CurrentSystemStateLineKind {
    Session,
    Branch,
    Head,
    Worktree,
    Other,
}

fn classify_current_system_state_line(line: &str) -> CurrentSystemStateLineKind {
    if line.starts_with("Session: `") {
        return CurrentSystemStateLineKind::Session;
    }
    if line.starts_with("Branch: `") {
        return CurrentSystemStateLineKind::Branch;
    }
    if line.starts_with("HEAD: `") {
        return CurrentSystemStateLineKind::Head;
    }

    let lower = line.to_ascii_lowercase();
    if lower.contains("worktree")
        || lower.contains("branch is clean")
        || lower.contains("ahead of `")
        || lower.contains("behind `")
        || lower.contains("in sync with `")
        || lower.contains("has no configured upstream")
    {
        CurrentSystemStateLineKind::Worktree
    } else {
        CurrentSystemStateLineKind::Other
    }
}

fn extract_section_lines<'a>(contents: &'a str, section: &str) -> Vec<&'a str> {
    let Some((start, end)) = find_section_bounds(contents, section) else {
        return Vec::new();
    };
    let body = &contents[start..end];
    // Skip the heading line itself, then collect non-empty lines.
    body.lines()
        .skip_while(|line| line.starts_with("## "))
        .filter(|line| !line.trim().is_empty())
        .collect()
}

fn parse_numbered_item(line: &str) -> Option<String> {
    let trimmed = line.trim();
    let dot = trimmed.find(". ")?;
    trimmed[..dot].parse::<usize>().ok()?;
    Some(trimmed[dot + 2..].trim().to_owned())
}

fn parse_recent_commits(output: &str) -> Vec<GitCommit> {
    output
        .lines()
        .filter_map(|line| {
            let (sha, subject) = line.split_once(' ')?;
            Some(GitCommit {
                sha: sha.to_owned(),
                subject: subject.to_owned(),
            })
        })
        .collect()
}

fn parse_status(output: &str, branch_mode: BranchMode) -> Result<ParsedStatus> {
    let mut lines = output.lines();
    let branch_line = lines
        .next()
        .ok_or_else(|| anyhow::anyhow!("git status did not return branch metadata"))?;
    let (branch, upstream, ahead, behind) = parse_branch_line(branch_line, branch_mode)?;

    let mut staged_files = Vec::new();
    let mut unstaged_files = Vec::new();
    let mut untracked_files = Vec::new();

    for line in lines {
        if let Some(path) = line.strip_prefix("?? ") {
            untracked_files.push(path.to_owned());
            continue;
        }

        if line.len() < 4 {
            continue;
        }

        let mut chars = line.chars();
        let x = chars.next().unwrap_or(' ');
        let y = chars.next().unwrap_or(' ');
        let path = line[3..].to_owned();

        if x != ' ' {
            staged_files.push(path.clone());
        }
        if y != ' ' {
            unstaged_files.push(path);
        }
    }

    Ok((
        branch,
        upstream,
        ahead,
        behind,
        staged_files,
        unstaged_files,
        untracked_files,
    ))
}

fn parse_branch_line(
    line: &str,
    branch_mode: BranchMode,
) -> Result<(String, Option<String>, usize, usize)> {
    let raw = line
        .strip_prefix("## ")
        .ok_or_else(|| anyhow::anyhow!("unexpected git status header: {line}"))?;

    if raw == "HEAD (no branch)" {
        return match branch_mode {
            BranchMode::RequireNamedBranch => {
                bail!("detached HEAD is not supported by `ccd handover` yet")
            }
            BranchMode::AllowDetachedHead => Ok(("HEAD".to_owned(), None, 0, 0)),
        };
    }

    let mut branch_and_tracking = raw;
    let mut ahead = 0usize;
    let mut behind = 0usize;

    if let Some((left, right)) = raw.split_once(" [") {
        branch_and_tracking = left;
        let counts = right.trim_end_matches(']');
        for entry in counts.split(", ") {
            if let Some(value) = entry.strip_prefix("ahead ") {
                ahead = value.parse().unwrap_or(0);
            }
            if let Some(value) = entry.strip_prefix("behind ") {
                behind = value.parse().unwrap_or(0);
            }
        }
    }

    let (branch, upstream) = if let Some((branch, upstream)) = branch_and_tracking.split_once("...")
    {
        (branch.to_owned(), Some(upstream.to_owned()))
    } else {
        (branch_and_tracking.to_owned(), None)
    };

    Ok((branch, upstream, ahead, behind))
}

/// Check whether the current branch has been fully merged into the remote trunk.
///
/// Returns `Some(trunk_branch)` when:
///   1. `HEAD` appears as a direct parent of a merge commit on `origin/<trunk>`, OR
///   2. the branch actually diverged from trunk (has unique commits) and
///      merging `HEAD` into `origin/<trunk>` would be a no-op at the tree level.
///
/// Condition 2's divergence guard prevents false positives on stale branches
/// that were created from an older trunk commit but never received any work —
/// once trunk moves forward, a pure tree-level no-op check alone would
/// misclassify them as "merged."
///
/// The no-op merge test covers the three GitHub merge modes that matter here:
/// merge commits, squash merges, and rebase/cherry-pick style linearization.
///
/// Only remote-tracking refs (`origin/main`, `origin/master`, `origin/develop`)
/// are checked, and the detector does not fetch.
/// A branch merged into *local* main but not yet pushed to the remote is not
/// considered "done" because the work has not landed in the shared trunk.
///
/// Trunk branches themselves (`main`, `master`, `develop`) always return
/// `None` to avoid accidental resets on the primary development branch.
pub(crate) fn branch_merged_into_trunk(repo_root: &Path, branch: &str) -> Option<String> {
    // Never reset trunk branches themselves.
    if is_trunk_branch(branch) {
        return None;
    }

    let head_sha = git_output_optional(repo_root, &["rev-parse", "HEAD"]);
    let head_sha = head_sha?;

    for trunk in TRUNK_BRANCH_NAMES {
        let ref_name = format!("origin/{trunk}");
        let trunk_tree_ref = format!("{ref_name}^{{tree}}");

        // Only check remote-tracking refs — local trunk may contain work that
        // has not been pushed yet, which is broader than the intended scope
        // ("PR merged on the remote").
        if git_output_optional(repo_root, &["rev-parse", "--verify", &ref_name]).is_none() {
            continue;
        }

        // Fast path for merge-commit workflows: the branch tip itself appears
        // as a direct parent of a merge commit on the remote trunk.
        if git_exit_code(
            repo_root,
            &["merge-base", "--is-ancestor", "HEAD", &ref_name],
        ) == Some(0)
        {
            let merge_parents = git_output_optional(
                repo_root,
                &[
                    "log",
                    "--merges",
                    "--ancestry-path",
                    "--format=%P",
                    &format!("{head_sha}..{ref_name}"),
                ],
            );
            if merge_parents.as_deref().is_some_and(|parents| {
                parents
                    .lines()
                    .any(|line| line.split_whitespace().any(|parent| parent == head_sha))
            }) {
                return Some(trunk.to_string());
            }
        }

        let merge_base = git_output_optional(repo_root, &["merge-base", "HEAD", &ref_name]);
        let Some(merge_base) = merge_base else {
            continue;
        };

        // Branches that never diverged from trunk should not be classified as
        // "merged" just because trunk moved forward after they were created.
        // Real merge-commit integration was already handled by the fast path.
        if merge_base == head_sha {
            continue;
        }

        // Empty or fully reverted branches produce no net tree changes and are
        // not treated as completed remote work.
        if git_exit_code(repo_root, &["diff", "--quiet", &merge_base, "HEAD"]) == Some(0) {
            continue;
        }

        // If merging the branch tip back into the remote trunk produces the
        // exact same tree as the current trunk tip, the branch's net effect is
        // already present upstream regardless of whether that happened through
        // merge, squash, or rebase/cherry-pick.
        let merged_tree = git_output_optional(
            repo_root,
            &["merge-tree", "--write-tree", &ref_name, "HEAD"],
        );
        let trunk_tree = git_output_optional(repo_root, &["rev-parse", &trunk_tree_ref]);
        if merged_tree.as_deref() != trunk_tree.as_deref() {
            continue;
        }

        return Some(trunk.to_string());
    }

    None
}

fn git_output(repo_root: &Path, args: &[&str]) -> Result<String> {
    debug!(args = ?args, dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(args)
        .output()
        .with_context(|| format!("failed to run git {}", args.join(" ")))?;
    debug!(success = output.status.success(), "git completed");

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
        let detail = if !stderr.is_empty() { stderr } else { stdout };
        bail!("git {} failed: {detail}", args.join(" "));
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}

fn git_output_optional(repo_root: &Path, args: &[&str]) -> Option<String> {
    debug!(args = ?args, dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(args)
        .output()
        .ok()?;
    debug!(success = output.status.success(), "git completed");
    if !output.status.success() {
        return None;
    }

    Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}

fn git_exit_code(repo_root: &Path, args: &[&str]) -> Option<i32> {
    debug!(args = ?args, dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(args)
        .output()
        .ok()?;
    debug!(success = output.status.success(), "git completed");
    output.status.code()
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── extract_title ───────────────────────────────────────────────

    #[test]
    fn extract_title_returns_h1_text() {
        let doc = "# My Handoff\n\n## Section\n\nBody text.";
        assert_eq!(extract_title(doc).unwrap(), "My Handoff");
    }

    #[test]
    fn extract_title_ignores_h2() {
        let doc = "## Not A Title\n\nBody text.";
        assert!(extract_title(doc).is_err());
    }

    #[test]
    fn extract_title_empty_document() {
        assert!(extract_title("").is_err());
    }

    // ── find_section_bounds ─────────────────────────────────────────

    #[test]
    fn find_section_bounds_single_section() {
        let doc = "# Title\n\n## Alpha\n\nContent here.\n";
        let (start, end) = find_section_bounds(doc, "Alpha").unwrap();
        assert!(doc[start..end].contains("Alpha"));
        assert!(doc[start..end].contains("Content here."));
    }

    #[test]
    fn find_section_bounds_middle_section() {
        let doc = "## One\n\nA\n\n## Two\n\nB\n\n## Three\n\nC\n";
        let (start, end) = find_section_bounds(doc, "Two").unwrap();
        let section = &doc[start..end];
        assert!(section.contains("Two"));
        assert!(section.contains("B"));
        assert!(!section.contains("C"));
    }

    #[test]
    fn find_section_bounds_last_section_extends_to_end() {
        let doc = "## First\n\nA\n\n## Last\n\nZ\n";
        let (start, end) = find_section_bounds(doc, "Last").unwrap();
        assert_eq!(end, doc.len());
        assert!(doc[start..end].contains("Z"));
    }

    #[test]
    fn find_section_bounds_missing_returns_none() {
        let doc = "## Alpha\n\nA\n";
        assert!(find_section_bounds(doc, "Missing").is_none());
    }

    #[test]
    fn has_section_matches_immediate_actions_with_suffix() {
        let doc = "## Immediate Actions (in order)\n\n1. Review the state.\n";
        assert!(has_section(doc, "## Immediate Actions"));
    }

    #[test]
    fn has_section_does_not_treat_similar_key_files_heading_as_match() {
        let doc = "## Key Files Changed\n\n- `src/lib.rs`\n";
        assert!(!has_section(doc, "## Key Files for This Session"));
    }

    // ── extract_bulleted_section ────────────────────────────────────

    #[test]
    fn extract_bulleted_section_normal() {
        let doc = "## Items\n\n- First\n- Second\n- Third\n";
        let items = extract_bulleted_section(doc, "Items");
        assert_eq!(items, vec!["First", "Second", "Third"]);
    }

    #[test]
    fn extract_bulleted_section_strips_whitespace() {
        let doc = "## Items\n\n-   Padded  \n";
        let items = extract_bulleted_section(doc, "Items");
        assert_eq!(items, vec!["Padded"]);
    }

    #[test]
    fn extract_bulleted_section_missing() {
        let doc = "## Other\n\n- X\n";
        assert!(extract_bulleted_section(doc, "Missing").is_empty());
    }

    #[test]
    fn extract_bulleted_section_skips_non_bullet_lines() {
        let doc = "## Items\n\nNarrative line.\n- Real item\nAnother line.\n";
        let items = extract_bulleted_section(doc, "Items");
        assert_eq!(items, vec!["Real item"]);
    }

    // ── extract_numbered_section ────────────────────────────────────

    #[test]
    fn extract_numbered_section_normal() {
        let doc = "## Steps\n\n1. Alpha\n2. Beta\n3. Gamma\n";
        let items = extract_numbered_section(doc, "Steps");
        assert_eq!(items, vec!["Alpha", "Beta", "Gamma"]);
    }

    #[test]
    fn extract_numbered_section_skips_malformed() {
        let doc = "## Steps\n\n1. Good\nNot numbered\n2. Also good\n";
        let items = extract_numbered_section(doc, "Steps");
        assert_eq!(items, vec!["Good", "Also good"]);
    }

    #[test]
    fn extract_numbered_section_missing() {
        assert!(extract_numbered_section("## Other\n\n1. X\n", "Missing").is_empty());
    }

    // ── parse_numbered_item ─────────────────────────────────────────

    #[test]
    fn parse_numbered_item_valid() {
        assert_eq!(
            parse_numbered_item("1. Do the thing"),
            Some("Do the thing".into())
        );
    }

    #[test]
    fn parse_numbered_item_high_number() {
        assert_eq!(
            parse_numbered_item("42. Item forty-two"),
            Some("Item forty-two".into())
        );
    }

    #[test]
    fn parse_numbered_item_not_a_number() {
        assert_eq!(parse_numbered_item("abc. Not valid"), None);
    }

    #[test]
    fn parse_numbered_item_no_space_after_dot() {
        assert_eq!(parse_numbered_item("1.No space"), None);
    }

    // ── parse_branch_line ───────────────────────────────────────────

    #[test]
    fn parse_branch_line_simple() {
        let (branch, upstream, ahead, behind) =
            parse_branch_line("## main", BranchMode::RequireNamedBranch).unwrap();
        assert_eq!(branch, "main");
        assert!(upstream.is_none());
        assert_eq!(ahead, 0);
        assert_eq!(behind, 0);
    }

    #[test]
    fn parse_branch_line_with_tracking() {
        let (branch, upstream, ahead, behind) =
            parse_branch_line("## main...origin/main", BranchMode::RequireNamedBranch).unwrap();
        assert_eq!(branch, "main");
        assert_eq!(upstream.as_deref(), Some("origin/main"));
        assert_eq!(ahead, 0);
        assert_eq!(behind, 0);
    }

    #[test]
    fn parse_branch_line_ahead_behind() {
        let (branch, upstream, ahead, behind) = parse_branch_line(
            "## feature...origin/feature [ahead 3, behind 2]",
            BranchMode::RequireNamedBranch,
        )
        .unwrap();
        assert_eq!(branch, "feature");
        assert_eq!(upstream.as_deref(), Some("origin/feature"));
        assert_eq!(ahead, 3);
        assert_eq!(behind, 2);
    }

    #[test]
    fn parse_branch_line_ahead_only() {
        let (_, _, ahead, behind) = parse_branch_line(
            "## dev...origin/dev [ahead 5]",
            BranchMode::RequireNamedBranch,
        )
        .unwrap();
        assert_eq!(ahead, 5);
        assert_eq!(behind, 0);
    }

    #[test]
    fn parse_branch_line_detached_head_allowed() {
        let (branch, upstream, _, _) =
            parse_branch_line("## HEAD (no branch)", BranchMode::AllowDetachedHead).unwrap();
        assert_eq!(branch, "HEAD");
        assert!(upstream.is_none());
    }

    #[test]
    fn parse_branch_line_detached_head_rejected() {
        assert!(parse_branch_line("## HEAD (no branch)", BranchMode::RequireNamedBranch).is_err());
    }

    #[test]
    fn parse_branch_line_missing_prefix() {
        assert!(parse_branch_line("main", BranchMode::RequireNamedBranch).is_err());
    }

    // ── parse_status ────────────────────────────────────────────────

    #[test]
    fn parse_status_clean() {
        let output = "## main...origin/main";
        let (branch, upstream, ahead, behind, staged, unstaged, untracked) =
            parse_status(output, BranchMode::RequireNamedBranch).unwrap();
        assert_eq!(branch, "main");
        assert_eq!(upstream.as_deref(), Some("origin/main"));
        assert_eq!((ahead, behind), (0, 0));
        assert!(staged.is_empty());
        assert!(unstaged.is_empty());
        assert!(untracked.is_empty());
    }

    #[test]
    fn parse_status_with_untracked() {
        let output = "## main\n?? new_file.rs\n?? another.txt";
        let (_, _, _, _, staged, unstaged, untracked) =
            parse_status(output, BranchMode::RequireNamedBranch).unwrap();
        assert!(staged.is_empty());
        assert!(unstaged.is_empty());
        assert_eq!(untracked, vec!["new_file.rs", "another.txt"]);
    }

    #[test]
    fn parse_status_staged_and_unstaged() {
        let output = "## main\nM  staged.rs\n M unstaged.rs";
        let (_, _, _, _, staged, unstaged, untracked) =
            parse_status(output, BranchMode::RequireNamedBranch).unwrap();
        assert_eq!(staged, vec!["staged.rs"]);
        assert_eq!(unstaged, vec!["unstaged.rs"]);
        assert!(untracked.is_empty());
    }

    #[test]
    fn parse_status_both_staged_and_unstaged_same_file() {
        let output = "## main\nMM both.rs";
        let (_, _, _, _, staged, unstaged, _) =
            parse_status(output, BranchMode::RequireNamedBranch).unwrap();
        assert_eq!(staged, vec!["both.rs"]);
        assert_eq!(unstaged, vec!["both.rs"]);
    }

    #[test]
    fn parse_status_empty_fails() {
        assert!(parse_status("", BranchMode::RequireNamedBranch).is_err());
    }

    // ── parse_recent_commits ────────────────────────────────────────

    #[test]
    fn parse_recent_commits_normal() {
        let output = "abc1234 fix: typo in readme\ndef5678 feat: add new command";
        let commits = parse_recent_commits(output);
        assert_eq!(commits.len(), 2);
        assert_eq!(commits[0].sha, "abc1234");
        assert_eq!(commits[0].subject, "fix: typo in readme");
        assert_eq!(commits[1].sha, "def5678");
        assert_eq!(commits[1].subject, "feat: add new command");
    }

    #[test]
    fn parse_recent_commits_empty() {
        assert!(parse_recent_commits("").is_empty());
    }

    // ── render_worktree_status_line ─────────────────────────────────

    fn make_git_state(
        branch: &str,
        upstream: Option<&str>,
        ahead: usize,
        behind: usize,
        clean: bool,
    ) -> GitState {
        GitState {
            head: "abc1234".into(),
            branch: branch.into(),
            upstream: upstream.map(Into::into),
            ahead,
            behind,
            clean,
            staged_files: Vec::new(),
            unstaged_files: Vec::new(),
            untracked_files: Vec::new(),
            recent_commits: Vec::new(),
        }
    }

    #[test]
    fn render_worktree_clean_in_sync() {
        let git = make_git_state("main", Some("origin/main"), 0, 0, true);
        let line = render_worktree_status_line(&git);
        assert!(line.contains("clean"));
        assert!(line.contains("in sync with `origin/main`"));
    }

    #[test]
    fn render_worktree_clean_ahead() {
        let git = make_git_state("dev", Some("origin/dev"), 3, 0, true);
        let line = render_worktree_status_line(&git);
        assert!(line.contains("ahead of `origin/dev` by 3 commit(s)"));
    }

    #[test]
    fn render_worktree_no_upstream() {
        let git = make_git_state("feature", None, 0, 0, true);
        let line = render_worktree_status_line(&git);
        assert!(line.contains("has no configured upstream"));
    }

    #[test]
    fn render_worktree_dirty_with_staged() {
        let mut git = make_git_state("main", Some("origin/main"), 0, 0, false);
        git.staged_files = vec!["file.rs".into()];
        let line = render_worktree_status_line(&git);
        assert!(line.contains("dirty"));
        assert!(line.contains("staged: file.rs"));
    }

    // ── current_system_state_lines ──────────────────────────────────

    #[test]
    fn current_system_state_lines_are_derived_from_live_git_state() {
        let git = make_git_state("main", Some("origin/main"), 0, 0, true);
        let lines = current_system_state_lines(Some(&git), Some("ses_TEST"));
        assert_eq!(lines[0], "Session: `ses_TEST`");
        assert_eq!(lines[1], "Branch: `main`");
        assert!(lines[2].starts_with("HEAD: `abc1234`"));
        assert_eq!(
            lines[3],
            "The tracked worktree is clean and `main` is in sync with `origin/main`."
        );
    }

    #[test]
    fn current_system_state_lines_skip_git_sections_when_git_is_unavailable() {
        let lines = current_system_state_lines(None, Some("ses_TEST"));
        assert_eq!(lines, vec!["Session: `ses_TEST`".to_owned()]);
    }

    // ── classify_current_system_state_line ───────────────────────────

    #[test]
    fn classify_branch_line() {
        assert!(matches!(
            classify_current_system_state_line("Branch: `main`"),
            CurrentSystemStateLineKind::Branch
        ));
    }

    #[test]
    fn classify_head_line() {
        assert!(matches!(
            classify_current_system_state_line("HEAD: `abc1234`"),
            CurrentSystemStateLineKind::Head
        ));
    }

    #[test]
    fn classify_worktree_line() {
        assert!(matches!(
            classify_current_system_state_line("The tracked worktree is clean"),
            CurrentSystemStateLineKind::Worktree
        ));
    }

    #[test]
    fn classify_session_line() {
        assert!(matches!(
            classify_current_system_state_line("Session: `01JNQX1234`"),
            CurrentSystemStateLineKind::Session
        ));
    }

    #[test]
    fn classify_other_line() {
        assert!(matches!(
            classify_current_system_state_line("All 203 tests pass"),
            CurrentSystemStateLineKind::Other
        ));
    }
}
