//! Live Claude Code adapter conformance verification (#30).
//!
//! Compares the captured (or deterministic-from-protocol) Claude
//! lifecycle hook fixtures under `tests/fixtures/live/claude/`
//! against the claims `lifeloop::claude_manifest()` makes. A
//! mismatch surfaces as a failed test with a diagnostic message
//! that names the specific manifest claim and the fixture state
//! that contradicts it.
//!
//! The fixtures committed in this MR are deterministic-from-protocol
//! per the operator playbook at
//! `docs/playbooks/capture-live-adapter-evidence.md` — they encode
//! the field shape Anthropic's published Claude Code hook protocol
//! documentation describes, NOT what `claude_manifest()` claims, so
//! the verification has independent ground truth. An operator
//! recapture run via `scripts/regenerate-live-claude-evidence.sh`
//! overwrites them with live observations; this verification
//! re-runs unchanged.
//!
//! Per #30 close-out: this verification is one half of the v1
//! freeze gate 1 ("Adapter evidence"). The other half is the
//! pre-v1 operator recapture pass; until that runs, the captured
//! evidence carries a documented pre-v1 degradation.

use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;

use lifeloop::{
    LifecycleEventKind, ManifestLifecycleEventSupport, ManifestPlacementClass, SupportState,
    claude_manifest,
};
use serde_json::Value;

// ---------------------------------------------------------------------------
// Fixture loading
// ---------------------------------------------------------------------------

fn fixtures_root() -> PathBuf {
    let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    crate_root
        .join("tests")
        .join("fixtures")
        .join("live")
        .join("claude")
}

#[derive(Debug)]
struct ClaudeFixture {
    event_dir: String, // the per-event subdirectory name
    path: PathBuf,
    body: Value,
}

fn load_fixtures() -> Vec<ClaudeFixture> {
    let root = fixtures_root();
    let mut fixtures = Vec::new();
    let entries = match fs::read_dir(&root) {
        Ok(it) => it,
        Err(err) => panic!(
            "live_evidence_claude: cannot read {}: {err}\n\
             Run `scripts/build-deterministic-claude-fixtures.py` to seed deterministic \
             fixtures, or follow `docs/playbooks/capture-live-adapter-evidence.md` to \
             capture live ones.",
            root.display()
        ),
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let event_name = path
            .file_name()
            .and_then(|n| n.to_str())
            .map(str::to_owned)
            .expect("utf-8 dir name");
        for inner in fs::read_dir(&path).expect("event dir readable").flatten() {
            let inner_path = inner.path();
            if inner_path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            let body: Value =
                serde_json::from_slice(&fs::read(&inner_path).expect("fixture readable"))
                    .unwrap_or_else(|err| {
                        panic!(
                            "live_evidence_claude: invalid JSON at {}: {err}",
                            inner_path.display()
                        )
                    });
            fixtures.push(ClaudeFixture {
                event_dir: event_name.clone(),
                path: inner_path,
                body,
            });
        }
    }
    fixtures.sort_by(|a, b| {
        (a.event_dir.clone(), a.path.clone()).cmp(&(b.event_dir.clone(), b.path.clone()))
    });
    fixtures
}

fn fixtures_by_event() -> BTreeMap<String, Vec<ClaudeFixture>> {
    let mut by_event: BTreeMap<String, Vec<ClaudeFixture>> = BTreeMap::new();
    for fixture in load_fixtures() {
        by_event
            .entry(fixture.event_dir.clone())
            .or_default()
            .push(fixture);
    }
    by_event
}

// ---------------------------------------------------------------------------
// Mappings between Claude hook event names and lifeloop event kinds
// ---------------------------------------------------------------------------
//
// `claude_manifest()` claims native support for a specific set of
// `LifecycleEventKind` values via the NativeHook integration mode.
// The map below pins which Claude hook event name produces evidence
// for which kind. When the manifest claims native support for a
// kind, the verification asserts a matching Claude fixture exists
// (and vice versa for unavailable claims that should NOT be
// contradicted by a fixture).

fn claude_event_for_kind(kind: LifecycleEventKind) -> Option<&'static str> {
    match kind {
        // Claude's `SessionStart` hook is the wire-level signal for
        // both SessionStarting (pre-state-change observability) and
        // SessionStarted (after-state-change confirmation). The
        // manifest claims native support for both via NativeHook;
        // the same fixture grounds both claims.
        LifecycleEventKind::SessionStarting | LifecycleEventKind::SessionStarted => {
            Some("SessionStart")
        }
        // `UserPromptSubmit` is the wire-level signal for both
        // FrameOpening (about to start a turn) and FrameOpened
        // (turn underway).
        LifecycleEventKind::FrameOpening | LifecycleEventKind::FrameOpened => {
            Some("UserPromptSubmit")
        }
        // `PreCompact` is the wire-level signal for
        // ContextPressureObserved.
        LifecycleEventKind::ContextPressureObserved => Some("PreCompact"),
        // `Stop` is the wire-level signal for FrameEnding /
        // FrameEnded.
        LifecycleEventKind::FrameEnding | LifecycleEventKind::FrameEnded => Some("Stop"),
        // `SessionEnd` is the wire-level signal for SessionEnding /
        // SessionEnded.
        LifecycleEventKind::SessionEnding | LifecycleEventKind::SessionEnded => Some("SessionEnd"),
        // The remaining kinds are not driven by a Claude hook event;
        // they are either synthesized by lifeloop (CapabilityDegraded,
        // ReceiptEmitted) or unavailable (SupervisorTick,
        // ReceiptGapDetected).
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn live_fixture_root_exists_and_has_per_event_subdirs() {
    let root = fixtures_root();
    assert!(
        root.is_dir(),
        "expected fixture root {} to be a directory; \
         seed with scripts/build-deterministic-claude-fixtures.py",
        root.display()
    );
    let by_event = fixtures_by_event();
    assert!(
        !by_event.is_empty(),
        "no Claude fixtures present under {}; \
         seed with scripts/build-deterministic-claude-fixtures.py",
        root.display()
    );
}

#[test]
fn every_native_lifecycle_claim_has_at_least_one_fixture() {
    // For every (LifecycleEventKind, NativeHook) pair the manifest
    // claims native(), at least one Claude fixture must exist that
    // grounds the claim. If this fails, EITHER the manifest is over-
    // claiming OR an operator recapture is missing an event.
    let manifest = claude_manifest();
    let by_event = fixtures_by_event();

    let mut missing: Vec<String> = Vec::new();
    for (kind, support) in &manifest.lifecycle_events {
        let ManifestLifecycleEventSupport { support, modes: _ } = support;
        if *support != SupportState::Native {
            continue;
        }
        let Some(event_name) = claude_event_for_kind(*kind) else {
            // Manifest claim native() but we have no Claude wire
            // event mapping for it — that itself is a manifest /
            // mapping inconsistency. Surface it loudly.
            missing.push(format!(
                "{kind:?}: manifest claims native() but no Claude wire event maps to this \
                 kind in claude_event_for_kind()"
            ));
            continue;
        };
        if by_event
            .get(event_name)
            .map(|v| v.is_empty())
            .unwrap_or(true)
        {
            missing.push(format!(
                "{kind:?}: manifest claims native() via Claude `{event_name}` hook, \
                 but no fixture exists at tests/fixtures/live/claude/{event_name}/"
            ));
        }
    }

    assert!(
        missing.is_empty(),
        "manifest/fixture drift:\n  {}",
        missing.join("\n  ")
    );
}

#[test]
fn every_fixture_carries_session_identity_field() {
    // The manifest's session_identity claim
    // (`harness_session_id: native()`) is grounded by the presence
    // of `session_id` in every Claude hook event. If any fixture is
    // missing it, the manifest is over-claiming.
    let manifest = claude_manifest();
    let session_identity = manifest
        .session_identity
        .expect("Claude has session_identity");
    if session_identity.harness_session_id != SupportState::Native {
        // If the manifest no longer claims native, this test no
        // longer applies — but it would be very surprising and
        // worth re-grounding before silently relaxing.
        panic!("manifest stopped claiming session_id is native; revisit live_evidence_claude.rs");
    }

    let mut missing: Vec<PathBuf> = Vec::new();
    for fixture in load_fixtures() {
        let has_id = fixture
            .body
            .get("session_id")
            .and_then(Value::as_str)
            .map(|s| !s.is_empty())
            .unwrap_or(false);
        if !has_id {
            missing.push(fixture.path);
        }
    }
    assert!(
        missing.is_empty(),
        "manifest claims harness_session_id is native(), but fixtures are missing \
         session_id:\n  {}",
        missing
            .iter()
            .map(|p| p.display().to_string())
            .collect::<Vec<_>>()
            .join("\n  ")
    );
}

#[test]
fn every_fixture_event_name_matches_its_directory() {
    // The capture/sanitization rig writes one event per
    // <event-name>/ subdir; the fixture's `hook_event_name` field
    // must match the directory name. A mismatch indicates a
    // bookkeeping bug in the rig or a hand-edited fixture.
    let mut mismatches: Vec<String> = Vec::new();
    for fixture in load_fixtures() {
        let claimed_event = fixture
            .body
            .get("hook_event_name")
            .and_then(Value::as_str)
            .unwrap_or("<missing>");
        if claimed_event != fixture.event_dir {
            mismatches.push(format!(
                "{}: in dir `{}` but hook_event_name=`{}`",
                fixture.path.display(),
                fixture.event_dir,
                claimed_event
            ));
        }
    }
    assert!(
        mismatches.is_empty(),
        "fixture event_name vs directory mismatch:\n  {}",
        mismatches.join("\n  ")
    );
}

#[test]
fn pre_compact_fixture_grounds_context_pressure_claim() {
    // The manifest's context_pressure block specifically names
    // "PreCompact and SessionEnd events" as the wire signals that
    // map to context.pressure_observed. PreCompact's presence is
    // the load-bearing one (SessionEnd carries no compaction-
    // specific fields, only the boundary reason). A PreCompact
    // fixture must exist AND must carry a `trigger` field, which
    // is what the manifest claim relies on to distinguish
    // auto-compaction from manual.
    let manifest = claude_manifest();
    assert_eq!(
        manifest.context_pressure.support,
        SupportState::Native,
        "manifest must continue to claim native context pressure"
    );

    let by_event = fixtures_by_event();
    let empty: Vec<ClaudeFixture> = Vec::new();
    let pre_compact = by_event.get("PreCompact").unwrap_or(&empty);
    assert!(
        !pre_compact.is_empty(),
        "manifest claims native context_pressure citing PreCompact, but no PreCompact \
         fixture exists at tests/fixtures/live/claude/PreCompact/"
    );
    let first = &pre_compact[0];
    let trigger = first
        .body
        .get("trigger")
        .and_then(Value::as_str)
        .unwrap_or("");
    assert!(
        !trigger.is_empty(),
        "PreCompact fixture missing required `trigger` field at {}",
        first.path.display()
    );
}

#[test]
fn placement_pre_session_grounded_by_session_start_fixture() {
    // The manifest claims PreSession placement is native via the
    // NativeHook integration mode. The wire-level grounding is the
    // SessionStart fixture: PreSession context lands when Lifeloop
    // injects it during the SessionStart hook callback, so the
    // SessionStart fixture's existence is what makes the claim
    // honest.
    let manifest = claude_manifest();
    let pre_session = manifest
        .placement
        .get(&ManifestPlacementClass::PreSession)
        .expect("PreSession in placement table");
    assert_eq!(pre_session.support, SupportState::Native);

    let by_event = fixtures_by_event();
    assert!(
        by_event
            .get("SessionStart")
            .map(|v| !v.is_empty())
            .unwrap_or(false),
        "manifest claims native PreSession placement, but SessionStart fixture is missing"
    );
}

#[test]
fn no_fixture_exists_for_unavailable_lifecycle_kinds() {
    // The manifest currently claims `unavailable()` for SupervisorTick
    // and ReceiptGapDetected. A fixture under either of those names
    // would contradict the manifest. We don't enumerate negative
    // cases with their own files, but we DO assert the directory
    // structure stays clean — surprise dirs surface here.
    let by_event = fixtures_by_event();
    let unexpected: Vec<String> = by_event
        .keys()
        .filter(|name| {
            !matches!(
                name.as_str(),
                "SessionStart" | "UserPromptSubmit" | "PreCompact" | "Stop" | "SessionEnd"
            )
        })
        .cloned()
        .collect();
    assert!(
        unexpected.is_empty(),
        "unexpected fixture event subdir(s) — either add a manifest claim or remove the \
         fixture: {unexpected:?}"
    );
}

#[test]
fn fixtures_pass_through_sanitizer_placeholder_vocabulary() {
    // Defense-in-depth: the sanitizer documented in
    // `tests/fixtures/live/README.md` rewrites operator data to a
    // fixed placeholder vocabulary. Fixtures committed to the repo
    // must NOT carry obviously-unredacted operator data. We can't
    // catch every leak with a structural check, but we can assert
    // that every absolute filesystem path is replaced with the
    // expected placeholder, since absolute paths are the most
    // common accidental leak.
    let mut leaks: Vec<String> = Vec::new();
    for fixture in load_fixtures() {
        let leak_iter = walk_strings(&fixture.body, "");
        for (path, value) in leak_iter {
            if value.starts_with('/') && !value.starts_with("</") && !value.starts_with("//") {
                // Allow `/` itself for path-style fields where the
                // placeholder is `<REPO_ROOT>` — but if a string
                // *starts* with `/` and isn't a placeholder, it's a
                // leaked absolute path.
                leaks.push(format!(
                    "{}: field `{path}` looks like an unredacted absolute path: {value}",
                    fixture.path.display()
                ));
            }
        }
    }
    assert!(
        leaks.is_empty(),
        "fixtures contain unredacted absolute paths:\n  {}",
        leaks.join("\n  ")
    );
}

fn walk_strings<'a>(value: &'a Value, path: &str) -> Vec<(String, &'a str)> {
    let mut out = Vec::new();
    match value {
        Value::String(s) => out.push((path.to_owned(), s.as_str())),
        Value::Object(map) => {
            for (k, v) in map {
                let next = if path.is_empty() {
                    k.clone()
                } else {
                    format!("{path}.{k}")
                };
                out.extend(walk_strings(v, &next));
            }
        }
        Value::Array(items) => {
            for (i, v) in items.iter().enumerate() {
                let next = format!("{path}[{i}]");
                out.extend(walk_strings(v, &next));
            }
        }
        _ => {}
    }
    out
}
