//! Integration tests for `dbmd index` — the write-through content catalog.
//!
//! Intent-derived from plan Block 5: `rebuild` is the from-scratch SWEEP repair
//! (its output must be byte-identical to the committed corpus indexes, since
//! those were generated by the same code path); `show` prints an `index.md`
//! (root or scoped) and a missing index exits 1 with empty stdout; `query` is
//! the complete structured read over the `index.jsonl` sidecar(s) with time
//! windows and `--limit`. Reads use committed corpus-a; rebuild writes into a
//! temp copy.

mod common;

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

use common::{copy_store_to_temp, corpus_a, dbmd, write_db_md, write_file};

// ── rebuild ────────────────────────────────────────────────────────────────────

/// Every `index.md` / `index.jsonl` under `dir`, as store-relative path strings.
fn index_artifacts(dir: &std::path::Path) -> BTreeSet<String> {
    fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut BTreeSet<String>) {
        for entry in std::fs::read_dir(dir).unwrap() {
            let entry = entry.unwrap();
            let p = entry.path();
            if p.is_dir() {
                walk(root, &p, out);
            } else if matches!(
                p.file_name().and_then(|n| n.to_str()),
                Some("index.md") | Some("index.jsonl")
            ) {
                out.insert(
                    p.strip_prefix(root)
                        .unwrap()
                        .to_string_lossy()
                        .replace('\\', "/"),
                );
            }
        }
    }
    let mut out = BTreeSet::new();
    walk(dir, dir, &mut out);
    out
}

#[test]
fn rebuild_full_is_byte_identical_to_the_committed_corpus() {
    // The strongest correctness check: rebuilding corpus-a from scratch must
    // reproduce every committed index artifact byte-for-byte (the loop path and
    // the repair path cannot drift, and the corpus was built by this path).
    let (_tmp, store) = copy_store_to_temp(&corpus_a());

    dbmd()
        .current_dir(&store)
        .args(["index", "rebuild"])
        .assert()
        .success();

    let committed = corpus_a();
    let artifacts = index_artifacts(&store);
    assert!(!artifacts.is_empty(), "corpus-a has index artifacts");
    for rel in &artifacts {
        let rebuilt = std::fs::read(store.join(rel)).unwrap();
        let original = std::fs::read(committed.join(rel)).unwrap();
        assert_eq!(
            rebuilt, original,
            "rebuilt {rel} must be byte-identical to the committed corpus"
        );
    }
    // And the rebuild produced no EXTRA artifacts beyond the committed set.
    let committed_artifacts = index_artifacts(&committed);
    assert_eq!(
        artifacts, committed_artifacts,
        "rebuild must produce exactly the committed artifact set (no orphans/extras)"
    );
}

#[test]
fn rebuild_layer_repairs_type_folder_artifacts() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());
    let jsonl = store.join("records/contacts/index.jsonl");
    std::fs::remove_file(&jsonl).unwrap();
    assert!(!jsonl.exists(), "precondition: sidecar was removed");

    dbmd()
        .current_dir(&store)
        .args(["index", "rebuild", "--layer", "records"])
        .assert()
        .success();

    assert!(
        jsonl.exists(),
        "`index rebuild --layer records` must repair child type-folder sidecars"
    );
    let restored = std::fs::read_to_string(&jsonl).unwrap();
    assert!(
        restored.contains("records/contacts/sarah-chen.md"),
        "restored sidecar should contain contact records: {restored}"
    );
}

// ── write-through == rebuild (THE invariant, end-to-end through the CLI) ─────────

/// Live index artifacts (`index.md` + `index.jsonl`) under the store's three
/// layers + the root, mapped to their bytes — the surface the byte-identity
/// invariant compares.
///
/// Scoped to `index.md` / `index.jsonl` at root + `sources/` + `records/` +
/// `wiki/`. corpus-a also ships a committed *golden snapshot* of the whole
/// index hierarchy under `EXPECTED/index/`; those are fixtures for other tests,
/// not part of the store's own catalog (the write/rebuild paths never touch
/// them), so they are deliberately excluded here.
fn live_index_artifacts(store: &Path) -> BTreeMap<String, Vec<u8>> {
    fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
        for entry in std::fs::read_dir(dir).unwrap() {
            let entry = entry.unwrap();
            let p = entry.path();
            if p.is_dir() {
                walk(root, &p, out);
            } else if matches!(
                p.file_name().and_then(|n| n.to_str()),
                Some("index.md") | Some("index.jsonl")
            ) {
                let rel = p
                    .strip_prefix(root)
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/");
                out.insert(rel, std::fs::read(&p).unwrap());
            }
        }
    }
    let mut out = BTreeMap::new();
    // The store-root `index.md` (the top of the catalog hierarchy).
    let root_index = store.join("index.md");
    if root_index.is_file() {
        out.insert("index.md".to_string(), std::fs::read(&root_index).unwrap());
    }
    for layer in ["sources", "records", "wiki"] {
        let dir = store.join(layer);
        if dir.is_dir() {
            walk(store, &dir, &mut out);
        }
    }
    out
}

/// **The property: write-through == rebuild, proven end-to-end through the
/// `dbmd` binary.** A scripted sequence of `write` / `fm set` / `rename` (the
/// loop write surfaces, each maintaining the catalog write-through, O(changed))
/// must leave the index hierarchy byte-identical to a from-scratch
/// `dbmd index rebuild` over the *same end state* — for **both** `index.md`
/// (the capped browse view) **and** `index.jsonl` (the complete, post-compaction
/// structured twin). The loop path can never drift from the repair path.
///
/// Single store, two phases: (1) run the scripted sequence and snapshot the
/// live catalog the write commands maintained; (2) run `index rebuild` over the
/// untouched end state (rebuild only rewrites the derived index artifacts, not
/// the content files) and snapshot again. The two snapshots must match exactly.
///
/// The sequence is chosen to exercise the cross-cutting write-through paths,
/// not just the easy case:
///   - `write` a new file with **pinned** `created`/`updated` (so the end state
///     is deterministic — both index paths read the timestamps off the file, so
///     wall-clock seeding is irrelevant to the comparison) → upsert into a
///     type-folder + parent-rollup recompute.
///   - `fm set status=…` then `fm set updated=…` on an existing file → in-place
///     field update **and** a recency re-sort of the type-folder browse view.
///   - `rename` a `contact` that is referenced by `attendees:` wiki-links in
///     three `meeting` records → the moved file's two type-folder indexes are
///     fixed, **and** every rewritten linker's `index.jsonl` entry is refreshed
///     so its `attendees` field tracks the new path. (A rebuild re-reads those
///     rewritten meeting files; if the loop path failed to refresh the linker
///     entries, their `index.jsonl` would still carry the pre-rename target and
///     this test would catch the drift.)
#[test]
fn writethrough_sequence_equals_rebuild_byte_for_byte() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());

    // corpus-a ships an `EXPECTED/index/` golden snapshot + `.gen-*.py` fixture
    // generators under its own root. They are test scaffolding, not store
    // content (no live index level lives there, and `rename`'s store-wide link
    // rewrite would otherwise wander into the golden copies). Remove them so the
    // store under test is exactly its three layers + meta files.
    std::fs::remove_dir_all(store.join("EXPECTED")).ok();
    for entry in std::fs::read_dir(&store).unwrap() {
        let entry = entry.unwrap();
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if name.starts_with(".gen-") && name.ends_with(".py") {
            std::fs::remove_file(entry.path()).ok();
        }
    }

    // ── Phase 1: the scripted loop sequence (write-through maintained) ───────
    // Every command runs from inside the store: `dbmd fm set` always opens the
    // store at the CWD (no `--dir`), so this is the configuration that drives
    // all three write surfaces uniformly with store-relative paths.

    // (a) write — a new contact, timestamps pinned for a deterministic end state.
    dbmd()
        .current_dir(&store)
        .args([
            "write",
            "records/contacts/jordan-li.md",
            "--type",
            "contact",
            "--summary",
            "VP Eng at Globex; technical sponsor on the platform eval",
            "--fm",
            "created=2026-05-25T10:00:00-07:00",
            "--fm",
            "updated=2026-05-25T10:00:00-07:00",
            "--fm",
            "name=Jordan Li",
        ])
        .assert()
        .success();

    // (b) fm set — update a field, then bump `updated` (a recency re-sort of the
    //     contacts browse view, write-through).
    dbmd()
        .current_dir(&store)
        .args([
            "fm",
            "set",
            "records/contacts/marcus-okafor.md",
            "status=inactive",
        ])
        .assert()
        .success();
    dbmd()
        .current_dir(&store)
        .args([
            "fm",
            "set",
            "records/contacts/marcus-okafor.md",
            "updated=2026-05-28T12:00:00-07:00",
        ])
        .assert()
        .success();

    // (c) rename — move a contact referenced by `attendees:` links in meetings.
    //     Exercises both the moved file's index move AND the linker-entry refresh.
    dbmd()
        .current_dir(&store)
        .args([
            "rename",
            "records/contacts/david-kim.md",
            "records/contacts/david-kim-ae.md",
        ])
        .assert()
        .success();

    // Sanity: the rename actually rewrote the indexed `attendees` field in the
    // meeting files on disk (so the linker-refresh path is genuinely under test,
    // not vacuously satisfied).
    let meeting = std::fs::read_to_string(
        store.join("records/meetings/2026/05/2026-05-22-northstar-renewal-call.md"),
    )
    .unwrap();
    assert!(
        meeting.contains("[[records/contacts/david-kim-ae]]")
            && !meeting.contains("[[records/contacts/david-kim]]"),
        "precondition: rename must have rewritten the meeting's attendees link"
    );

    // Snapshot the catalog the write commands maintained write-through.
    let write_through = live_index_artifacts(&store);
    assert!(
        write_through.contains_key("index.md")
            && write_through.contains_key("records/contacts/index.md")
            && write_through.contains_key("records/contacts/index.jsonl")
            && write_through.contains_key("records/meetings/index.jsonl"),
        "the sequence must have produced the catalog artifacts under test (not a vacuous compare): {:?}",
        write_through.keys().collect::<Vec<_>>()
    );

    // ── Phase 2: from-scratch rebuild over the identical end state ───────────
    // `index rebuild` rewrites only the derived index artifacts; the content
    // files (and thus the end state both paths index) are untouched.
    dbmd()
        .current_dir(&store)
        .args(["index", "rebuild"])
        .assert()
        .success();
    let rebuilt = live_index_artifacts(&store);

    // ── The assertion: byte-identical, artifact-by-artifact, md AND jsonl ────
    assert_eq!(
        write_through.keys().collect::<Vec<_>>(),
        rebuilt.keys().collect::<Vec<_>>(),
        "write-through and rebuild must produce the SAME set of index artifacts\
         \n  write-through: {:?}\n  rebuild:       {:?}",
        write_through.keys().collect::<Vec<_>>(),
        rebuilt.keys().collect::<Vec<_>>(),
    );
    for (rel, wt_bytes) in &write_through {
        let rb_bytes = &rebuilt[rel];
        assert_eq!(
            wt_bytes,
            rb_bytes,
            "INVARIANT VIOLATED: `{rel}` differs between write-through and rebuild\
             \n--- write-through ---\n{}\n--- rebuild ---\n{}",
            String::from_utf8_lossy(wt_bytes),
            String::from_utf8_lossy(rb_bytes),
        );
    }
}

#[test]
fn rebuild_scoped_to_a_folder_matches_committed() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());

    dbmd()
        .current_dir(&store)
        .args(["index", "rebuild", "--folder", "records/contacts"])
        .assert()
        .success();

    for rel in ["records/contacts/index.md", "records/contacts/index.jsonl"] {
        let rebuilt = std::fs::read(store.join(rel)).unwrap();
        let original = std::fs::read(corpus_a().join(rel)).unwrap();
        assert_eq!(
            rebuilt, original,
            "{rel} identical after folder-scoped rebuild"
        );
    }
}

#[test]
fn rebuild_folder_refuses_paths_outside_the_store() {
    let tmp = tempfile::TempDir::new().expect("store tempdir");
    let store = tmp.path();
    write_db_md(store);

    let outside = tempfile::TempDir::new().expect("outside tempdir");
    let outside_folder = outside.path().join("records").join("contacts");
    std::fs::create_dir_all(&outside_folder).unwrap();

    let out = dbmd()
        .current_dir(store)
        .args([
            "--json",
            "index",
            "rebuild",
            "--folder",
            outside_folder.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .code(1);
    let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();
    let v: serde_json::Value = serde_json::from_str(stderr.trim()).expect("json error on stderr");
    assert_eq!(v["error"]["code"], serde_json::json!("PATH_OUTSIDE_STORE"));
    assert!(
        !outside_folder.join("index.md").exists() && !outside_folder.join("index.jsonl").exists(),
        "a refused folder rebuild must not create outside-store index artifacts"
    );
}

#[test]
fn rebuild_folder_rejects_traversal_scope() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());
    dbmd()
        .current_dir(&store)
        .args(["index", "rebuild", "--folder", "records/../contacts"])
        .assert()
        .failure()
        .code(1);
}

#[test]
fn rebuild_dry_run_previews_without_writing() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());

    // Mutate the on-disk index so a real rebuild WOULD change it; --dry-run must
    // leave it untouched while still printing the would-be content.
    let target = store.join("records/contacts/index.md");
    let before = std::fs::read(&target).unwrap();
    std::fs::write(&target, "STALE PLACEHOLDER\n").unwrap();

    let out = dbmd()
        .current_dir(&store)
        .args([
            "index",
            "rebuild",
            "--dry-run",
            "--folder",
            "records/contacts",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();

    // The preview carries the `--- <path> ---` separators for both artifacts.
    assert!(
        stdout.contains("--- records/contacts/index.md ---"),
        "dry-run prints the md separator:\n{stdout}"
    );
    assert!(
        stdout.contains("--- records/contacts/index.jsonl ---"),
        "dry-run prints the jsonl separator:\n{stdout}"
    );
    // Nothing was written: the stale placeholder is still on disk.
    let after = std::fs::read(&target).unwrap();
    assert_eq!(after, b"STALE PLACEHOLDER\n", "dry-run must not write");
    assert_ne!(after, before);
}

#[test]
fn rebuild_dry_run_json_wraps_preview_without_writing() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());
    let target = store.join("records/contacts/index.md");
    std::fs::write(&target, "STALE PLACEHOLDER\n").unwrap();

    let out = dbmd()
        .current_dir(&store)
        .args([
            "--json",
            "index",
            "rebuild",
            "--dry-run",
            "--folder",
            "records/contacts",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v["dry_run"], true);
    assert_eq!(v["scope"], "folder records/contacts");
    assert!(v["preview"]
        .as_str()
        .unwrap()
        .contains("--- records/contacts/index.md ---"));
    assert_eq!(
        std::fs::read_to_string(&target).unwrap(),
        "STALE PLACEHOLDER\n"
    );
}

#[test]
fn rebuild_rejects_both_layer_and_folder() {
    let (_tmp, store) = copy_store_to_temp(&corpus_a());
    dbmd()
        .current_dir(&store)
        .args([
            "index",
            "rebuild",
            "--layer",
            "records",
            "--folder",
            "records/contacts",
        ])
        .assert()
        .failure()
        .code(1);
}

// ── show ───────────────────────────────────────────────────────────────────────

#[test]
fn show_root_prints_the_root_index() {
    let out = dbmd()
        .args(["index", "show"])
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("scope: root"),
        "root index frontmatter:\n{stdout}"
    );
    assert!(stdout.contains("# Knowledge base index"));
}

#[test]
fn show_scoped_prints_a_type_folder_index() {
    let out = dbmd()
        .args(["index", "show", "wiki/people"])
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("folder: wiki/people"));
    assert!(stdout.contains("[[wiki/people/sarah-chen]]"));
}

#[test]
fn show_json_wraps_path_and_contents() {
    let out = dbmd()
        .args(["--json", "index", "show", "wiki/people"])
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v["path"], "wiki/people/index.md");
    assert!(v["contents"]
        .as_str()
        .unwrap()
        .contains("folder: wiki/people"));
}

#[test]
fn show_refuses_paths_outside_the_store() {
    let tmp = tempfile::TempDir::new().expect("store tempdir");
    let store = tmp.path();
    write_db_md(store);

    let outside = tempfile::TempDir::new().expect("outside tempdir");
    let outside_index = write_file(
        outside.path(),
        "records/contacts/index.md",
        "OUTSIDE INDEX\n",
    );
    let outside_scope = outside_index.parent().unwrap();

    let out = dbmd()
        .current_dir(store)
        .args(["--json", "index", "show", outside_scope.to_str().unwrap()])
        .assert()
        .failure()
        .code(1);
    let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();
    let v: serde_json::Value = serde_json::from_str(stderr.trim()).expect("json error on stderr");
    assert_eq!(v["error"]["code"], serde_json::json!("PATH_OUTSIDE_STORE"));
    assert!(
        out.get_output().stdout.is_empty(),
        "outside index content must not be printed"
    );
}

#[test]
fn show_byte_matches_the_committed_index() {
    // `index show <path>` is a straight read-through of the committed file.
    let out = dbmd()
        .args(["index", "show", "records/contacts"])
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let printed = out.get_output().stdout.clone();
    let on_disk = std::fs::read(corpus_a().join("records/contacts/index.md")).unwrap();
    assert_eq!(printed, on_disk, "show prints the index.md verbatim");
}

#[test]
fn show_missing_index_exits_1_with_empty_stdout_and_a_hint() {
    let out = dbmd()
        .args(["index", "show", "records/nonexistent"])
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .failure()
        .code(1);
    let o = out.get_output();
    assert!(
        o.stdout.is_empty(),
        "stdout stays empty so pipelines don't break"
    );
    let stderr = String::from_utf8(o.stderr.clone()).unwrap();
    assert!(
        stderr.contains("no index.md") && stderr.contains("rebuild"),
        "stderr carries the rebuild hint; got: {stderr}"
    );
}

// ── query ──────────────────────────────────────────────────────────────────────

/// Run `dbmd index query <args> --dir corpus_a`; return stdout path lines.
fn query_paths(args: &[&str]) -> Vec<String> {
    let out = dbmd()
        .arg("index")
        .arg("query")
        .args(args)
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    stdout.lines().map(str::to_string).collect()
}

#[test]
fn query_by_type_returns_the_folder_records() {
    let got: BTreeSet<String> = query_paths(&["--type", "invoice", "--in", "records"])
        .into_iter()
        .collect();
    let expected: BTreeSet<String> = [
        "records/invoices/2026/04/2026-04-18-figma-annual.md",
        "records/invoices/2026/04/2026-04-30-aws-april.md",
        "records/invoices/2026/05/2026-05-31-aws-may.md",
    ]
    .iter()
    .map(|s| s.to_string())
    .collect();
    assert_eq!(got, expected);
}

#[test]
fn query_where_narrows_within_type() {
    let got: BTreeSet<String> = query_paths(&["--type", "invoice", "--where", "status=paid"])
        .into_iter()
        .collect();
    // Two of the three invoices are paid.
    assert!(got.contains("records/invoices/2026/04/2026-04-18-figma-annual.md"));
    assert!(got.contains("records/invoices/2026/04/2026-04-30-aws-april.md"));
    assert!(!got.contains("records/invoices/2026/05/2026-05-31-aws-may.md"));
}

#[test]
fn query_updated_after_window_filters_by_recency() {
    // The AWS-April invoice was updated 2026-05-02 (paid); the others on their
    // create date. An updated-after 2026-05-01 window keeps only the May-touched
    // ones (the 05-02 paid invoice + the 05-31 unpaid invoice).
    let got: BTreeSet<String> =
        query_paths(&["--type", "invoice", "--updated-after", "2026-05-01"])
            .into_iter()
            .collect();
    assert!(got.contains("records/invoices/2026/04/2026-04-30-aws-april.md"));
    assert!(got.contains("records/invoices/2026/05/2026-05-31-aws-may.md"));
    assert!(
        !got.contains("records/invoices/2026/04/2026-04-18-figma-annual.md"),
        "the figma invoice (updated 2026-04-18) is outside the window: {got:?}"
    );
}

#[test]
fn query_limit_caps_results() {
    let capped = query_paths(&["--type", "expense", "--limit", "3"]);
    assert_eq!(capped.len(), 3, "limit caps the complete result set");
}

#[test]
fn query_json_returns_full_records() {
    let out = dbmd()
        .args([
            "index",
            "query",
            "--type",
            "invoice",
            "--where",
            "status=paid",
        ])
        .arg("--json")
        .arg("--dir")
        .arg(corpus_a())
        .assert()
        .success();
    let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    let arr = v.as_array().expect("array of records");
    assert_eq!(arr.len(), 2);
    for rec in arr {
        // Full structured record: path + summary + status field, straight from
        // the sidecar.
        assert_eq!(rec["status"], serde_json::json!("paid"));
        assert!(rec["path"]
            .as_str()
            .unwrap()
            .starts_with("records/invoices/"));
        assert!(rec.get("summary").is_some());
    }
}
