"""Tests for recovery-tooling gaps: issue #80205 (range-query budget can
omit a recoverable tail row) and the lost_and_found last-resort lane for
sources whose table schemas are unreadable.

The corrupted fixtures here are REAL physical SQLite page damage (flipped
b-tree/schema header bytes), not mocked cursor exceptions.
"""

from __future__ import annotations

import shutil
import sqlite3
from pathlib import Path

import pytest

from hermes_state import SessionDB
from hermes_cli import session_recovery
from hermes_cli.session_lost_and_found import (
    STUB_TITLE_PREFIX,
    classify_lost_and_found_row,
    map_lost_and_found_rows,
    rebuild_fts_indexes,
    stub_missing_parent_sessions,
)
from hermes_cli.session_recovery import (
    SessionRecoverySafetyError,
    SessionRecoverySourceError,
    _probe_populated_edge,
    recover_session_database,
)

from tests.hermes_cli.test_session_recovery import (
    _btree_leaf_pages,
    _make_page_spanning_source,
)


from hermes_cli.session_lost_and_found import find_sqlite3_cli

# .recover needs a sqlite3 shell built with sqlite_dbpage — PATH presence
# alone is not enough (Ubuntu CI ships a build without it).
HAVE_SQLITE3_CLI = find_sqlite3_cli() is not None


# ── physical corruption helpers ─────────────────────────────────────────────


def _page_size(data: bytes) -> int:
    size = int.from_bytes(data[16:18], "big")
    return 65_536 if size == 1 else size


def _leaf_cell_count(path: Path, page_number: int) -> int:
    data = path.read_bytes()
    page_size = _page_size(data)
    header = (page_number - 1) * page_size + (100 if page_number == 1 else 0)
    assert data[header] in {0x0A, 0x0D}
    return int.from_bytes(data[header + 3 : header + 5], "big")


def _corrupt_leaf(path: Path, page_number: int) -> None:
    data = bytearray(path.read_bytes())
    page_size = _page_size(bytes(data))
    header = (page_number - 1) * page_size + (100 if page_number == 1 else 0)
    assert data[header] in {0x0A, 0x0D}
    data[header + 3 : header + 5] = b"\xff\xff"
    path.write_bytes(data)


def _corrupt_schema_page(path: Path) -> None:
    """Damage the sqlite_master b-tree so no table schema is readable.

    Page 1 holds the schema table root. An impossible cell count in its
    header makes every ``PRAGMA table_info`` / schema read raise
    'database disk image is malformed' while the file still opens and the
    data pages of every table remain physically intact.
    """
    data = bytearray(path.read_bytes())
    assert data[:16] == b"SQLite format 3\x00"
    header = 100
    assert data[header] in {0x02, 0x05, 0x0A, 0x0D}
    data[header + 3 : header + 5] = b"\xff\xff"
    path.write_bytes(data)


def _make_schema_unreadable_source(path: Path) -> dict[str, int]:
    db = SessionDB(db_path=path)
    try:
        for session_number in range(3):
            session_id = f"20260812_1353{session_number:02d}_abc{session_number:03x}"
            db.create_session(session_id, "cli", cwd=f"/tmp/laf-{session_number}")
            db.set_session_title(session_id, f"LAF {session_number}")
            for message_number in range(9):
                db.append_message(
                    session_id,
                    "user" if message_number % 2 == 0 else "assistant",
                    f"lost-and-found payload {session_number} {message_number}",
                )
    finally:
        db.close()
    conn = sqlite3.connect(str(path), isolation_level=None)
    try:
        conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        conn.execute("PRAGMA journal_mode=DELETE")
        conn.execute("VACUUM")
    finally:
        conn.close()
    _corrupt_schema_page(path)
    return {"sessions": 3, "messages": 27}


# ── issue #80205: recoverable tail row next to a damaged rowid edge ─────────


def test_exact_lookup_recovers_tail_row_next_to_damaged_high_edge(
    tmp_path: Path,
) -> None:
    """Regression for #80205: a readable boundary row must not be omitted.

    Damaging the RIGHTMOST messages leaf makes the ordered high-edge probe
    fail, so salvage falls back to the full rowid domain. The last readable
    row (the final cell of the last healthy leaf) can only be reached through
    a singleton range once bisection narrows down — and a singleton *range*
    scan must advance past the row into the damaged sibling page to prove the
    range is exhausted, discarding the already-produced row. The fix performs
    an exact ``rowid = ?`` lookup for singleton ranges, which stops at the
    hit and recovers the row exactly as SQLite's page-level ``.recover``
    does.
    """
    source = tmp_path / "tail-damaged.db"
    output = tmp_path / "tail-recovered.db"
    message_count = 320
    messages_root, count_index_root = _make_page_spanning_source(
        source, message_count
    )

    _, leaf_pages = _btree_leaf_pages(source, messages_root)
    assert len(leaf_pages) >= 3
    rightmost_leaf = leaf_pages[-1]
    lost_rows = _leaf_cell_count(source, rightmost_leaf)
    assert 0 < lost_rows < message_count
    boundary_rowid = message_count - lost_rows
    _corrupt_leaf(source, rightmost_leaf)
    if count_index_root is not None:
        _, index_leaves = _btree_leaf_pages(source, count_index_root)
        _corrupt_leaf(source, index_leaves[-1])

    report = recover_session_database(
        source,
        output,
        work_dir=tmp_path,
        chunk_size=8,
        allow_partial=True,
    )

    copied = report["copy"]["messages"]
    bounds = copied["rowid_bounds"]
    # Premise check: the high edge probe really failed and fell back.
    assert any("high rowid" in error for error in bounds["errors"]), bounds
    assert "high" in bounds["fallback_edges"]

    conn = sqlite3.connect(str(output))
    try:
        recovered_ids = {
            int(row[0]) for row in conn.execute("SELECT id FROM messages")
        }
    finally:
        conn.close()

    assert 1 in recovered_ids
    # The headline regression: the last readable row before the damage.
    assert boundary_rowid in recovered_ids, (
        f"boundary row {boundary_rowid} was omitted; max recovered "
        f"{max(recovered_ids)}; exact_lookup_recovered="
        f"{copied.get('exact_lookup_recovered')}"
    )
    assert copied["exact_lookup_recovered"] >= 1
    assert recovered_ids == set(range(1, boundary_rowid + 1))
    assert report["verification"]["integrity_check"] == ["ok"]
    assert report["verified"] is True


def test_probe_populated_edge_caps_synthetic_domain(tmp_path: Path) -> None:
    """The gallop converges on a finite bound in O(log) probes when the
    region beyond the data is cleanly seekable."""
    db_path = tmp_path / "clean.db"
    conn = sqlite3.connect(str(db_path), isolation_level=None)
    try:
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
        conn.executemany(
            "INSERT INTO t (id, v) VALUES (?, ?)",
            [(i, f"value {i}") for i in range(1, 101)],
        )
        probe = _probe_populated_edge(conn, "t", edge="high", anchor=1)
        assert probe["capped"] is True
        assert probe["bound"] >= 100
        assert probe["bound"] < 10_000
        assert probe["probes"] <= 64

        probe_low = _probe_populated_edge(conn, "t", edge="low", anchor=100)
        assert probe_low["capped"] is True
        assert probe_low["bound"] <= 1
    finally:
        conn.close()


# ── lost_and_found lane: unreadable table schemas ───────────────────────────


def test_unreadable_schema_without_cli_names_the_sqlite3_requirement(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Without a sqlite3 CLI the refusal must say exactly what to install."""
    source = tmp_path / "schemaless.db"
    output = tmp_path / "schemaless-recovered.db"
    _make_schema_unreadable_source(source)

    import hermes_cli.session_lost_and_found as laf

    monkeypatch.setattr(laf, "find_sqlite3_cli", lambda: None)
    with pytest.raises(SessionRecoverySourceError) as excinfo:
        recover_session_database(
            source,
            output,
            work_dir=tmp_path,
            allow_partial=True,
        )
    message = str(excinfo.value)
    assert "sessions" in message and "messages" in message
    assert "sqlite3" in message
    assert ".recover" in message
    assert not output.exists()


@pytest.mark.skipif(
    not HAVE_SQLITE3_CLI,
    reason="sqlite3 CLI not on PATH; .recover is a shell-only feature",
)
def test_lost_and_found_lane_recovers_schema_unreadable_source(
    tmp_path: Path,
) -> None:
    """The last-resort lane must salvage rows SQL-level recovery cannot."""
    source = tmp_path / "schemaless.db"
    output = tmp_path / "schemaless-recovered.db"
    expected = _make_schema_unreadable_source(source)

    # Premise: the schema really is unreadable at the SQL level.
    probe = sqlite3.connect(str(source))
    try:
        with pytest.raises(sqlite3.DatabaseError):
            probe.execute("SELECT COUNT(*) FROM messages").fetchone()
    finally:
        probe.close()

    report = recover_session_database(
        source,
        output,
        work_dir=tmp_path,
        allow_partial=True,
    )

    assert report["mode"] == "lost_and_found_salvage"
    assert report["best_effort"] is True
    assert report["partial"] is True
    assert report["complete"] is False
    assert report["installed"] is False
    assert report["unreadable_schemas"] == ["sessions", "messages"]
    assert any(
        "BEST-EFFORT" in warning
        for warning in report["verification"]["warnings"]
    )

    conn = sqlite3.connect(str(output))
    try:
        assert conn.execute("PRAGMA integrity_check").fetchall() == [("ok",)]
        assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
        session_count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
        message_count = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
        orphans = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE session_id NOT IN "
            "(SELECT id FROM sessions)"
        ).fetchone()[0]
        fts_matches = conn.execute(
            "SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
            ("payload",),
        ).fetchone()[0]
    finally:
        conn.close()

    assert session_count == expected["sessions"]
    assert message_count == expected["messages"]
    assert orphans == 0
    assert fts_matches == expected["messages"]

    # The output must open as a regular current-schema session database.
    recovered_db = SessionDB(db_path=output)
    try:
        sessions = recovered_db.list_sessions_rich(limit=10)
        assert len(sessions) == expected["sessions"]
    finally:
        recovered_db.close()


# ── mapper unit tests (no sqlite3 CLI required) ─────────────────────────────


def _make_synthetic_lost_and_found(
    path: Path,
    dest_schema_db: Path,
) -> dict[str, int]:
    """Build a .recover-shaped lost_and_found DB directly, no CLI needed."""
    schema = sqlite3.connect(str(dest_schema_db))
    try:
        sessions_columns = [
            str(row[1]) for row in schema.execute("PRAGMA table_info(sessions)")
        ]
        messages_columns = [
            str(row[1]) for row in schema.execute("PRAGMA table_info(messages)")
        ]
        usage_columns = [
            str(row[1])
            for row in schema.execute("PRAGMA table_info(session_model_usage)")
        ]
    finally:
        schema.close()
    # Width is derived from the live schema so ordinary column additions
    # don't break this test (it pinned 54, then 55, then 56 in one week).
    # The floor guards against accidentally reading an empty/old schema.
    current_width = len(sessions_columns)
    assert current_width >= 55
    assert len(usage_columns) == 18

    max_fields = current_width
    conn = sqlite3.connect(str(path), isolation_level=None)
    try:
        cells = ", ".join(f"c{i}" for i in range(max_fields))
        conn.execute(
            f"CREATE TABLE lost_and_found (rootpgno INTEGER, pgno INTEGER, "
            f"nfield INTEGER, id INTEGER, {cells})"
        )

        def insert(nfield: int, rowid, values: list) -> None:
            padded = list(values) + [None] * (max_fields - len(values))
            placeholders = ", ".join("?" for _ in range(4 + max_fields))
            conn.execute(
                f"INSERT INTO lost_and_found VALUES ({placeholders})",
                [2, 5, nfield, rowid, *padded],
            )

        def session_row(session_id: str, ncols: int) -> list:
            base = {
                "id": session_id,
                "source": "telegram",
                "started_at": 1_754_000_000.0,
                "message_count": 2,
                "title": f"synthetic {session_id}",
            }
            return [base.get(column) for column in sessions_columns[:ncols]]

        # Current layout (dynamic width) and historical 52-column layout.
        insert(max_fields, 1, session_row("20260101_010101_aaa001", max_fields))
        insert(52, 2, session_row("20260202_020202_bbb002", 52))
        # 14-column legacy layout: identity + a plausible epoch timestamp.
        legacy = ["20250303_030303_ccc003", "cli", 1_741_000_000.0] + [None] * 11
        insert(14, 3, legacy)

        # messages rows: NULL first cell (rowid alias), session id second,
        # role third.
        for index, (session_id, role, content) in enumerate(
            [
                ("20260101_010101_aaa001", "user", "hello from user"),
                ("20260101_010101_aaa001", "assistant", "hello from assistant"),
                ("20261111_111111_ddd004", "user", "orphaned message payload"),
                ("20261111_111111_ddd004", "tool", "orphaned tool payload"),
            ]
        ):
            row = {
                "id": None,
                "session_id": session_id,
                "role": role,
                "content": content,
                "timestamp": 1_754_000_100.0 + index,
            }
            insert(
                23,
                100 + index,
                [row.get(column) for column in messages_columns[:23]],
            )

        # session_model_usage: 18 columns, orphaned session id on purpose.
        usage = {
            "session_id": "20261212_121212_eee005",
            "model": "test/model",
            "billing_provider": "",
            "billing_base_url": "",
            "billing_mode": "",
            "task": "",
            "api_call_count": 4,
            "input_tokens": 100,
            "output_tokens": 50,
            "cache_read_tokens": 0,
            "cache_write_tokens": 0,
            "reasoning_tokens": 0,
            "estimated_cost_usd": 0.01,
            "actual_cost_usd": 0.01,
            "first_seen": 1_754_000_000.0,
            "last_seen": 1_754_000_500.0,
        }
        insert(18, 200, [usage.get(column) for column in usage_columns])

        # Junk that must NOT be classified into canonical tables.
        insert(3, 300, ["random", "noise", 42])
        insert(max_fields, 301, ["not-a-session-id", "cli"] + [None] * (max_fields - 2))
        insert(23, 302, [None, "sess-x", "not-a-role", "junk"])
    finally:
        conn.close()
    return {
        "sessions": 3,
        "messages": 4,
        "session_model_usage": 1,
        "junk": 3,
    }


def test_classify_lost_and_found_row_sentinels() -> None:
    assert (
        classify_lost_and_found_row(
            23, (None, "20260101_010101_aaa001", "user", "hi")
        )
        == "messages"
    )
    assert (
        classify_lost_and_found_row(
            55, ("20260101_010101_aaa001", "cli") + (None,) * 53
        )
        == "sessions"
    )
    assert (
        classify_lost_and_found_row(
            52, ("20260101_010101_aaa001", "discord") + (None,) * 50
        )
        == "sessions"
    )
    assert (
        classify_lost_and_found_row(
            14, ("20250101_010101_zzz999", "cli") + (None,) * 12
        )
        == "sessions"
    )
    assert (
        classify_lost_and_found_row(
            18, ("20260101_010101_aaa001", "gpt-x") + (None,) * 16
        )
        == "session_model_usage"
    )
    # Junk shapes.
    assert classify_lost_and_found_row(3, ("random", "noise", 42)) is None
    assert (
        classify_lost_and_found_row(55, ("not-a-session-id", "cli") + (None,) * 53)
        is None
    )
    assert (
        classify_lost_and_found_row(23, (None, "sess", "not-a-role", "x")) is None
    )
    assert classify_lost_and_found_row(0, ()) is None


def test_mapper_rebuilds_sessiondb_from_synthetic_lost_and_found(
    tmp_path: Path,
) -> None:
    """Binary-independent: mapper + stubbing + FTS rebuild end to end."""
    schema_ref = tmp_path / "schema-ref.db"
    SessionDB(db_path=schema_ref).close()

    lf_path = tmp_path / "lost_and_found.db"
    expected = _make_synthetic_lost_and_found(lf_path, schema_ref)

    output = tmp_path / "mapped.db"
    SessionDB(db_path=output).close()

    lf_conn = sqlite3.connect(str(lf_path), isolation_level=None)
    dest = sqlite3.connect(str(output), isolation_level=None)
    try:
        dest.execute("PRAGMA foreign_keys=OFF")
        mapping = map_lost_and_found_rows(lf_conn, dest)
        stubbing = stub_missing_parent_sessions(dest)
        fts = rebuild_fts_indexes(dest)

        assert mapping["mapped"]["sessions"] == expected["sessions"]
        assert mapping["mapped"]["messages"] == expected["messages"]
        assert (
            mapping["mapped"]["session_model_usage"]
            == expected["session_model_usage"]
        )
        assert mapping["legacy_minimal_sessions"] == 1
        assert mapping["unmapped_rows"] == expected["junk"]

        # Orphaned children got stub parents — never deleted.
        assert stubbing["sessions_stubbed"] == 2  # ddd004 + eee005
        assert stubbing["messages_retained"] == 2
        message_count = dest.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
        assert message_count == expected["messages"]
        usage_count = dest.execute(
            "SELECT COUNT(*) FROM session_model_usage"
        ).fetchone()[0]
        assert usage_count == expected["session_model_usage"]

        stub_titles = [
            str(row[0])
            for row in dest.execute(
                "SELECT title FROM sessions WHERE source = 'recovered'"
            )
        ]
        assert len(stub_titles) == 2
        assert all(title.startswith("[best-effort recovered") for title in stub_titles)

        # The 52-col row landed with its real metadata preserved.
        row = dest.execute(
            "SELECT source, title FROM sessions WHERE id = ?",
            ("20260202_020202_bbb002",),
        ).fetchone()
        assert row == ("telegram", "synthetic 20260202_020202_bbb002")

        assert fts.get("messages_fts") == "rebuilt"
        fts_hits = dest.execute(
            "SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
            ("payload OR hello",),
        ).fetchone()[0]
        assert fts_hits == expected["messages"]

        assert dest.execute("PRAGMA integrity_check").fetchall() == [("ok",)]
        assert dest.execute("PRAGMA foreign_key_check").fetchall() == []
    finally:
        lf_conn.close()
        dest.close()

    # And the mapped output opens through the normal SessionDB path.
    db = SessionDB(db_path=output)
    try:
        assert len(db.list_sessions_rich(limit=20)) == 5
    finally:
        db.close()


# ── issue #72291: source-fingerprint error must name the parent CLI ─────────


def test_fingerprint_error_enumerates_parent_cli_session(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    source = tmp_path / "busy.db"
    SessionDB(db_path=source).close()

    fingerprints = iter([{"main": {"size": 1, "mtime_ns": 1}},
                         {"main": {"size": 2, "mtime_ns": 2}},
                         {"main": {"size": 3, "mtime_ns": 3}}])
    monkeypatch.setattr(
        session_recovery,
        "_source_fingerprint",
        lambda _source: next(fingerprints),
    )
    with pytest.raises(SessionRecoverySafetyError) as excinfo:
        session_recovery.inspect_session_database(source, work_dir=tmp_path)
    message = str(excinfo.value)
    assert "Stop every Hermes process" in message
    # The gap from #72291: the parent CLI session itself must be enumerated.
    assert "CLI session" in message
    assert "fresh shell" in message
    assert "snapshot" in message


# ── issue #101409: mis-mapped salvage must not be reported verified ─────────


def _map_salvage_rows(
    tmp_path: Path,
    *,
    blank_session_started_at: bool,
    blank_message_timestamp: bool,
) -> sqlite3.Connection:
    """Map synthetic lost_and_found cells into a fresh template DB.

    With either ``blank_*`` flag the cells mimic an upgraded source's
    *physical* column order (#101409): whatever lands on the declared
    ``started_at``/``timestamp`` position is not an epoch timestamp, so
    the NOT NULL substitute turns it into 0.0 on every row.
    """

    schema_ref = tmp_path / "schema-ref.db"
    SessionDB(db_path=schema_ref).close()
    schema = sqlite3.connect(str(schema_ref))
    try:
        sessions_columns = [
            str(row[1]) for row in schema.execute("PRAGMA table_info(sessions)")
        ]
        messages_columns = [
            str(row[1]) for row in schema.execute("PRAGMA table_info(messages)")
        ]
    finally:
        schema.close()
    current_width = len(sessions_columns)

    lf_path = tmp_path / "lost_and_found.db"
    lf_conn = sqlite3.connect(str(lf_path), isolation_level=None)
    try:
        lf_cells = ", ".join(f"c{i}" for i in range(current_width))
        lf_conn.execute(
            "CREATE TABLE lost_and_found (rootpgno INTEGER, pgno INTEGER, "
            "nfield INTEGER, id INTEGER, " + lf_cells + ")"
        )

        def insert(nfield: int, rowid: int, values: list) -> None:
            padded = list(values) + [None] * (current_width - len(values))
            placeholders = ", ".join("?" for _ in range(4 + current_width))
            lf_conn.execute(
                "INSERT INTO lost_and_found VALUES (" + placeholders + ")",
                [2, 5, nfield, rowid, *padded],
            )

        def session_row(session_id: str) -> list:
            # title is UNIQUE (idx_sessions_title_unique) — keep it distinct
            # per row so the probe isolates timestamp mis-mapping.
            row = {
                "id": session_id,
                "source": "telegram",
                "started_at": None
                if blank_session_started_at
                else 1_754_000_000.0,
                "message_count": 2,
                "title": f"mis-mapped probe {session_id}",
            }
            return [row.get(column) for column in sessions_columns]

        for index in range(3):
            insert(
                current_width,
                index + 1,
                session_row(f"20260101_01010{index}_aaa00{index}"),
            )

        for index in range(2):
            message = {
                "id": None,
                "session_id": "20260101_010100_aaa000",
                "role": "user",
                "content": "payload",
                "timestamp": None
                if blank_message_timestamp
                else 1_754_000_100.0 + index,
            }
            insert(
                23,
                100 + index,
                [message.get(column) for column in messages_columns[:23]],
            )
    finally:
        lf_conn.close()

    output = tmp_path / "mapped.db"
    SessionDB(db_path=output).close()
    lf_conn = sqlite3.connect(str(lf_path), isolation_level=None)
    dest = sqlite3.connect(str(output), isolation_level=None)
    try:
        dest.execute("PRAGMA foreign_keys=OFF")
        map_lost_and_found_rows(lf_conn, dest)
    finally:
        lf_conn.close()
        dest.close()
    return sqlite3.connect(str(output), isolation_level=None)


def test_plausibility_gate_flags_positional_mis_mapping(
    tmp_path: Path,
) -> None:
    """A salvage whose timestamps all landed below the epoch floor was
    mapped onto the wrong columns and must be flagged, not verified
    (#101409)."""

    conn = _map_salvage_rows(
        tmp_path,
        blank_session_started_at=True,
        blank_message_timestamp=False,
    )
    try:
        # The mapper happily inserted every row; structural checks pass.
        assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 3
        assert conn.execute(
            "SELECT COUNT(*) FROM sessions WHERE started_at = 0.0"
        ).fetchone()[0] == 3

        errors = session_recovery._lost_and_found_plausibility_errors(conn)
        assert len(errors) == 1
        assert "sessions.started_at" in errors[0]
    finally:
        conn.close()


def test_plausibility_gate_flags_mis_mapped_message_timestamps(
    tmp_path: Path,
) -> None:
    conn = _map_salvage_rows(
        tmp_path,
        blank_session_started_at=False,
        blank_message_timestamp=True,
    )
    try:
        errors = session_recovery._lost_and_found_plausibility_errors(conn)
        assert len(errors) == 1
        assert "messages.timestamp" in errors[0]
    finally:
        conn.close()


def test_plausibility_gate_passes_correctly_mapped_salvage(
    tmp_path: Path,
) -> None:
    """Well-mapped rows — and partially damaged ones (a torn cell on some
    rows is expected salvage noise) — must not trip the gate: it fires
    only on a *systematic* violation."""

    conn = _map_salvage_rows(
        tmp_path,
        blank_session_started_at=False,
        blank_message_timestamp=False,
    )
    try:
        # Damage one of three sessions the way a torn cell would.
        conn.execute(
            "UPDATE sessions SET started_at = 0.0 WHERE id = ?",
            ("20260101_010101_aaa001",),
        )
        conn.commit()

        assert session_recovery._lost_and_found_plausibility_errors(conn) == []
    finally:
        conn.close()


def _rebuild_with_started_at_appended(conn: sqlite3.Connection) -> None:
    """Give ``sessions`` the physical layout of an upgraded DB: ``started_at``
    lands at the END (as ``ALTER TABLE ADD COLUMN`` would place a column that
    the current template declares mid-definition). Data is preserved."""
    info = list(conn.execute("PRAGMA table_info(sessions)"))
    declared = [row[1] for row in info]

    def coldef(row):
        _, name, ctype, notnull, dflt, pk = row
        parts = [f'"{name}" {ctype}']
        if pk:
            parts.append("PRIMARY KEY")
        if notnull:
            parts.append("NOT NULL")
        if dflt is not None:
            parts.append(f"DEFAULT {dflt}")
        return " ".join(parts)

    reordered = [r for r in info if r[1] != "started_at"] + [r for r in info if r[1] == "started_at"]
    cols = ", ".join(f'"{c}"' for c in declared)
    conn.executescript("PRAGMA foreign_keys=OFF;")
    conn.execute("CREATE TABLE sessions_new (" + ", ".join(coldef(r) for r in reordered) + ")")
    conn.execute(f"INSERT INTO sessions_new({cols}) SELECT {cols} FROM sessions")
    conn.executescript("DROP TABLE sessions; ALTER TABLE sessions_new RENAME TO sessions;")


@pytest.mark.skipif(
    not HAVE_SQLITE3_CLI,
    reason="sqlite3 CLI not on PATH; .recover is a shell-only feature",
)
def test_lost_and_found_lane_refuses_to_verify_a_physically_shifted_source(
    tmp_path: Path,
) -> None:
    """#101409 end to end: a source whose physical column order differs from
    the template's declared order maps every cell onto the wrong column. The
    output still passes integrity/FK/FTS, so only the plausibility gate can
    stop the report from claiming ``verified``."""
    source = tmp_path / "upgraded.db"
    output = tmp_path / "upgraded-recovered.db"
    db = SessionDB(db_path=source)
    try:
        for n in range(3):
            sid = f"20260812_1400{n:02d}_def{n:03x}"
            db.create_session(sid, "cli", cwd=f"/tmp/shift-{n}")
            db.set_session_title(sid, f"shift {n}")
            for m in range(4):
                db.append_message(sid, "user" if m % 2 == 0 else "assistant", f"payload {n} {m}")
    finally:
        db.close()
    conn = sqlite3.connect(str(source), isolation_level=None)
    try:
        conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        conn.execute("PRAGMA journal_mode=DELETE")
        _rebuild_with_started_at_appended(conn)
        conn.execute("VACUUM")
        physical = [r[1] for r in conn.execute("PRAGMA table_info(sessions)")]
        assert physical[-1] == "started_at"
    finally:
        conn.close()
    # The reporter's damage: page 1 (header + sqlite_master) overwritten, so
    # ``.recover`` cannot name any table and every row lands in
    # lost_and_found, to be mapped positionally onto the template.
    with open(source, "r+b") as fh:
        fh.write(b"\0" * _page_size(source.read_bytes()))

    report = recover_session_database(source, output, work_dir=tmp_path, allow_partial=True)

    assert report["mode"] == "lost_and_found_salvage"
    # Mis-mapped rows that trip a NOT NULL / type constraint are stubbed, not
    # mapped (the reporter saw 190 of 1,875) — at least one lands positionally.
    assert report["lost_and_found"]["mapped"]["sessions"] >= 1
    assert report["verification"]["healthy"] is False
    assert report["verified"] is False
    assert any("sessions.started_at is implausible" in e for e in report["verification"]["errors"])
    out = sqlite3.connect(str(output))
    try:
        # The mis-mapping the gate caught: every mapped (non-stub) session got
        # the NOT NULL substitute where its real start time should be.
        mapped = out.execute(
            f"SELECT started_at FROM sessions WHERE COALESCE(title, '') NOT LIKE '{STUB_TITLE_PREFIX}%'"
        ).fetchall()
        assert mapped and all(row[0] == 0.0 for row in mapped)
    finally:
        out.close()


def test_plausibility_gate_ignores_stub_only_sessions(tmp_path: Path) -> None:
    """Stub rows from ``stub_missing_parent_sessions`` legitimately carry
    ``started_at = 0.0``; a salvage where only stubs survived is depleted,
    not mis-mapped, and must not be flagged."""
    output = tmp_path / "stubs.db"
    SessionDB(db_path=output).close()
    conn = sqlite3.connect(str(output))
    try:
        now = 1_750_000_000.0
        conn.execute(
            "INSERT INTO sessions (id, source, started_at, title) VALUES (?, ?, ?, ?)",
            ("20260812_140000_aaa000", "recovered", 0.0, "[best-effort recovered 1] session metadata was unreadable"),
        )
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
            ("20260812_140000_aaa000", "user", "hi", now),
        )
        conn.commit()
        assert session_recovery._lost_and_found_plausibility_errors(conn) == []
        # One genuinely mapped row with a real timestamp keeps it clean too...
        conn.execute(
            "INSERT INTO sessions (id, source, started_at, title) VALUES (?, ?, ?, ?)",
            ("20260812_140001_aaa001", "cli", now, None),
        )
        conn.commit()
        assert session_recovery._lost_and_found_plausibility_errors(conn) == []
        # ...and a mapped row at 0.0 with a NULL title (the mis-mapped shape:
        # blank titles) is still counted as mapped, not as a stub.
        conn.execute("UPDATE sessions SET started_at = 0.0 WHERE id = '20260812_140001_aaa001'")
        conn.commit()
        errors = session_recovery._lost_and_found_plausibility_errors(conn)
        assert len(errors) == 1 and "sessions.started_at" in errors[0]
    finally:
        conn.close()
