//! SL-059 PHASE-03 VT-2 — the `doctrine knowledge` CLI as BLACK-BOX goldens.
//!
//! Pins `knowledge new` / `show` / `list` / `status` at the CLI surface (byte-exact
//! stdout + JSON + error text) over the BUILT binary — the
//! `mem.pattern.testing.black-box-cli-golden` idiom, mirroring `e2e_adr_cli_golden.rs`.
//! The four kinds share one kind-blind engine but diverge in prefix, status vocabulary,
//! and typed `[facet]`; these goldens pin the read/write surface and the FR-002/FR-004
//! prefix→kind routing + foreign-kind refuse.
//!
//! Determinism: `knowledge new`/`status` stamp `clock::today()` into created/updated, so
//! fixtures are hand-seeded with FIXED dates; the two non-deterministic surfaces (the
//! `new` mint and the `status` `updated→today()` bump) are asserted structurally at the
//! documented call sites.

#![allow(
    clippy::expect_used,
    clippy::unwrap_used,
    clippy::tests_outside_test_module,
    reason = "integration test: fail-fast unwrap/expect are idiomatic, and test fns live at crate root by construction"
)]

use std::fs;
use std::path::Path;
use std::process::{Command, Output};

const BIN: &str = env!("CARGO_BIN_EXE_doctrine");

/// Hand-seed one record's authored tree (`.doctrine/knowledge/<dir>/NNN/record-NNN.{toml,md}`)
/// — fixed dates make show/list deterministic.
fn seed(root: &Path, dir: &str, id: u32, toml: &str) {
    let d = root.join(format!(".doctrine/knowledge/{dir}/{id:03}"));
    fs::create_dir_all(&d).unwrap();
    fs::write(d.join(format!("record-{id:03}.toml")), toml).unwrap();
    fs::write(d.join(format!("record-{id:03}.md")), "# body\n").unwrap();
}

/// `doctrine knowledge <args...> -p <root>` over the built binary.
fn run(root: &Path, args: &[&str]) -> Output {
    Command::new(BIN)
        .arg("knowledge")
        .args(args)
        .arg("-p")
        .arg(root)
        .output()
        .expect("spawn doctrine")
}

fn stdout(out: &Output) -> String {
    String::from_utf8(out.stdout.clone()).expect("utf8 stdout")
}
fn stderr(out: &Output) -> String {
    String::from_utf8(out.stderr.clone()).expect("utf8 stderr")
}

fn tmp() -> tempfile::TempDir {
    tempfile::tempdir().expect("tempdir")
}

// --- fixtures -------------------------------------------------------------

/// A populated assumption (facet + evidence) — exercises every show axis.
fn asm007_toml() -> &'static str {
    "schema = \"doctrine.knowledge\"\n\
     version = 1\n\
     \n\
     id = 7\n\
     slug = \"token-expiry\"\n\
     title = \"Token expiry\"\n\
     record_kind = \"assumption\"\n\
     status = \"testing\"\n\
     created = \"2026-01-02\"\n\
     updated = \"2026-01-03\"\n\
     tags = [\"auth\", \"security\"]\n\
     \n\
     # hand-added comment — must survive a status edit\n\
     [facet]\n\
     claim = \"tokens expire in 1h\"\n\
     confidence = \"high\"\n\
     basis = \"observation\"\n\
     validation_plan = \"probe the IdP\"\n\
     validated_by = \"\"\n\
     validated_on = \"\"\n\
     invalidated_by = \"\"\n\
     invalidated_on = \"\"\n\
     \n\
     [evidence]\n\
     supports = [\"DEC-005-C\"]\n\
     contradicts = []\n\
     notes = [\"see the audit\"]\n"
}

/// A minimal record at any kind/status, empty facet+evidence — for list rows + status.
fn minimal_toml(id: u32, slug: &str, title: &str, record_kind: &str, status: &str) -> String {
    format!(
        "schema = \"doctrine.knowledge\"\n\
         version = 1\n\
         \n\
         id = {id}\n\
         slug = \"{slug}\"\n\
         title = \"{title}\"\n\
         record_kind = \"{record_kind}\"\n\
         status = \"{status}\"\n\
         created = \"2026-01-04\"\n\
         updated = \"2026-01-04\"\n\
         tags = []\n\
         \n\
         [facet]\n\
         \n\
         [evidence]\n"
    )
}

// === T1 — `knowledge new` mints in the kind's namespace (structural) =======

#[test]
fn knowledge_new_mints_canonical_id_and_seeds_default_status() {
    let dir = tmp();
    // `new` needs a project-root marker; an empty `.git` dir is enough.
    fs::create_dir_all(dir.path().join(".git")).unwrap();

    let out = run(dir.path(), &["new", "decision", "First decision"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    // The mint stamps today() into created/updated — assert the canonical id + dir
    // structurally (the prefix is load-bearing; the absolute path floats per run).
    let so = stdout(&out);
    assert!(
        so.starts_with("Created DEC-001: "),
        "mint prints the canonical id; got: {so}"
    );
    assert!(so.trim_end().ends_with("/.doctrine/knowledge/decision/001"));

    // The seeded record carries the kind's default status (`proposed`) and the empty
    // `[facet]`/`[evidence]` scaffold (F-A2: the seed == default_status).
    let toml = fs::read_to_string(
        dir.path()
            .join(".doctrine/knowledge/decision/001/record-001.toml"),
    )
    .unwrap();
    assert!(toml.contains("record_kind = \"decision\""));
    assert!(toml.contains("status = \"proposed\""));
    assert!(toml.contains("[facet]") && toml.contains("[evidence]"));
}

// === T2 — `knowledge show` Table golden ===================================

#[test]
fn knowledge_show_table_is_byte_exact() {
    let dir = tmp();
    seed(dir.path(), "assumption", 7, asm007_toml());

    let out = run(dir.path(), &["show", "ASM-007"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "ASM-007 — Token expiry\n\
         token-expiry · assumption · testing\n\
         created 2026-01-02 · updated 2026-01-03\n\
         tags: auth, security\n\
         \n\
         [facet]\n\
         \x20\x20claim: tokens expire in 1h\n\
         \x20\x20confidence: high\n\
         \x20\x20basis: observation\n\
         \x20\x20validation_plan: probe the IdP\n\
         \n\
         [evidence]\n\
         \x20\x20supports: DEC-005-C\n\
         \x20\x20notes: see the audit\n"
    );
}

// === T3 — `knowledge show --json` golden ==================================

#[test]
fn knowledge_show_json_is_byte_exact() {
    let dir = tmp();
    seed(dir.path(), "assumption", 7, asm007_toml());

    let out = run(dir.path(), &["show", "ASM-007", "--json"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    // Pretty JSON, BTreeMap key order (serde_json sorts), NO trailing newline
    // (`write!`, not `writeln!`). Absent optional facet fields render as `null`.
    assert_eq!(
        stdout(&out),
        "{\n  \"kind\": \"knowledge\",\n  \"knowledge\": {\n    \"created\": \"2026-01-02\",\n    \"evidence\": {\n      \"contradicts\": [],\n      \"notes\": [\n        \"see the audit\"\n      ],\n      \"supports\": [\n        \"DEC-005-C\"\n      ]\n    },\n    \"facet\": {\n      \"basis\": \"observation\",\n      \"claim\": \"tokens expire in 1h\",\n      \"confidence\": \"high\",\n      \"invalidated_by\": null,\n      \"invalidated_on\": null,\n      \"validated_by\": null,\n      \"validated_on\": null,\n      \"validation_plan\": \"probe the IdP\"\n    },\n    \"id\": \"ASM-007\",\n    \"record_kind\": \"assumption\",\n    \"slug\": \"token-expiry\",\n    \"status\": \"testing\",\n    \"tags\": [\n      \"auth\",\n      \"security\"\n    ],\n    \"title\": \"Token expiry\",\n    \"updated\": \"2026-01-03\"\n  }\n}"
    );
}

// === T4 — `knowledge show` prefix→kind routing + error goldens ============

#[test]
fn knowledge_show_routes_each_prefix_to_its_kind() {
    // FR-004: ASM/DEC/QUE/CON each route `show` to the right tree. The four counters
    // are independent — id 1 in each kind is a distinct record.
    let dir = tmp();
    seed(
        dir.path(),
        "assumption",
        1,
        &minimal_toml(1, "a", "An A", "assumption", "held"),
    );
    seed(
        dir.path(),
        "decision",
        1,
        &minimal_toml(1, "d", "A D", "decision", "accepted"),
    );
    seed(
        dir.path(),
        "question",
        1,
        &minimal_toml(1, "q", "A Q", "question", "open"),
    );
    seed(
        dir.path(),
        "constraint",
        1,
        &minimal_toml(1, "c", "A C", "constraint", "active"),
    );

    for (reference, kind, status) in [
        ("ASM-001", "assumption", "held"),
        ("DEC-001", "decision", "accepted"),
        ("QUE-001", "question", "open"),
        ("CON-001", "constraint", "active"),
    ] {
        let out = run(dir.path(), &["show", reference]);
        assert!(out.status.success(), "{reference} stderr: {}", stderr(&out));
        let so = stdout(&out);
        assert!(
            so.contains(&format!("· {kind} · {status}")),
            "{reference} routed to {kind}: {so}"
        );
    }
}

#[test]
fn knowledge_show_garbage_ref_errors_with_exact_text() {
    let dir = tmp();
    // `ASM-x` splits on the last `-`: prefix `ASM` resolves, but the tail `x` is not a
    // numeric id — the numeric-parse error.
    let out = run(dir.path(), &["show", "ASM-x"]);
    assert!(!out.status.success());
    assert_eq!(
        stderr(&out),
        "Error: `x` is not a numeric id in `ASM-x`\n\
         \n\
         Caused by:\n\
         \x20\x20\x20\x20invalid digit found in string\n"
    );
}

#[test]
fn knowledge_show_unknown_prefix_errors() {
    let dir = tmp();
    let out = run(dir.path(), &["show", "REQ-001"]);
    assert!(!out.status.success());
    assert_eq!(
        stderr(&out),
        "Error: unknown record prefix `REQ` in `REQ-001` (expected ASM/DEC/QUE/CON)\n"
    );
}

// === T5 — `knowledge status` goldens (no resolution coupling) =============

#[test]
fn knowledge_status_transition_prints_exact_and_preserves_edits() {
    let dir = tmp();
    seed(dir.path(), "assumption", 7, asm007_toml());

    let out = run(dir.path(), &["status", "ASM-007", "validated"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "ASM-007: validated\n");

    let after = fs::read_to_string(
        dir.path()
            .join(".doctrine/knowledge/assumption/007/record-007.toml"),
    )
    .unwrap();
    assert!(after.contains("status = \"validated\""), "status flipped");
    assert!(
        after.contains("created = \"2026-01-02\""),
        "created untouched"
    );
    // `updated` bumps to today() — assert it MOVED off the seeded value.
    assert!(
        !after.contains("updated = \"2026-01-03\""),
        "updated bumped"
    );
    // Edit-preservation (toml_edit in place): comment + facet + evidence survive.
    assert!(after.contains("# hand-added comment"), "comment preserved");
    assert!(
        after.contains("claim = \"tokens expire in 1h\""),
        "facet preserved"
    );
    assert!(
        after.contains("supports = [\"DEC-005-C\"]"),
        "evidence preserved"
    );
}

#[test]
fn knowledge_status_refuses_a_foreign_kind_state() {
    // FR-002: `accepted` is a DECISION status; on an ASM it is out-of-vocab — refused.
    let dir = tmp();
    seed(dir.path(), "assumption", 7, asm007_toml());
    let path = dir
        .path()
        .join(".doctrine/knowledge/assumption/007/record-007.toml");
    let before = fs::read_to_string(&path).unwrap();

    let out = run(dir.path(), &["status", "ASM-007", "accepted"]);
    assert!(!out.status.success());
    assert_eq!(
        stderr(&out),
        "Error: `accepted` is not a assumption status (known: held, testing, validated, invalidated, obsolete)\n"
    );
    // refused → file untouched.
    assert_eq!(fs::read_to_string(&path).unwrap(), before, "file untouched");
}

#[test]
fn knowledge_status_no_op_writes_nothing() {
    let dir = tmp();
    seed(
        dir.path(),
        "question",
        1,
        &minimal_toml(1, "q", "A Q", "question", "open"),
    );
    let path = dir
        .path()
        .join(".doctrine/knowledge/question/001/record-001.toml");
    let before = fs::read_to_string(&path).unwrap();

    let out = run(dir.path(), &["status", "QUE-001", "open"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "QUE-001: open\n");
    // no-op guard: identical status writes nothing — bytes hold exactly.
    assert_eq!(fs::read_to_string(&path).unwrap(), before);
}

#[test]
fn knowledge_status_on_malformed_toml_refuses_and_leaves_file_untouched() {
    // Hand-stripped `status`/`updated` → a tail `insert` would land AFTER the trailing
    // `[facet]`/`[evidence]` header, inside that subtable (silent corruption).
    // set_record_status must REFUSE, not append (mirrors the adr/standard guard goldens).
    let dir = tmp();
    let toml = "schema = \"doctrine.knowledge\"\n\
                version = 1\n\
                \n\
                id = 50\n\
                slug = \"bad\"\n\
                title = \"Bad\"\n\
                record_kind = \"assumption\"\n\
                created = \"2026-01-01\"\n\
                tags = []\n\
                \n\
                [facet]\n\
                \n\
                [evidence]\n";
    seed(dir.path(), "assumption", 50, toml);
    let path = dir
        .path()
        .join(".doctrine/knowledge/assumption/050/record-050.toml");

    let out = run(dir.path(), &["status", "ASM-050", "validated"]);
    assert!(!out.status.success());
    assert_eq!(
        stderr(&out),
        "Error: malformed record 050: missing seeded `status`/`updated` (regenerate via `knowledge new`)\n"
    );
    assert_eq!(fs::read_to_string(&path).unwrap(), toml, "file untouched");
}

// === T6 — `knowledge list` goldens (cross-kind, hide-set, shared token) ===

/// Seed a six-record cross-kind corpus spanning visible + hidden (settled) states,
/// including the SHARED `superseded`/`obsolete` tokens across two kinds each.
fn seed_list_corpus(root: &Path) {
    seed(
        root,
        "assumption",
        1,
        &minimal_toml(1, "alpha", "Alpha", "assumption", "held"),
    );
    seed(
        root,
        "assumption",
        2,
        &minimal_toml(2, "beta", "Beta", "assumption", "obsolete"),
    );
    seed(
        root,
        "decision",
        1,
        &minimal_toml(1, "choose", "Choose", "decision", "accepted"),
    );
    seed(
        root,
        "decision",
        2,
        &minimal_toml(2, "oldway", "Old Way", "decision", "superseded"),
    );
    seed(
        root,
        "constraint",
        1,
        &minimal_toml(1, "limit", "Limit", "constraint", "superseded"),
    );
    seed(
        root,
        "question",
        1,
        &minimal_toml(1, "howmany", "How Many", "question", "open"),
    );
}

#[test]
fn knowledge_list_table_default_hides_settled_byte_exact() {
    let dir = tmp();
    seed_list_corpus(dir.path());

    let out = run(dir.path(), &["list"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    // Cross-kind, grouped by (kind ordinal, id); settled states (obsolete/superseded)
    // ABSENT; held/accepted/open visible.
    assert_eq!(
        stdout(&out),
        "id      │ kind       │ status   │ title\n\
         ASM-001 │ assumption │ held     │ Alpha\n\
         DEC-001 │ decision   │ accepted │ Choose\n\
         QUE-001 │ question   │ open     │ How Many\n"
    );
}

#[test]
fn knowledge_list_all_reveals_settled_rows() {
    let dir = tmp();
    seed_list_corpus(dir.path());

    let out = run(dir.path(), &["list", "--all"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "id      │ kind       │ status     │ title\n\
         ASM-001 │ assumption │ held       │ Alpha\n\
         ASM-002 │ assumption │ obsolete   │ Beta\n\
         DEC-001 │ decision   │ accepted   │ Choose\n\
         DEC-002 │ decision   │ superseded │ Old Way\n\
         QUE-001 │ question   │ open       │ How Many\n\
         CON-001 │ constraint │ superseded │ Limit\n"
    );
}

#[test]
fn knowledge_list_shared_status_token_spans_kinds() {
    // VT-4: a cross-kind `--status` on a SHARED token (`superseded`) returns items
    // across kinds (DEC + CON), AND reveals the hide-set (explicit `--status` reveals).
    let dir = tmp();
    seed_list_corpus(dir.path());

    let out = run(dir.path(), &["list", "-s", "superseded"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "id      │ kind       │ status     │ title\n\
         DEC-002 │ decision   │ superseded │ Old Way\n\
         CON-001 │ constraint │ superseded │ Limit\n"
    );
}

#[test]
fn knowledge_list_json_default_is_the_shared_envelope_byte_exact() {
    let dir = tmp();
    seed_list_corpus(dir.path());

    let out = run(dir.path(), &["list", "--json"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "{\n  \"kind\": \"knowledge\",\n  \"rows\": [\n    {\n      \"id\": \"ASM-001\",\n      \"record_kind\": \"assumption\",\n      \"slug\": \"alpha\",\n      \"status\": \"held\",\n      \"title\": \"Alpha\"\n    },\n    {\n      \"id\": \"DEC-001\",\n      \"record_kind\": \"decision\",\n      \"slug\": \"choose\",\n      \"status\": \"accepted\",\n      \"title\": \"Choose\"\n    },\n    {\n      \"id\": \"QUE-001\",\n      \"record_kind\": \"question\",\n      \"slug\": \"howmany\",\n      \"status\": \"open\",\n      \"title\": \"How Many\"\n    }\n  ]\n}"
    );
}

#[test]
fn knowledge_list_unknown_status_errors_with_union_vocab() {
    // The `--status` known-set is the UNION of the four vocabs; an out-of-union token
    // is the uniform vocab error (the `listing::validate_statuses` opt-in surface).
    let dir = tmp();
    let out = run(dir.path(), &["list", "-s", "bogus"]);
    assert!(!out.status.success());
    let err = stderr(&out);
    assert!(err.contains("unknown status `bogus`"), "got: {err}");
    // a few union members from distinct kinds must appear in the known-set.
    assert!(err.contains("held") && err.contains("proposed") && err.contains("active"));
}
