"""Regression tests for ticket #000026 Phase 1.

Per-process migration memoization. ``arborist.store.connect()`` used
to run every forward-migration probe on every open — hundreds of
redundant probe sequences per typical query. With memoization, the
migration block runs once per (physical file, process) and is skipped
on every subsequent open of the same shard.

Tests pin:

- First connect runs migrations.
- Second connect on the same path skips them.
- Functional integrity is preserved (all schema is in place after
  the second connect).
- The cache invalidates when a file is replaced at the same path
  (different inode → migrations re-run).
- ``_clear_migration_cache()`` resets the memo for tests / explicit
  re-probe.
"""

from __future__ import annotations

from pathlib import Path

from arborist import store
from arborist.store import (
    _clear_migration_cache,
    connect,
    invalidate_migration_cache,
)


class _MigrationProbeCounter:
    """Wraps each migration helper and counts invocations.

    Patches the module-level names so ``connect()`` (which references
    them via the ``arborist.store`` module globals) calls the
    counted versions.
    """

    # Mirrors the forward-migration sequence in ``store.connect()`` — keep
    # in sync when a migration is added (otherwise its memoization goes
    # unverified, and the count assertions below silently under-count).
    NAMES = (
        "_migrate_audit_mode",
        "_migrate_mesh_peer_chains",
        "_migrate_document_http_meta",
        "_migrate_selfmodel_tables",
        "_migrate_capital_ledger",
        "_migrate_memory_root",
        "_migrate_adapter_loss_reports",
        "_migrate_controller_events",
        "_migrate_fork_score_branches",
    )

    def __init__(self, monkeypatch):
        self.counts: dict[str, int] = {n: 0 for n in self.NAMES}
        for name in self.NAMES:
            original = getattr(store, name)

            def _wrapped(conn, *, _orig=original, _name=name):
                self.counts[_name] += 1
                return _orig(conn)

            monkeypatch.setattr(store, name, _wrapped)

    @property
    def total(self) -> int:
        return sum(self.counts.values())


def test_first_connect_runs_all_forward_migrations(tmp_path: Path, monkeypatch):
    _clear_migration_cache()
    counter = _MigrationProbeCounter(monkeypatch)
    db = tmp_path / "first.db"

    conn = connect(db)
    conn.close()

    # Every probe ran exactly once.
    assert counter.total == len(counter.NAMES)
    for name in counter.NAMES:
        assert counter.counts[name] == 1


def test_second_connect_skips_migrations(tmp_path: Path, monkeypatch):
    _clear_migration_cache()
    db = tmp_path / "second.db"

    # Warm the cache with a fresh open (no probe counter yet).
    conn = connect(db)
    conn.close()

    # Now patch in counters and open again. Zero probes should run.
    counter = _MigrationProbeCounter(monkeypatch)
    conn = connect(db)
    conn.close()
    assert counter.total == 0


def test_second_connect_schema_is_intact(tmp_path: Path):
    """Belt-and-suspenders: migration skipping must not break schema
    visibility. Open + close + reopen, then read every table the
    migrations create."""
    _clear_migration_cache()
    db = tmp_path / "intact.db"
    connect(db).close()

    conn = connect(db)
    try:
        # Smoke-check tables that each migration is responsible for.
        for table in (
            "providence_cache",       # _migrate_audit_mode
            "mesh_peer_chains",       # _migrate_mesh_peer_chains
            "document_http_meta",     # _migrate_document_http_meta
            "selfmodel_records",      # _migrate_selfmodel_tables
            "capital_ledger",         # _migrate_capital_ledger
            "memory_records",         # _migrate_memory_root
            "adapter_loss_reports",   # _migrate_adapter_loss_reports
            "controller_events",      # _migrate_controller_events
            "fork_score_branches",    # _migrate_fork_score_branches
        ):
            row = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
                (table,),
            ).fetchone()
            assert row is not None, f"missing table after re-open: {table}"
    finally:
        conn.close()


def test_clear_migration_cache_forces_reprobe(tmp_path: Path, monkeypatch):
    _clear_migration_cache()
    db = tmp_path / "clear.db"

    connect(db).close()

    counter = _MigrationProbeCounter(monkeypatch)

    # Without clearing — zero probes.
    connect(db).close()
    assert counter.total == 0

    # After clearing — full re-probe.
    _clear_migration_cache()
    connect(db).close()
    assert counter.total == len(counter.NAMES)


def test_invalidate_migration_cache_after_file_replacement(
    tmp_path: Path, monkeypatch
):
    """Caller-driven invalidation contract: after replacing a shard
    at the same path, the caller must call
    ``invalidate_migration_cache(path)`` so the next ``connect()``
    re-runs the full probe sequence on the new file.

    We don't auto-detect replacement — (dev, inode) is unreliable
    under tmpfs inode reuse, and (mtime, size) drifts naturally as
    SQLite operates. Path-only with explicit invalidation is the
    honest contract; tests + snapshot-restore flows are responsible
    for calling the invalidator."""
    _clear_migration_cache()
    db = tmp_path / "replaced.db"

    # First open — populate cache.
    connect(db).close()

    # Replace the file at the same path.
    db.unlink()

    # Without invalidation, the cache still claims migrations are
    # current — schema would be silently absent on the new file.
    invalidate_migration_cache(db)

    counter = _MigrationProbeCounter(monkeypatch)
    conn = connect(db)
    conn.close()
    assert counter.total == len(counter.NAMES)


def test_distinct_paths_each_get_one_probe(tmp_path: Path, monkeypatch):
    """Two different shard files get one probe each — the cache is
    per-path, not global."""
    _clear_migration_cache()
    db_a = tmp_path / "a.db"
    db_b = tmp_path / "b.db"

    counter = _MigrationProbeCounter(monkeypatch)
    connect(db_a).close()
    connect(db_b).close()
    # Two paths × the full migration sequence.
    assert counter.total == 2 * len(counter.NAMES)

    # Re-open both — cache hit on each, zero new probes.
    before = counter.total
    connect(db_a).close()
    connect(db_b).close()
    assert counter.total == before
