use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use tracing::debug;

use crate::commands::start::START_RENDER_SECTION_ORDER;
use crate::handoff::{self, GitState};
use crate::paths::state::StateLayout;
use crate::state::compiled as compiled_state;
use crate::state::consistency;
use crate::state::projection_metadata;
use crate::state::reference_paths::{
    collect_prose_path_candidates, extract_key_files_candidates, token_is_forward_reference,
};
use crate::state::runtime as runtime_state;
use crate::state::session as session_state;

use super::{
    BehavioralDriftInputs, BehavioralDriftSignal, BehavioralDriftState, DriftAggregateStatus,
    DriftSignalStatus, HandoffState,
};

pub(super) const CANONICAL_PROMPT_SURFACE_ORDER: &[&str] = &[
    "effective_policy",
    "effective_memory",
    "execution_gates",
    "handoff",
];
pub(super) const CANONICAL_COMPILED_SURFACE_ORDER: &[&str] = &[
    "effective_memory",
    "handoff",
    "execution_gates",
    "escalation",
    "recovery",
    "git_state",
    "session_state",
];

pub(super) struct ProjectionObservationLoad {
    pub(super) observations: Vec<projection_metadata::ProjectionObservation>,
    pub(super) warnings: Vec<String>,
}

pub(super) fn build_behavioral_drift(
    repo_root: &Path,
    inputs: BehavioralDriftInputs<'_>,
) -> BehavioralDriftState {
    let prefix_observations = projection_observations_for_session(
        inputs.layout,
        inputs.locality_id,
        inputs.runtime,
        inputs.tracked_session,
    );

    let signals = vec![
        build_handoff_structure_signal(inputs.handoff_contents),
        build_handoff_expectations_signal(inputs.tracked_session, inputs.git, inputs.handoff),
        build_handoff_references_signal(repo_root, inputs.runtime_handoff),
        build_handoff_issue_references_signal(
            repo_root,
            inputs.runtime_handoff,
            inputs.tracked_session,
        ),
        build_handoff_milestone_progression_signal(
            repo_root,
            inputs.runtime_handoff,
            inputs.tracked_session,
        ),
        build_surface_order_signal(),
        build_compiled_state_churn_signal(
            inputs.layout,
            inputs.tracked_session,
            &prefix_observations,
        ),
        build_tool_surface_signal(inputs.tracked_session, &prefix_observations),
    ]
    .into_iter()
    .chain(
        inputs
            .consistency_axes
            .into_iter()
            .map(map_consistency_axis),
    )
    .collect::<Vec<_>>();

    let drifted = signals
        .iter()
        .filter(|signal| signal.status == DriftSignalStatus::Drift)
        .collect::<Vec<_>>();
    let no_signal_count = signals
        .iter()
        .filter(|signal| signal.status == DriftSignalStatus::NoSignal)
        .count();

    if !drifted.is_empty() {
        let mut recommended_corrections = Vec::new();
        for correction in drifted
            .iter()
            .filter_map(|signal| signal.recommended_correction.clone())
        {
            if !recommended_corrections.contains(&correction) {
                recommended_corrections.push(correction);
            }
        }

        return BehavioralDriftState {
            status: DriftAggregateStatus::NeedsRecalibration,
            summary: format!(
                "{} behavioral drift signal(s) need explicit recalibration before wrap-up is treated as clean.",
                drifted.len()
            ),
            evidence: drifted
                .iter()
                .flat_map(|signal| signal.evidence.clone())
                .collect(),
            recommended_corrections,
            signals,
        };
    }

    if no_signal_count == signals.len() {
        return BehavioralDriftState {
            status: DriftAggregateStatus::NoSignal,
            summary: "Radar has no deterministic behavioral-drift signal beyond the base evaluation surfaces."
                .to_owned(),
            evidence: Vec::new(),
            recommended_corrections: Vec::new(),
            signals,
        };
    }

    BehavioralDriftState {
        status: DriftAggregateStatus::Aligned,
        summary: "No confirmed behavioral drift was detected from the available CCD-local signals."
            .to_owned(),
        evidence: Vec::new(),
        recommended_corrections: Vec::new(),
        signals,
    }
}

fn map_consistency_axis(axis: consistency::ConsistencyAxis) -> BehavioralDriftSignal {
    BehavioralDriftSignal {
        id: axis.id,
        status: match axis.status {
            consistency::ConsistencyStatus::Aligned => DriftSignalStatus::Aligned,
            consistency::ConsistencyStatus::Drift => DriftSignalStatus::Drift,
            consistency::ConsistencyStatus::NoSignal => DriftSignalStatus::NoSignal,
        },
        summary: axis.summary,
        evidence: axis.evidence,
        recommended_correction: axis.recommended_correction,
    }
}

#[derive(Clone, Debug)]
enum HandoffReferenceOutcome {
    Resolved,
    Missing,
    OutsideRepo { reason: &'static str },
}

fn classify_handoff_reference(
    repo_root: &Path,
    canonical_root: &Path,
    candidate: &str,
) -> HandoffReferenceOutcome {
    let path = Path::new(candidate);
    if path.is_absolute() {
        return HandoffReferenceOutcome::OutsideRepo {
            reason: "absolute path",
        };
    }
    if path
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return HandoffReferenceOutcome::OutsideRepo {
            reason: "parent-directory traversal escapes the repo root",
        };
    }
    let target = repo_root.join(path);
    if !target.exists() {
        return HandoffReferenceOutcome::Missing;
    }
    // Symlinks inside the checkout can still point outside the repo. Only
    // accept the candidate if the canonical resolved path is still under
    // the canonical repo root, so a symlink that escapes the checkout
    // surfaces as drift just like an absolute or `..` reference would.
    let canonical_target = match target.canonicalize() {
        Ok(target) => target,
        Err(_) => return HandoffReferenceOutcome::Missing,
    };
    if canonical_target.starts_with(canonical_root) {
        HandoffReferenceOutcome::Resolved
    } else {
        HandoffReferenceOutcome::OutsideRepo {
            reason: "symlink target escapes the repo root",
        }
    }
}

/// Outcome for a single handoff entry that may have produced multiple
/// candidate paths. The entry resolves if any one candidate resolves;
/// otherwise drift is attributed to the entry as a whole so evidence stays
/// keyed to the operator's original text.
enum HandoffEntryOutcome {
    Resolved,
    Missing,
    OutsideRepo(&'static str),
}

fn classify_handoff_entry(
    repo_root: &Path,
    canonical_root: &Path,
    candidates: &[String],
) -> HandoffEntryOutcome {
    let mut last_outside: Option<&'static str> = None;
    for candidate in candidates {
        match classify_handoff_reference(repo_root, canonical_root, candidate) {
            HandoffReferenceOutcome::Resolved => return HandoffEntryOutcome::Resolved,
            HandoffReferenceOutcome::Missing => {}
            HandoffReferenceOutcome::OutsideRepo { reason } => last_outside = Some(reason),
        }
    }
    match last_outside {
        Some(reason) => HandoffEntryOutcome::OutsideRepo(reason),
        None => HandoffEntryOutcome::Missing,
    }
}

fn build_handoff_references_signal(
    repo_root: &Path,
    handoff: &runtime_state::RuntimeHandoffState,
) -> BehavioralDriftSignal {
    let canonical_root = match repo_root.canonicalize() {
        Ok(root) => root,
        Err(_) => {
            return drift(
                "handoff_references_resolved",
                "The current handoff still references paths that could not be validated against the repo root.",
                vec![format!(
                    "Could not canonicalize repo root for handoff reference validation: {}",
                    repo_root.display()
                )],
                "Re-run `ccd handover` from a valid repo checkout so CCD can validate handoff references against the repo root.",
            )
        }
    };
    let mut missing: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut outside: std::collections::BTreeMap<String, &'static str> =
        std::collections::BTreeMap::new();

    // `render_handoff_markdown` omits inactive items from the operator-
    // facing export; mirror that filter here so archived references whose
    // files have since been deleted do not raise drift for prose the
    // operator no longer sees.

    // `key_files` entries are explicit file references, regardless of
    // whether they happen to contain `/` or a known extension. Each entry
    // may produce multiple candidate paths (e.g., the full text plus a
    // prefix-before-" - "); the entry resolves if any one candidate does,
    // so legitimate filenames like `docs/ADR - retry budget.md` are not
    // mistaken for `docs/ADR` and false-flagged.
    for item in handoff.key_files.iter().filter(|i| i.lifecycle.is_active()) {
        let candidates = extract_key_files_candidates(&item.text);
        if candidates.is_empty() {
            continue;
        }
        match classify_handoff_entry(repo_root, &canonical_root, &candidates) {
            HandoffEntryOutcome::Resolved => {}
            HandoffEntryOutcome::Missing => {
                missing.insert(item.text.clone());
            }
            HandoffEntryOutcome::OutsideRepo(reason) => {
                outside.insert(item.text.clone(), reason);
            }
        }
    }

    // Prose-bearing fields use the path-like heuristic so we do not flag
    // every backticked identifier or commit hash. Prose tokens are
    // deduplicated across fields to keep evidence concise.
    let mut prose_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    let prose_fields: [&[runtime_state::RuntimeHandoffItem]; 3] = [
        &handoff.immediate_actions,
        &handoff.operational_guardrails,
        &handoff.definition_of_done,
    ];
    for field in prose_fields {
        for item in field.iter().filter(|i| i.lifecycle.is_active()) {
            let mut tokens: Vec<String> = Vec::new();
            collect_prose_path_candidates(&item.text, &mut tokens);
            for token in tokens {
                if !prose_seen.insert(token.clone()) {
                    continue;
                }
                match classify_handoff_reference(repo_root, &canonical_root, &token) {
                    HandoffReferenceOutcome::Resolved => {}
                    HandoffReferenceOutcome::Missing => {
                        // Forward-references: tokens governed by a
                        // create-action verb name deliverables the
                        // session is chartered to produce, not stale
                        // inputs. Per-token clause analysis (ccd#597)
                        // lets mixed lines like `Read docs/input.md and
                        // publish docs/output.md` still flag the input
                        // while suppressing the output. OutsideRepo
                        // outcomes are handled below and not suppressed.
                        if token_is_forward_reference(&item.text, &token) {
                            continue;
                        }
                        missing.insert(token);
                    }
                    HandoffReferenceOutcome::OutsideRepo { reason } => {
                        outside.insert(token, reason);
                    }
                }
            }
        }
    }

    if missing.is_empty() && outside.is_empty() {
        return aligned(
            "handoff_references_resolved",
            "Every handoff path reference resolves to a repo-local path under the repo root.",
            Vec::new(),
        );
    }

    let mut evidence: Vec<String> = Vec::new();
    if !missing.is_empty() {
        evidence.push(format!(
            "{} handoff path reference(s) no longer exist on disk:",
            missing.len()
        ));
        evidence.extend(
            missing
                .iter()
                .map(|path| format!("- {}", markdown_list_literal(path))),
        );
    }
    if !outside.is_empty() {
        evidence.push(format!(
            "{} handoff path reference(s) are not repo-local:",
            outside.len()
        ));
        evidence.extend(
            outside
                .iter()
                .map(|(path, reason)| format!("- {} ({reason})", markdown_list_literal(path))),
        );
    }

    drift(
        "handoff_references_resolved",
        "The current handoff still references paths that are missing or live outside the repo root.",
        evidence,
        "Rewrite the handoff so its key_files, immediate actions, guardrails, and definition of done only reference repo-local paths that still exist, then run `ccd handoff write` to persist the corrected handoff.",
    )
}

/// Commit that landed on HEAD at or after the current session-start
/// epoch (per committer date) along with the GitHub issue numbers
/// referenced in its subject or body.
pub(super) struct SessionCommitRef {
    pub(super) short_hash: String,
    pub(super) subject: String,
    pub(super) issues: BTreeSet<u32>,
}

/// Extract GitHub issue numbers out of commit prose.
///
/// Matches the deterministic surface the handoff-staleness detector in
/// ccd#570 calls out: the autoclose keywords (`close[sd]?`, `fix(e[sd])?`,
/// `resolve[sd]?`), the `Refs #N` convention, optionally qualified with
/// `ccd` (`closes ccd#N`), and the squash-merge trailer `(#N)` that
/// GitHub appends to merged-PR subjects. Anything else — including bare
/// `#N` tokens in free prose — is intentionally not matched to keep the
/// false-positive surface narrow.
fn extract_commit_issue_refs(text: &str, out: &mut BTreeSet<u32>) {
    const KEYWORDS: &[&str] = &[
        "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
        "ref", "refs",
    ];
    let lower = text.to_ascii_lowercase();
    let bytes = lower.as_bytes();

    for keyword in KEYWORDS {
        let kw_bytes = keyword.as_bytes();
        let mut cursor = 0;
        while cursor + kw_bytes.len() <= bytes.len() {
            match lower[cursor..].find(keyword) {
                Some(rel) => {
                    let start = cursor + rel;
                    let end = start + kw_bytes.len();
                    let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
                    let after_ok = bytes.get(end).is_none_or(|c| !c.is_ascii_alphabetic());
                    if before_ok && after_ok {
                        if let Some(issue) = parse_issue_suffix(&lower[end..]) {
                            out.insert(issue);
                        }
                    }
                    cursor = start + 1;
                }
                None => break,
            }
        }
    }

    // Squash-merge trailer: `... (#123)`.
    let mut cursor = 0;
    while let Some(rel) = lower[cursor..].find("(#") {
        let start = cursor + rel + 2;
        let tail = &lower[start..];
        let digit_end = tail
            .find(|c: char| !c.is_ascii_digit())
            .unwrap_or(tail.len());
        if digit_end > 0 && tail.as_bytes().get(digit_end) == Some(&b')') {
            if let Ok(issue) = tail[..digit_end].parse::<u32>() {
                out.insert(issue);
            }
        }
        cursor = start;
    }
}

fn parse_issue_suffix(tail: &str) -> Option<u32> {
    let trimmed = tail.trim_start_matches(|c: char| c == ':' || c.is_ascii_whitespace());
    let without_prefix = trimmed.strip_prefix("ccd").unwrap_or(trimmed);
    let without_hash = without_prefix.strip_prefix('#')?;
    let digit_end = without_hash
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(without_hash.len());
    if digit_end == 0 {
        return None;
    }
    without_hash[..digit_end].parse::<u32>().ok()
}

/// Extract GitHub issue tokens from the forward-looking handoff fields
/// that a new session would act on — `title` plus the `text` of any
/// active `immediate_actions` entry. Lifecycle-inactive entries are
/// omitted so archived references do not raise drift for prose the
/// operator no longer sees, matching `build_handoff_references_signal`.
fn extract_handoff_issue_refs(handoff: &runtime_state::RuntimeHandoffState) -> BTreeSet<u32> {
    let mut out = BTreeSet::new();
    collect_handoff_issue_tokens(&handoff.title, &mut out);
    for item in handoff
        .immediate_actions
        .iter()
        .filter(|i| i.lifecycle.is_active())
    {
        collect_handoff_issue_tokens(&item.text, &mut out);
    }
    out
}

fn collect_handoff_issue_tokens(text: &str, out: &mut BTreeSet<u32>) {
    let bytes = text.as_bytes();
    let mut idx = 0;
    while idx < bytes.len() {
        if bytes[idx] == b'#' {
            let digit_start = idx + 1;
            let mut digit_end = digit_start;
            while digit_end < bytes.len() && bytes[digit_end].is_ascii_digit() {
                digit_end += 1;
            }
            if digit_end > digit_start {
                // Trailing boundary keeps tokens like `#1234 (something)` clean and
                // avoids consuming run-on alphanumerics (the scanner would have
                // already refused a leading letter since `#` is the gate).
                if bytes
                    .get(digit_end)
                    .is_none_or(|c| !c.is_ascii_alphanumeric())
                {
                    if let Ok(issue) = text[digit_start..digit_end].parse::<u32>() {
                        out.insert(issue);
                    }
                }
                idx = digit_end;
                continue;
            }
        }
        idx += 1;
    }
}

/// Walk `HEAD` commits that actually landed at or after the current
/// session-start epoch and report the ones whose subject or body names
/// a GitHub issue.
///
/// `--since=@<epoch>` bounds the walk at the git level — confirmed
/// empirically to filter on **committer** date, not author date — so
/// the subprocess does not emit every ancestor commit in very old
/// repos before the Rust side short-circuits. The in-process
/// `committer_epoch < session_start` check is kept as defense in
/// depth: documentation on which date `--since` uses has historically
/// been ambiguous, and validating `%ct` per record makes the filter
/// robust to future git behavior changes and gives us explicit
/// evidence on which commit the walk stopped at. A cherry-pick or
/// rebase that preserved an older author date still lands with a
/// fresh committer date and remains detected.
pub(super) fn collect_session_commits(
    repo_root: &Path,
    session_start_epoch_s: u64,
) -> Result<Vec<SessionCommitRef>, String> {
    use std::process::Command;
    let since_arg = format!("--since=@{session_start_epoch_s}");
    debug!(
        args = ?["log", "HEAD", since_arg.as_str(), "--format"],
        session_start_epoch_s,
        dir = %repo_root.display(),
        "spawning git log for handoff issue-reference drift"
    );
    let output = Command::new("git")
        .args([
            "log",
            "HEAD",
            &since_arg,
            "--format=%ct%x1f%h%x1f%s%x1f%B%x1e",
        ])
        .current_dir(repo_root)
        .output()
        .map_err(|err| format!("failed to spawn `git log`: {err}"))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let detail = stderr.trim();
        if detail.is_empty() {
            return Err("`git log` exited non-zero".to_owned());
        }
        return Err(format!("`git log` failed: {detail}"));
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut commits = Vec::new();
    for record in stdout.split('\x1e') {
        let record = record.trim_matches(|c: char| c == '\n' || c == '\r');
        if record.is_empty() {
            continue;
        }
        let mut parts = record.splitn(4, '\x1f');
        let committer_epoch = parts.next().unwrap_or("").trim().parse::<u64>().ok();
        let hash = parts.next().unwrap_or("").trim().to_owned();
        let subject = parts.next().unwrap_or("").trim().to_owned();
        let body = parts.next().unwrap_or("");
        let Some(ct) = committer_epoch else { continue };
        if ct < session_start_epoch_s {
            // `git log` walks HEAD in reverse-chronological order by
            // default, so once a commit is older than session_start the
            // rest of the walk is too. Short-circuit instead of scanning
            // every ancestor commit in the history.
            break;
        }
        if hash.is_empty() {
            continue;
        }
        let mut issues = BTreeSet::new();
        extract_commit_issue_refs(&subject, &mut issues);
        extract_commit_issue_refs(body, &mut issues);
        commits.push(SessionCommitRef {
            short_hash: hash,
            subject,
            issues,
        });
    }
    Ok(commits)
}

/// Aggregate every path touched by commits in `session_start..HEAD`.
///
/// Performance-minded counterpart to [`collect_session_commits`]: the
/// on-agent-end hook also wants the set of files the session has moved,
/// which it intersects with the handoff's `key_files` to flag completion
/// signals. A single `git log --name-only` pass is faster than 1-per-commit
/// `git show` calls and already bounded by `--since=@<epoch>`.
///
/// `git log --pretty=format:` strips the commit headers entirely, so the
/// stdout is a newline-separated list of paths (with blank lines between
/// commits, which we skip). We do **not** filter by committer date here
/// because `--since` already bounds the walk; any slack from the flag's
/// ambiguous date semantics is acceptable for an aggregated set.
pub(super) fn collect_session_touched_paths(
    repo_root: &Path,
    session_start_epoch_s: u64,
) -> Result<BTreeSet<String>, String> {
    use std::process::Command;
    let since_arg = format!("--since=@{session_start_epoch_s}");
    let output = Command::new("git")
        .args(["log", "HEAD", &since_arg, "--pretty=format:", "--name-only"])
        .current_dir(repo_root)
        .output()
        .map_err(|err| format!("failed to spawn `git log --name-only`: {err}"))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let detail = stderr.trim();
        if detail.is_empty() {
            return Err("`git log --name-only` exited non-zero".to_owned());
        }
        return Err(format!("`git log --name-only` failed: {detail}"));
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut paths = BTreeSet::new();
    for line in stdout.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        paths.insert(trimmed.to_owned());
    }
    Ok(paths)
}

pub(super) fn build_handoff_issue_references_signal(
    repo_root: &Path,
    handoff: &runtime_state::RuntimeHandoffState,
    tracked_session: Option<&session_state::SessionStateFile>,
) -> BehavioralDriftSignal {
    let session_start = match tracked_session {
        Some(session) if session.started_at_epoch_s > 0 => session.started_at_epoch_s,
        _ => {
            return no_signal(
                "handoff_issue_references_fresh",
                "No active CCD session boundary is available; cannot bound `session_start..HEAD` for handoff issue-reference drift.",
                Vec::new(),
            );
        }
    };

    let handoff_issues = extract_handoff_issue_refs(handoff);
    if handoff_issues.is_empty() {
        return aligned(
            "handoff_issue_references_fresh",
            "The current handoff title and immediate actions do not reference any GitHub issue tokens, so session-bounded commits cannot stale them.",
            Vec::new(),
        );
    }

    let commits = match collect_session_commits(repo_root, session_start) {
        Ok(commits) => commits,
        Err(reason) => {
            // Fail closed: the handoff names issues that the detector
            // was supposed to validate against `session_start..HEAD`,
            // and commit history could not be read. Returning
            // `no_signal` here would let the ccd-radar fast path
            // declare the handoff clean without the one check meant to
            // catch stale issue references ever running. Force a
            // rewrite instead so the operator re-resolves the handoff
            // against whatever substrate the git read actually
            // surfaces.
            return drift(
                "handoff_issue_references_fresh",
                "Could not read the session-bounded commit history needed to validate handoff issue references; staleness cannot be ruled out.",
                vec![
                    format!(
                        "{} handoff issue reference(s) remain unverified:",
                        handoff_issues.len()
                    ),
                    handoff_issues
                        .iter()
                        .map(|issue| format!("#{issue}"))
                        .collect::<Vec<_>>()
                        .join(", "),
                    format!("git log read failure: {reason}"),
                ],
                "Re-run `ccd handover` from a valid git checkout so CCD can walk `session_start..HEAD` and decide whether the handoff still names advanced issues; if the read keeps failing, manually reconcile the handoff's title and immediate_actions against the issues the session landed before closing out.",
            );
        }
    };

    let mut hits: BTreeMap<u32, Vec<&SessionCommitRef>> = BTreeMap::new();
    for commit in &commits {
        for issue in &commit.issues {
            if handoff_issues.contains(issue) {
                hits.entry(*issue).or_default().push(commit);
            }
        }
    }

    if hits.is_empty() {
        return aligned(
            "handoff_issue_references_fresh",
            "No commits in `session_start..HEAD` close or reference GitHub issues named in the current handoff.",
            Vec::new(),
        );
    }

    let mut evidence: Vec<String> = Vec::with_capacity(hits.len() + 1);
    evidence.push(format!(
        "{} handoff issue reference(s) have been advanced by commits in `session_start..HEAD`:",
        hits.len()
    ));
    for (issue, commits) in &hits {
        let joined = commits
            .iter()
            .map(|commit| format!("{} ({})", commit.short_hash, commit.subject))
            .collect::<Vec<_>>()
            .join("; ");
        evidence.push(format!("- #{issue}: {joined}"));
    }

    drift(
        "handoff_issue_references_fresh",
        "The current handoff still references GitHub issues that commits landed during this session have closed or advanced.",
        evidence,
        "Rewrite the handoff so its title and immediate_actions no longer reference GitHub issues that were closed or materially advanced by commits in `session_start..HEAD`, then run `ccd handoff write` to persist the corrected handoff.",
    )
}

/// Milestone-shaped label family recognised by the ccd#598 detector.
///
/// The first landing is deliberately narrow: exact-phrase matching on
/// `M<digits>` and `Phase <uppercase-letter>`. Anything fuzzier (slice
/// numbers, stage labels, work-item IDs) needs an explicit opt-in from
/// the handoff before it joins this enum.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum MilestoneFamily {
    M,
    Phase,
}

#[derive(Clone, Debug)]
struct MilestoneToken {
    family: MilestoneFamily,
    value: u32,
    literal: String,
}

/// Scan `text` for milestone-shaped tokens and append them to `out`.
///
/// Requires a non-alphanumeric boundary before and after each token so
/// identifiers like `HTM1` or `PhaseAware` do not false-positive. `M` and
/// `Phase` are matched case-sensitively on their opening letter to mirror
/// the repo's own commit-subject convention (`M5`, `Phase A`).
fn collect_milestone_tokens(text: &str, out: &mut Vec<MilestoneToken>) {
    let bytes = text.as_bytes();
    let len = bytes.len();
    let mut idx = 0;
    while idx < len {
        let b = bytes[idx];
        let before_ok = idx == 0 || !bytes[idx - 1].is_ascii_alphanumeric();

        if b == b'M' && before_ok {
            let digit_start = idx + 1;
            let mut digit_end = digit_start;
            while digit_end < len && bytes[digit_end].is_ascii_digit() {
                digit_end += 1;
            }
            if digit_end > digit_start
                && bytes
                    .get(digit_end)
                    .is_none_or(|c| !c.is_ascii_alphanumeric())
            {
                if let Ok(value) = text[digit_start..digit_end].parse::<u32>() {
                    out.push(MilestoneToken {
                        family: MilestoneFamily::M,
                        value,
                        literal: text[idx..digit_end].to_owned(),
                    });
                    idx = digit_end;
                    continue;
                }
            }
        }

        if b == b'P' && before_ok && idx + 7 <= len && &bytes[idx..idx + 5] == b"Phase" {
            // Exactly one ASCII space between "Phase" and the label letter
            // keeps the matcher conservative; multi-space variants can opt
            // in later without broadening the default surface.
            if bytes[idx + 5] == b' ' {
                let letter_pos = idx + 6;
                let letter = bytes[letter_pos];
                if letter.is_ascii_uppercase()
                    && bytes
                        .get(letter_pos + 1)
                        .is_none_or(|c| !c.is_ascii_alphanumeric())
                {
                    let value = (letter - b'A') as u32;
                    out.push(MilestoneToken {
                        family: MilestoneFamily::Phase,
                        value,
                        literal: text[idx..=letter_pos].to_owned(),
                    });
                    idx = letter_pos + 1;
                    continue;
                }
            }
        }

        idx += 1;
    }
}

fn extract_handoff_milestone_tokens(
    handoff: &runtime_state::RuntimeHandoffState,
) -> Vec<MilestoneToken> {
    let mut out = Vec::new();
    collect_milestone_tokens(&handoff.title, &mut out);
    for item in handoff
        .immediate_actions
        .iter()
        .filter(|i| i.lifecycle.is_active())
    {
        collect_milestone_tokens(&item.text, &mut out);
    }
    out
}

pub(super) fn build_handoff_milestone_progression_signal(
    repo_root: &Path,
    handoff: &runtime_state::RuntimeHandoffState,
    tracked_session: Option<&session_state::SessionStateFile>,
) -> BehavioralDriftSignal {
    let session_start = match tracked_session {
        Some(session) if session.started_at_epoch_s > 0 => session.started_at_epoch_s,
        _ => {
            return no_signal(
                "handoff_milestone_progression_fresh",
                "No active CCD session boundary is available; cannot bound `session_start..HEAD` for handoff milestone-progression drift.",
                Vec::new(),
            );
        }
    };

    let handoff_tokens = extract_handoff_milestone_tokens(handoff);
    if handoff_tokens.is_empty() {
        return aligned(
            "handoff_milestone_progression_fresh",
            "The current handoff title and immediate actions do not reference milestone-shaped tokens (`M<digits>`, `Phase <letter>`), so session-bounded commits cannot stale them.",
            Vec::new(),
        );
    }

    // Per-family ceiling from the handoff: a commit progresses drift only
    // when its token exceeds the largest handoff-declared value in that
    // family, so "handoff mentions M1, M2 as upcoming" stays aligned until
    // a commit actually lands past M2.
    let mut handoff_max: BTreeMap<MilestoneFamily, u32> = BTreeMap::new();
    for token in &handoff_tokens {
        handoff_max
            .entry(token.family)
            .and_modify(|v| *v = (*v).max(token.value))
            .or_insert(token.value);
    }

    let commits = match collect_session_commits(repo_root, session_start) {
        Ok(commits) => commits,
        Err(reason) => {
            // Mirror the issue-references detector: fail closed so the
            // radar fast path cannot treat a handoff as clean when the
            // one check meant to catch milestone drift never ran.
            return drift(
                "handoff_milestone_progression_fresh",
                "Could not read the session-bounded commit history needed to validate handoff milestone progression; staleness cannot be ruled out.",
                vec![
                    format!(
                        "{} handoff milestone token(s) remain unverified:",
                        handoff_tokens.len()
                    ),
                    handoff_tokens
                        .iter()
                        .map(|t| format!("`{}`", t.literal))
                        .collect::<Vec<_>>()
                        .join(", "),
                    format!("git log read failure: {reason}"),
                ],
                "Re-run `ccd handover` from a valid git checkout so CCD can walk `session_start..HEAD` and decide whether the handoff still names milestones the session advanced past; if the read keeps failing, manually reconcile the handoff's title and immediate_actions against the milestones the session landed before closing out.",
            );
        }
    };

    struct Progression<'a> {
        commit: &'a SessionCommitRef,
        from_literal: String,
        to_literal: String,
    }
    let mut progressions: Vec<Progression<'_>> = Vec::new();
    for commit in &commits {
        let mut commit_tokens = Vec::new();
        collect_milestone_tokens(&commit.subject, &mut commit_tokens);
        for token in &commit_tokens {
            let Some(&hmax) = handoff_max.get(&token.family) else {
                continue;
            };
            if token.value <= hmax {
                continue;
            }
            // Surface the handoff-side literal that was progressed past so
            // evidence is anchored to operator-visible text, not the
            // internal family/value pair.
            let from_literal = handoff_tokens
                .iter()
                .find(|ht| ht.family == token.family && ht.value == hmax)
                .map(|ht| ht.literal.clone())
                .unwrap_or_else(|| match token.family {
                    MilestoneFamily::M => format!("M{hmax}"),
                    MilestoneFamily::Phase => {
                        format!("Phase {}", (b'A' + hmax as u8) as char)
                    }
                });
            progressions.push(Progression {
                commit,
                from_literal,
                to_literal: token.literal.clone(),
            });
        }
    }

    if progressions.is_empty() {
        return aligned(
            "handoff_milestone_progression_fresh",
            "No commits in `session_start..HEAD` reference milestone tokens beyond the handoff's stated milestones.",
            Vec::new(),
        );
    }

    let mut evidence: Vec<String> = Vec::with_capacity(progressions.len() + 1);
    evidence.push(format!(
        "{} commit(s) in `session_start..HEAD` advanced past the handoff milestone tokens:",
        progressions.len()
    ));
    for p in &progressions {
        evidence.push(format!(
            "- {} ({}): `{}` -> `{}`",
            p.commit.short_hash, p.commit.subject, p.from_literal, p.to_literal
        ));
    }

    drift(
        "handoff_milestone_progression_fresh",
        "The current handoff still names milestone tokens that commits landed during this session have progressed past.",
        evidence,
        "Rewrite the handoff so its title and immediate_actions reflect the milestone progression landed during this session (for example `M2` instead of the stale `M1`), then run `ccd handoff write` to persist the corrected handoff.",
    )
}

fn markdown_list_literal(text: &str) -> String {
    if text.contains('`') {
        text.to_owned()
    } else {
        format!("`{text}`")
    }
}

fn build_handoff_structure_signal(contents: &str) -> BehavioralDriftSignal {
    let missing_sections = handoff::REQUIRED_SECTIONS
        .iter()
        .filter(|section| !handoff::has_section(contents, section))
        .map(|section| section.trim_start_matches("## ").to_owned())
        .collect::<Vec<_>>();

    if missing_sections.is_empty() {
        return aligned(
            "handoff_structure",
            "The workspace-local handoff contains the required CCD session sections.",
            vec![layout_handoff_sections()],
        );
    }

    drift(
        "handoff_structure",
        "The workspace-local handoff is structurally incomplete for reliable next-session guidance.",
        vec![format!("Missing sections: {}", missing_sections.join(", "))],
        "Rewrite the handoff so it includes the required CCD sections before closing the session.",
    )
}

fn build_handoff_expectations_signal(
    tracked_session: Option<&session_state::SessionStateFile>,
    git: Option<&GitState>,
    handoff: &HandoffState,
) -> BehavioralDriftSignal {
    let Some(_session) = tracked_session else {
        return no_signal(
            "handoff_expectations",
            "No active session telemetry is available, so handoff-expectation drift cannot be scoped to this conversation.",
            Vec::new(),
        );
    };

    let mut evidence = vec![format!("Handoff title: `{}`", handoff.title)];
    if let Some(git) = git {
        evidence.insert(0, format!("Branch: `{}`", git.branch));
    }

    if handoff.title != "No active session"
        && !handoff.immediate_actions.is_empty()
        && !handoff.definition_of_done.is_empty()
    {
        return aligned(
            "handoff_expectations",
            "The active session is anchored by a concrete handoff title, actions, and definition of done.",
            evidence,
        );
    }

    if handoff.title == "No active session" {
        evidence.push("The handoff title still says `No active session`.".to_owned());
    }
    if handoff.immediate_actions.is_empty() {
        evidence.push("`## Immediate Actions` is empty.".to_owned());
    }
    if handoff.definition_of_done.is_empty() {
        evidence.push("`## Definition of Done` is empty.".to_owned());
    }

    drift(
        "handoff_expectations",
        "The active session is not anchored by a concrete next-session intent yet.",
        evidence,
        "Set a concrete next-session title and fill in Immediate Actions and Definition of Done before wrap-up.",
    )
}

fn build_surface_order_signal() -> BehavioralDriftSignal {
    let start_order = START_RENDER_SECTION_ORDER
        .iter()
        .map(|section| section.as_str())
        .collect::<Vec<_>>();
    let compiled_order = compiled_state::SESSION_RENDER_SURFACE_ORDER.to_vec();
    let start_order_ok = start_order == CANONICAL_PROMPT_SURFACE_ORDER;
    let compiled_order_ok = compiled_order == CANONICAL_COMPILED_SURFACE_ORDER;

    let mut evidence = Vec::new();
    evidence.push(format!("Kernel start order: {}", start_order.join(" -> ")));
    evidence.push(format!(
        "Kernel compiled order: {}",
        compiled_order.join(" -> ")
    ));

    if start_order_ok && compiled_order_ok {
        return aligned(
            "prefix_surface_order",
            "Prompt surfaces remain ordered by stability, preserving cache-friendly prefix assembly.",
            evidence,
        );
    }

    if !start_order_ok {
        evidence.push(format!(
            "Expected kernel start order: {}",
            CANONICAL_PROMPT_SURFACE_ORDER.join(" -> ")
        ));
    }
    if !compiled_order_ok {
        evidence.push(format!(
            "Expected kernel compiled order: {}",
            CANONICAL_COMPILED_SURFACE_ORDER.join(" -> ")
        ));
    }

    drift(
        "prefix_surface_order",
        "Kernel prompt surface ordering drifted away from the canonical stability order.",
        evidence,
        "Keep kernel prompt surfaces ordered policy -> memory -> execution_gates -> handoff, and keep the kernel compiled session subset effective_memory -> handoff -> execution_gates -> escalation -> recovery -> git_state -> session_state.",
    )
}

fn build_compiled_state_churn_signal(
    layout: &StateLayout,
    tracked_session: Option<&session_state::SessionStateFile>,
    observation_load: &ProjectionObservationLoad,
) -> BehavioralDriftSignal {
    let Some(_session) = tracked_session else {
        return no_signal(
            "compiled_state_churn",
            "No active session telemetry is available, so derived runtime-view churn cannot be scoped to this conversation.",
            Vec::new(),
        );
    };

    let fingerprints = observation_load
        .observations
        .iter()
        .map(|observation| observation.source_fingerprint.trim())
        .filter(|fingerprint| !fingerprint.is_empty())
        .collect::<Vec<_>>();
    let distinct_fingerprints = distinct_count(&fingerprints);

    if distinct_fingerprints == 0 {
        return no_signal(
            "compiled_state_churn",
            "No derived runtime-view observations were recorded after this session started.",
            observation_load.warnings.clone(),
        );
    }

    let mut evidence = vec![
        format!(
            "{} distinct derived runtime-view fingerprint(s) observed in this session.",
            distinct_fingerprints
        ),
        format!(
            "Observation log: {}",
            layout.clone_projection_metadata_path().display()
        ),
    ];
    evidence.extend(observation_load.warnings.iter().cloned());
    if let Some(summary) = projection_surface_change_summary(&observation_load.observations) {
        evidence.push(summary);
    }

    if distinct_fingerprints >= 3 {
        return drift(
            "compiled_state_churn",
            "The derived session prefix changed repeatedly during one session, which breaks prompt-cache reuse.",
            evidence,
            "Batch memory, focus, and handoff edits when possible, or use lightweight delta updates instead of repeatedly rebuilding the full session prefix.",
        );
    }

    aligned(
        "compiled_state_churn",
        "Compiled-state churn stayed within the expected session baseline.",
        evidence,
    )
}

fn build_tool_surface_signal(
    tracked_session: Option<&session_state::SessionStateFile>,
    observation_load: &ProjectionObservationLoad,
) -> BehavioralDriftSignal {
    let Some(_session) = tracked_session else {
        return no_signal(
            "tool_surface_mutation",
            "No active session telemetry is available, so tool-surface drift cannot be scoped to this conversation.",
            Vec::new(),
        );
    };

    let tool_fingerprints = observation_load
        .observations
        .iter()
        .filter_map(|observation| observation.tool_surface_fingerprint.as_deref())
        .map(str::trim)
        .filter(|fingerprint| !fingerprint.is_empty())
        .collect::<Vec<_>>();
    let distinct_tool_fingerprints = distinct_count(&tool_fingerprints);

    if distinct_tool_fingerprints == 0 {
        return no_signal(
            "tool_surface_mutation",
            "No MCP tool-surface fingerprint was recorded for this session.",
            observation_load.warnings.clone(),
        );
    }

    let mut evidence = vec![format!(
        "{} distinct tool-surface fingerprint(s) observed in this session.",
        distinct_tool_fingerprints
    )];
    evidence.extend(observation_load.warnings.iter().cloned());

    if distinct_tool_fingerprints >= 2 {
        return drift(
            "tool_surface_mutation",
            "The available tool surface changed mid-session, which can invalidate cached prefixes.",
            evidence,
            "Register tool stubs up front and avoid adding or removing MCP tools mid-session when defer-loading can preserve prefix stability.",
        );
    }

    aligned(
        "tool_surface_mutation",
        "The observed tool surface stayed stable during this session.",
        evidence,
    )
}

fn projection_observations_for_session(
    layout: &StateLayout,
    locality_id: &str,
    runtime: &runtime_state::LoadedRuntimeState,
    tracked_session: Option<&session_state::SessionStateFile>,
) -> ProjectionObservationLoad {
    let Some(session) = tracked_session else {
        return ProjectionObservationLoad {
            observations: Vec::new(),
            warnings: Vec::new(),
        };
    };

    let mut warnings = Vec::new();
    let mut observations = match projection_metadata::load_for_layout(layout) {
        Ok(Some(metadata)) => metadata
            .observations
            .into_iter()
            .filter(|observation| observation.observed_at_epoch_s >= session.started_at_epoch_s)
            .collect::<Vec<_>>(),
        Ok(None) => Vec::new(),
        Err(error) => {
            let warning = format!(
                "Failed to load projection observations from {}: {error:#}",
                layout.clone_projection_metadata_path().display()
            );
            eprintln!("Warning: {warning}");
            warnings.push(warning);
            Vec::new()
        }
    };

    let current_store = match compiled_state::preview_for_target_with_cache(
        layout,
        runtime,
        compiled_state::ProjectionTarget::Session,
    ) {
        Ok(store) => Some(store.value),
        Err(error) => {
            let warning = format!(
                "Failed to preview the current compiled session state for repo `{locality_id}`: {error:#}"
            );
            eprintln!("Warning: {warning}");
            warnings.push(warning);
            None
        }
    };

    if let Some(store) = current_store {
        let current = projection_metadata::ProjectionObservation {
            observed_at_epoch_s: session_state::now_epoch_s()
                .ok()
                .unwrap_or(session.started_at_epoch_s),
            source_fingerprint: store.source_fingerprint.clone(),
            projection_digests: store
                .projection_digests
                .clone()
                .or_else(|| Some(compiled_state::compute_projection_digests(&store))),
            tool_surface_fingerprint: projection_metadata::current_tool_surface_fingerprint(),
            session_id: None,
        };

        if observations.last().is_none_or(|last| {
            last.source_fingerprint != current.source_fingerprint
                || last.tool_surface_fingerprint != current.tool_surface_fingerprint
        }) {
            observations.push(current);
        }
    }

    ProjectionObservationLoad {
        observations,
        warnings,
    }
}

fn projection_surface_change_summary(
    observations: &[projection_metadata::ProjectionObservation],
) -> Option<String> {
    let mut counts = BTreeMap::new();

    for window in observations.windows(2) {
        let [previous, current] = window else {
            continue;
        };
        let (Some(previous), Some(current)) = (
            previous.projection_digests.as_ref(),
            current.projection_digests.as_ref(),
        ) else {
            continue;
        };

        if previous.effective_memory != current.effective_memory {
            *counts.entry("effective_memory").or_insert(0usize) += 1;
        }
        if previous.handoff != current.handoff {
            *counts.entry("handoff").or_insert(0usize) += 1;
        }
    }

    if counts.is_empty() {
        return None;
    }

    Some(format!(
        "Observed changed surfaces: {}",
        counts
            .into_iter()
            .map(|(surface, count)| format!("{surface} x{count}"))
            .collect::<Vec<_>>()
            .join(", ")
    ))
}

fn distinct_count(values: &[&str]) -> usize {
    values.iter().copied().collect::<BTreeSet<_>>().len()
}

fn aligned(
    id: &'static str,
    summary: impl Into<String>,
    evidence: Vec<String>,
) -> BehavioralDriftSignal {
    BehavioralDriftSignal {
        id,
        status: DriftSignalStatus::Aligned,
        summary: summary.into(),
        evidence,
        recommended_correction: None,
    }
}

fn no_signal(
    id: &'static str,
    summary: impl Into<String>,
    evidence: Vec<String>,
) -> BehavioralDriftSignal {
    BehavioralDriftSignal {
        id,
        status: DriftSignalStatus::NoSignal,
        summary: summary.into(),
        evidence,
        recommended_correction: None,
    }
}

fn drift(
    id: &'static str,
    summary: impl Into<String>,
    evidence: Vec<String>,
    recommended_correction: impl Into<String>,
) -> BehavioralDriftSignal {
    BehavioralDriftSignal {
        id,
        status: DriftSignalStatus::Drift,
        summary: summary.into(),
        evidence,
        recommended_correction: Some(recommended_correction.into()),
    }
}

fn layout_handoff_sections() -> String {
    format!(
        "Required sections present: {}",
        handoff::REQUIRED_SECTIONS
            .iter()
            .map(|section| section.trim_start_matches("## "))
            .collect::<Vec<_>>()
            .join(", ")
    )
}
