"""Regression: ``append_audit`` must not fork the chain under concurrency.

qa.db carried a 2-way fork at ``seq`` 7724/7725 — two ``providence_burn``
events whose ``prev_event_hash`` both pointed at row 7723's ``event_hash``.
Cause: ``append_audit`` did ``prev = latest_event_hash(conn)`` then
``INSERT`` as two separate autocommit statements (the burn caller ran it
*outside* its ``transaction()`` block). Two concurrent appenders read the
same head, both chained off it; ``seq AUTOINCREMENT`` serialized the rows
but not the hash linkage.

Fix: ``append_audit`` wraps its head-read + insert in its own
``BEGIN IMMEDIATE``/``COMMIT`` when the connection isn't already in a
transaction, and ``connect()`` sets a ``busy_timeout`` so the loser waits
on the write lock instead of failing ``database is locked`` (and then
re-reading a stale head). This test fans many ``append_audit`` calls
across barrier-synced threads against one shard and asserts the result is
a clean linear chain — every non-NULL ``prev_event_hash`` claimed by
exactly one row, exactly one genesis.
"""

from __future__ import annotations

import threading
from pathlib import Path

from arborist.store import _clear_migration_cache, append_audit, connect


def test_concurrent_append_audit_keeps_chain_linear(tmp_path: Path) -> None:
    _clear_migration_cache()
    db = tmp_path / "fork.db"
    connect(db).close()  # bootstrap schema (no audit rows written)

    n_threads = 8
    per_thread = 20
    barrier = threading.Barrier(n_threads)
    errors: list[BaseException] = []
    lock = threading.Lock()

    def worker(tid: int) -> None:
        try:
            conn = connect(db)
            try:
                barrier.wait(timeout=20)
                for j in range(per_thread):
                    append_audit(
                        conn, event_type="test_event", body={"tid": tid, "j": j}
                    )
            finally:
                conn.close()
        except BaseException as exc:  # noqa: BLE001 — surface it
            with lock:
                errors.append(exc)

    threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=60)

    assert not errors, f"concurrent append_audit raised: {errors!r}"

    conn = connect(db)
    try:
        forks = conn.execute(
            "SELECT prev_event_hash, COUNT(*) AS k FROM audit_events "
            "WHERE prev_event_hash IS NOT NULL GROUP BY prev_event_hash HAVING k > 1"
        ).fetchall()
        genesis = conn.execute(
            "SELECT COUNT(*) FROM audit_events WHERE prev_event_hash IS NULL"
        ).fetchone()[0]
        total = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
        # Walk by seq: every row chains off the immediately-prior row.
        rows = conn.execute(
            "SELECT event_hash, prev_event_hash FROM audit_events ORDER BY seq"
        ).fetchall()
    finally:
        conn.close()

    assert not forks, f"audit chain forked: {[dict(r) for r in forks]}"
    assert genesis == 1, f"expected one genesis row, got {genesis}"
    assert total == n_threads * per_thread

    prev = None
    for r in rows:
        assert r["prev_event_hash"] == prev, "chain not linear in seq order"
        prev = r["event_hash"]


def test_append_audit_inside_transaction_folds_in(tmp_path: Path) -> None:
    """When the caller already holds a ``transaction()``, ``append_audit``
    must NOT open a nested ``BEGIN IMMEDIATE`` (SQLite forbids it) — it
    folds the head-read + insert into the caller's unit, and a rollback
    drops the audit row with the rest of the work."""
    from arborist.store import transaction

    _clear_migration_cache()
    db = tmp_path / "txn.db"
    conn = connect(db)
    try:
        before = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
        # Commit path: audit row persists.
        with transaction(conn):
            append_audit(conn, event_type="t", body={"k": 1})
        assert (
            conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
            == before + 1
        )
        # Rollback path: audit row is dropped with the rest.
        try:
            with transaction(conn):
                append_audit(conn, event_type="t", body={"k": 2})
                raise RuntimeError("boom")
        except RuntimeError:
            pass
        assert (
            conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
            == before + 1
        )
    finally:
        conn.close()
