//! Schema versioning: every logged morphism carries a `schema_hash` that
//! pins it to the (kcl + manifest spec + rhai) bundle active at write
//! time. `verify_log` rejects logs whose entries were produced under
//! rules that no longer match the loaded executor.
//!
//! The tests here build a *temp copy* of the treasury module so we can
//! mutate its files without polluting the source tree. Each test cleans
//! its temp dir even if it panics (the helper drops via `TempModule`).

use std::path::{Path, PathBuf};

use nakui_core::event_log::{
    execute_and_log, replay, seed_and_log, verify_log, EventLog, LogEntry, VerifyError,
};
use nakui_core::executor::Executor;
use nakui_core::store::MemoryStore;
use serde_json::{json, Value};
use uuid::Uuid;

fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("workspace root above core/")
        .to_path_buf()
}

fn treasury_module() -> PathBuf {
    workspace_root().join("modules/treasury")
}

fn fresh_log_path() -> PathBuf {
    std::env::temp_dir().join(format!("nakui_schema_{}.jsonl", Uuid::new_v4()))
}

/// Owned temp copy of a module directory. Drops the entire tree.
struct TempModule {
    pub path: PathBuf,
}

impl TempModule {
    fn from(src: &Path) -> Self {
        let dst = std::env::temp_dir().join(format!("nakui_module_{}", Uuid::new_v4()));
        copy_dir_recursive(src, &dst).expect("copy module");
        Self { path: dst }
    }
}

impl Drop for TempModule {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.path);
    }
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        let dst_path = dst.join(entry.file_name());
        if ty.is_dir() {
            copy_dir_recursive(&entry.path(), &dst_path)?;
        } else {
            std::fs::copy(entry.path(), &dst_path)?;
        }
    }
    Ok(())
}

fn deposit_5k(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid) {
    execute_and_log(
        exec,
        store,
        log,
        "register_cash_move",
        &[("caja", caja)],
        json!({
            "monto": 5_000_i64,
            "tipo": "in",
            "timestamp": "2026-05-04T10:00:00Z",
            "memo": "x",
            "movimiento_id": Uuid::new_v4().to_string(),
        }),
    )
    .expect("deposit");
}

fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid) {
    seed_and_log(
        exec,
        store,
        log,
        "Caja",
        id,
        json!({"id": id.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
    )
    .unwrap();
}

#[test]
fn executor_exposes_per_morphism_schema_hash() {
    let exec = Executor::load_module(treasury_module()).expect("load");
    let h_deposit = exec
        .schema_hash("register_cash_move")
        .expect("register_cash_move has a hash");
    let h_transfer = exec
        .schema_hash("transfer_between_cajas")
        .expect("transfer_between_cajas has a hash");
    assert_ne!(
        h_deposit, h_transfer,
        "different morphisms must have different hashes"
    );
    assert!(
        exec.schema_hash("not_a_real_morphism").is_none(),
        "unknown morphisms have no hash"
    );

    // Re-loading the same module yields the same hashes — the contract
    // depends only on the bytes on disk, not load-time state.
    let exec2 = Executor::load_module(treasury_module()).expect("reload");
    assert_eq!(
        exec.schema_hash("register_cash_move"),
        exec2.schema_hash("register_cash_move")
    );
}

#[test]
fn execute_and_log_writes_schema_hash_into_entries() {
    let temp = TempModule::from(&treasury_module());
    let exec = Executor::load_module(&temp.path).expect("load");
    let log_path = fresh_log_path();
    let mut log = EventLog::open(&log_path).unwrap();
    let mut store = MemoryStore::new();

    let a = Uuid::new_v4();
    seed_caja(&exec, &mut store, &mut log, a);
    deposit_5k(&exec, &mut store, &mut log, a);

    let entries = log.entries().unwrap();
    let morphism_entry = entries
        .iter()
        .find_map(|e| match e {
            LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
            _ => None,
        })
        .expect("morphism entry present");
    assert_eq!(
        morphism_entry,
        Some(exec.schema_hash("register_cash_move").unwrap()),
        "logged hash must equal the executor's hash for that morphism"
    );

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn verify_log_passes_when_module_is_unchanged() {
    let temp = TempModule::from(&treasury_module());
    let exec = Executor::load_module(&temp.path).expect("load");
    let log_path = fresh_log_path();
    let mut log = EventLog::open(&log_path).unwrap();
    let mut store = MemoryStore::new();

    let a = Uuid::new_v4();
    seed_caja(&exec, &mut store, &mut log, a);
    deposit_5k(&exec, &mut store, &mut log, a);

    verify_log(&log, &exec).expect("clean module → verify ok");

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn verify_log_rejects_log_after_morphism_script_changes() {
    let temp = TempModule::from(&treasury_module());

    // Write a log under the original script.
    let log_path = fresh_log_path();
    let a = Uuid::new_v4();
    let original_hash;
    {
        let exec = Executor::load_module(&temp.path).expect("load v1");
        original_hash = exec.schema_hash("register_cash_move").unwrap();
        let mut log = EventLog::open(&log_path).unwrap();
        let mut store = MemoryStore::new();
        seed_caja(&exec, &mut store, &mut log, a);
        deposit_5k(&exec, &mut store, &mut log, a);
    }

    // Mutate the script with a real (non-cosmetic) change — prepend a
    // new statement. The normalizer preserves this since it changes
    // tokens, not just whitespace/comments.
    let script_path = temp.path.join("morphisms/register_cash_move.rhai");
    let original = std::fs::read_to_string(&script_path).expect("read script");
    std::fs::write(
        &script_path,
        format!("let _audit_marker = 42;\n{}", original),
    )
    .expect("write script");

    // Reload — the hash for register_cash_move must change.
    let exec2 = Executor::load_module(&temp.path).expect("reload v2");
    let new_hash = exec2.schema_hash("register_cash_move").unwrap();
    assert_ne!(
        original_hash, new_hash,
        "real source edit must move the hash"
    );

    // verify_log must surface SchemaMismatch, not OpsMismatch — the
    // schema check runs first because "rules changed" is more
    // actionable than "ops differ for some reason."
    let log = EventLog::open(&log_path).unwrap();
    match verify_log(&log, &exec2) {
        Err(VerifyError::SchemaMismatch {
            morphism,
            logged,
            current,
            ..
        }) => {
            assert_eq!(morphism, "register_cash_move");
            assert_eq!(logged, original_hash);
            assert_eq!(current, new_hash);
        }
        other => panic!("expected SchemaMismatch, got {:?}", other),
    }

    // Replay still works — it doesn't validate against the executor.
    let replayed = replay(&log).expect("replay is schema-agnostic");
    assert!(replayed.records().contains_key("Caja"));

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn legacy_log_without_schema_hash_still_replays_and_verifies() {
    // Hand-craft a log entry that omits schema_hash entirely — what an
    // older nakui-core would have written. The Option default lets it
    // deserialize, replay walks ops the normal way, and verify_log
    // skips the schema check because the entry predates the contract.
    let log_path = fresh_log_path();
    let a = Uuid::new_v4();
    {
        let exec = Executor::load_module(treasury_module()).expect("load");
        let mut log = EventLog::open(&log_path).unwrap();
        let mut store = MemoryStore::new();
        seed_caja(&exec, &mut store, &mut log, a);
        // Now write a Morphism entry by hand, bypassing execute_and_log,
        // simulating a log produced by an older binary.
        let entry: Value = json!({
            "kind": "morphism",
            "seq": log.next_seq(),
            "morphism": "register_cash_move",
            "inputs": {"caja": a.to_string()},
            "params": {
                "monto": 5_000,
                "tipo": "in",
                "timestamp": "2026-05-04T10:00:00Z",
                "memo": "legacy",
                "movimiento_id": Uuid::new_v4().to_string(),
            },
            "ops": []
            // NOTE: no schema_hash field — that's the legacy shape.
        });
        // Append via raw IO to skip log.append's monotonic check (which
        // we trivially satisfy anyway since seq is correct).
        let line = serde_json::to_string(&entry).unwrap();
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&log_path)
            .unwrap();
        use std::io::Write;
        f.write_all(line.as_bytes()).unwrap();
        f.write_all(b"\n").unwrap();
        f.sync_all().unwrap();
    }

    // Replay must succeed (no schema check).
    let log = EventLog::open(&log_path).unwrap();
    let entries = log.entries().expect("entries parse");
    assert_eq!(entries.len(), 2, "seed + legacy morphism");
    let legacy = entries
        .iter()
        .find_map(|e| match e {
            LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
            _ => None,
        })
        .expect("morphism present");
    assert!(
        legacy.is_none(),
        "legacy entry must deserialize with schema_hash=None"
    );

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn executor_exposes_schema_bundle_hash() {
    let exec1 = Executor::load_module(treasury_module()).expect("load 1");
    let exec2 = Executor::load_module(treasury_module()).expect("load 2");
    assert_eq!(
        exec1.schema_bundle_hash, exec2.schema_bundle_hash,
        "bundle hash must be stable across re-loads of the same module"
    );

    // The bundle hash and the per-morphism hash live in different
    // tag namespaces (`nakui-bundle-v1` vs `nakui-schema-v1`), so they
    // can't accidentally collide even when the script bytes are
    // empty/identical.
    let morph_hash = exec1.schema_hash("register_cash_move").unwrap();
    assert_ne!(exec1.schema_bundle_hash, morph_hash);
}

#[test]
fn seed_and_log_writes_bundle_hash_into_seed_entries() {
    let exec = Executor::load_module(treasury_module()).expect("load");
    let log_path = fresh_log_path();
    let mut log = EventLog::open(&log_path).unwrap();
    let mut store = MemoryStore::new();
    let id = Uuid::new_v4();
    seed_caja(&exec, &mut store, &mut log, id);

    let entries = log.entries().unwrap();
    let seed_hash = entries
        .iter()
        .find_map(|e| match e {
            LogEntry::Seed { schema_hash, .. } => Some(*schema_hash),
            _ => None,
        })
        .expect("seed entry present");
    assert_eq!(
        seed_hash,
        Some(exec.schema_bundle_hash),
        "logged seed hash must equal the executor's bundle hash"
    );

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn verify_log_rejects_seed_after_schema_changes() {
    let temp = TempModule::from(&treasury_module());
    let log_path = fresh_log_path();
    let id = Uuid::new_v4();
    let original_hash;
    {
        let exec = Executor::load_module(&temp.path).expect("load v1");
        original_hash = exec.schema_bundle_hash;
        let mut log = EventLog::open(&log_path).unwrap();
        let mut store = MemoryStore::new();
        seed_caja(&exec, &mut store, &mut log, id);
    }

    // Mutate schema.ncl. Even a comment is enough — bundle hash is byte-
    // level for the same false-positive-over-false-negative reason as
    // morphism hashes.
    let schema_path = temp.path.join("schema.ncl");
    let original = std::fs::read_to_string(&schema_path).expect("read schema");
    std::fs::write(
        &schema_path,
        format!("{}\n# seed-versioning-test mutation\n", original),
    )
    .expect("write schema");

    let exec2 = Executor::load_module(&temp.path).expect("reload v2");
    let new_hash = exec2.schema_bundle_hash;
    assert_ne!(
        original_hash, new_hash,
        "schema.ncl byte change must move the bundle hash"
    );

    let log = EventLog::open(&log_path).unwrap();
    match verify_log(&log, &exec2) {
        Err(VerifyError::SeedSchemaMismatch {
            entity,
            id: mismatched_id,
            logged,
            current,
            ..
        }) => {
            assert_eq!(entity, "Caja");
            assert_eq!(mismatched_id, id);
            assert_eq!(logged, original_hash);
            assert_eq!(current, new_hash);
        }
        other => panic!("expected SeedSchemaMismatch, got {:?}", other),
    }

    let _ = std::fs::remove_file(&log_path);
}

#[test]
fn comment_only_edits_do_not_invalidate_the_hash() {
    // The improvement that motivated the AST-aware normalization:
    // operators leaving TODOs or whitespace edits in scripts no longer
    // re-stamps every log entry. Same script behaviour ⇒ same hash.
    let temp = TempModule::from(&treasury_module());
    let exec1 = Executor::load_module(&temp.path).expect("load v1");
    let h1 = exec1.schema_hash("register_cash_move").unwrap();

    let script_path = temp.path.join("morphisms/register_cash_move.rhai");
    let original = std::fs::read_to_string(&script_path).expect("read");
    std::fs::write(
        &script_path,
        format!(
            "// new top-level comment\n\n\n{}\n\n// trailing TODO\n/*\n  block\n  comment\n*/\n",
            original.replace("// states.caja:", "//   states.caja:    EDITED COMMENT"),
        ),
    )
    .expect("write");

    let exec2 = Executor::load_module(&temp.path).expect("reload v2");
    let h2 = exec2.schema_hash("register_cash_move").unwrap();
    assert_eq!(
        h1, h2,
        "comment-only and whitespace-only edits must not move the hash"
    );

    // Sanity: the bundle hash also stays intact (we didn't touch schema.ncl).
    assert_eq!(exec1.schema_bundle_hash, exec2.schema_bundle_hash);
}

#[test]
fn morphism_script_change_does_not_flag_unrelated_seeds() {
    // Bundle hash covers schema.ncl only — a .rhai edit moves the
    // morphism hash but leaves the bundle hash alone. So existing
    // seeds verify cleanly even when a morphism's behaviour changed.
    let temp = TempModule::from(&treasury_module());
    let log_path = fresh_log_path();
    let id = Uuid::new_v4();
    {
        let exec = Executor::load_module(&temp.path).expect("load v1");
        let mut log = EventLog::open(&log_path).unwrap();
        let mut store = MemoryStore::new();
        seed_caja(&exec, &mut store, &mut log, id);
        // No morphism executed — only the seed is in the log.
    }

    // Modify a Rhai script. Bundle stays the same.
    let script_path = temp.path.join("morphisms/register_cash_move.rhai");
    let original = std::fs::read_to_string(&script_path).expect("read");
    std::fs::write(
        &script_path,
        format!("{}\n// rhai-only mutation\n", original),
    )
    .unwrap();

    let exec2 = Executor::load_module(&temp.path).expect("reload");
    let log = EventLog::open(&log_path).unwrap();
    verify_log(&log, &exec2)
        .expect("seed-only log should pass verify after a morphism-only change");

    let _ = std::fs::remove_file(&log_path);
}
