"""SelfModel — schema, snapshot, falsification (ticket #000014).

Covers:
- canonical-JSON round-trip stability
- selfmodel_root invariance under capability-claim ordering
- snapshot determinism given fixed store state
- store_snapshot idempotency on the same root
- falsify and mark_stale audit events
- chain-check stays clean after SelfModel landing
"""

from __future__ import annotations

import time

import pytest

from arborist.selfmodel import (
    CapabilityClaim,
    SelfModel,
    canonical_body,
    claim_hash,
    falsify,
    latest,
    load,
    mark_stale,
    selfmodel_root,
    snapshot,
    store_snapshot,
)
from arborist.selfmodel.canonical import SCHEMA_VERSION, with_claims
from arborist.selfmodel.store import claims_for
from arborist.store import connect, latest_event_hash, transaction


# --- canonical-JSON ---------------------------------------------------


def _bare_model() -> SelfModel:
    return SelfModel(
        schema_version=SCHEMA_VERSION,
        parent_selfmodel_root=None,
        model_profile_hash="model-profile-aaaa",
        verifier_method_root="verifier-root-bbbb",
        governance_policy_hash="gov-cccc",
        canonicalization_version="norm-v1",
        chunking_version="tok-512-v1",
        accepted_patch_root=None,
        rejected_patch_root=None,
        memory_root=None,
    )


def test_canonical_body_stable_across_field_reorder():
    a = _bare_model()
    # Build "different" object with same logical content via dict round-trip.
    b = SelfModel(
        chunking_version="tok-512-v1",
        accepted_patch_root=None,
        canonicalization_version="norm-v1",
        governance_policy_hash="gov-cccc",
        memory_root=None,
        model_profile_hash="model-profile-aaaa",
        parent_selfmodel_root=None,
        rejected_patch_root=None,
        schema_version=SCHEMA_VERSION,
        verifier_method_root="verifier-root-bbbb",
    )
    assert canonical_body(a) == canonical_body(b)
    assert selfmodel_root(a) == selfmodel_root(b)


def test_selfmodel_root_invariant_under_claim_order():
    base = _bare_model()
    c1 = CapabilityClaim(
        metric="strict_rate", threshold=0.50, eval_digest="d1"
    )
    c2 = CapabilityClaim(
        metric="directive_coverage", threshold=0.99, eval_digest="d2"
    )
    a = with_claims(base, [c1, c2])
    b = with_claims(base, [c2, c1])
    assert selfmodel_root(a) == selfmodel_root(b)


def test_canonical_body_changes_when_field_changes():
    a = _bare_model()
    b = SelfModel(**{**a.__dict__, "model_profile_hash": "different"})
    assert selfmodel_root(a) != selfmodel_root(b)


# --- snapshot ---------------------------------------------------------


def test_snapshot_returns_selfmodel_with_defaults_on_empty_db(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
        assert sm.schema_version == SCHEMA_VERSION
        assert sm.governance_policy_hash == "unset"
        assert sm.model_profile_hash == "unset"
        assert sm.canonicalization_version == "norm-v1"
        assert sm.chunking_version == "tok-512-v1"
        assert sm.parent_selfmodel_root is None
        # verifier_method_root is SHA-256 over an empty list; always 64 hex.
        assert len(sm.verifier_method_root) == 64
    finally:
        conn.close()


def test_snapshot_deterministic_on_same_state(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            a = snapshot(conn)
            b = snapshot(conn)
        assert selfmodel_root(a) == selfmodel_root(b)
    finally:
        conn.close()


# --- store_snapshot ---------------------------------------------------


def test_store_snapshot_persists_and_emits_audit(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
        loaded = load(conn, root)
        assert loaded is not None
        assert loaded["selfmodel_root"] == root
        assert loaded["state"] == "live"
        assert loaded["audit_event_hash"] is not None

        # An audit event of type selfmodel_snapshot_landed exists.
        row = conn.execute(
            "SELECT event_type FROM audit_events "
            "WHERE event_type='selfmodel_snapshot_landed' "
            "AND subject_root = ?",
            (root,),
        ).fetchone()
        assert row is not None
    finally:
        conn.close()


def test_store_snapshot_idempotent(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            r1 = store_snapshot(conn, sm)
            r2 = store_snapshot(conn, sm)
        assert r1 == r2
        # Only one selfmodel_snapshot_landed event.
        n = conn.execute(
            "SELECT COUNT(*) FROM audit_events "
            "WHERE event_type='selfmodel_snapshot_landed' "
            "AND subject_root = ?",
            (r1,),
        ).fetchone()[0]
        assert n == 1
    finally:
        conn.close()


def test_store_snapshot_with_claims(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        c = CapabilityClaim(
            metric="strict_rate",
            threshold=0.50,
            eval_digest="bench-fixture-aabb",
            measured_value=0.54,
            measured_at=1700000000,
        )
        with transaction(conn):
            sm = with_claims(snapshot(conn), [c])
            root = store_snapshot(conn, sm, claims=[c])
        rows = claims_for(conn, root)
        assert len(rows) == 1
        assert rows[0]["metric"] == "strict_rate"
        assert rows[0]["threshold"] == pytest.approx(0.50)
        # Audit event for claim emission.
        ev = conn.execute(
            "SELECT event_type FROM audit_events "
            "WHERE event_type='selfmodel_capability_claim_added' "
            "AND subject_root = ?",
            (root,),
        ).fetchone()
        assert ev is not None
    finally:
        conn.close()


def test_latest_returns_most_recent_live(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm1 = snapshot(conn)
            r1 = store_snapshot(conn, sm1, ts=1700000000)
            # Force a different model-profile-hash on the second snapshot.
            sm2_dict = {**sm1.__dict__, "model_profile_hash": "shifted"}
            sm2 = SelfModel(**sm2_dict)
            r2 = store_snapshot(conn, sm2, ts=1700000100)
        live = latest(conn)
        assert live is not None
        assert live["selfmodel_root"] == r2
        assert r1 != r2
    finally:
        conn.close()


# --- falsify / mark_stale ---------------------------------------------


def test_falsify_flips_state_and_emits_event(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
            event_hash = falsify(
                conn,
                root,
                reason="strict_rate dropped to 0.40 under bench",
                triggering_claim_hash="claim-aaaa",
            )
        loaded = load(conn, root)
        assert loaded["state"] == "falsified"
        assert loaded["falsified_reason"].startswith("strict_rate")
        assert event_hash != ""

        ev = conn.execute(
            "SELECT body FROM audit_events WHERE event_hash = ?",
            (event_hash,),
        ).fetchone()
        assert ev is not None
    finally:
        conn.close()


def test_falsify_idempotent(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
            falsify(conn, root, reason="r1")
            second = falsify(conn, root, reason="r2")
        assert second == ""  # no-op on already-falsified
    finally:
        conn.close()


def test_mark_stale_flips_state_and_emits_event(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
            event_hash = mark_stale(conn, root, reason="verifier method shifted")
        loaded = load(conn, root)
        assert loaded["state"] == "stale"
        assert event_hash != ""
    finally:
        conn.close()


def test_mark_stale_does_not_overwrite_falsified(tmp_path):
    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
            falsify(conn, root, reason="hard regression")
            second = mark_stale(conn, root, reason="just a soft drift")
        assert second == ""  # no-op, falsified is terminal
        loaded = load(conn, root)
        assert loaded["state"] == "falsified"
    finally:
        conn.close()


# --- audit chain integrity --------------------------------------------


def test_audit_chain_stays_clean_after_selfmodel_ops(tmp_path):
    """All SelfModel ops use append_audit, which chains via prev_event_hash.

    Recompute the chain from scratch and assert no breaks.
    """
    import hashlib

    db = tmp_path / "shard.db"
    conn = connect(db)
    try:
        with transaction(conn):
            sm = snapshot(conn)
            root = store_snapshot(conn, sm)
            falsify(conn, root, reason="r")

        rows = conn.execute(
            "SELECT event_hash, prev_event_hash, body, ts "
            "FROM audit_events ORDER BY seq"
        ).fetchall()
        prev = None
        for row in rows:
            h = hashlib.sha256()
            if prev is not None:
                h.update(bytes.fromhex(prev))
            h.update(row["body"].encode("utf-8", errors="surrogatepass"))
            assert h.hexdigest() == row["event_hash"], (
                "audit chain break detected"
            )
            assert row["prev_event_hash"] == prev
            prev = row["event_hash"]
        assert prev == latest_event_hash(conn)
    finally:
        conn.close()
