"""Phase E audit gap tests: verify all 8 new audit_log writers fire correctly.

Covered:
  - query.local  (POST /api/query — local DuckDB table)
  - query.remote (POST /api/query — BQ-remote table, smoke only: no live BQ)
  - query.hybrid (POST /api/query/hybrid — admin-only BQ+local join)
  - catalog.list (GET /api/v2/catalog)
  - catalog.schema (GET /api/v2/schema/{table_id})
  - catalog.sample (GET /api/v2/sample/{table_id})
  - snapshot.estimate (POST /api/v2/scan/estimate)
  - snapshot.create  (POST /api/v2/scan)
  - data.access_check (GET /api/data/{table_id}/check-access)

Each success-path test asserts:
  1. Endpoint returns expected HTTP status.
  2. An audit_log row appears with correct action + resource + non-null user_id.

Each audit-failure test monkeypatches AuditRepository.log to raise and asserts
the endpoint still returns the normal success status (audit failure is invisible
to caller).
"""
import importlib
from unittest.mock import MagicMock, patch
import pytest
import pyarrow as pa

from src.db import get_system_db


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _grant_table(conn, user_id: str, table_id: str) -> None:
    """Grant user_id read access to table_id via a dedicated group."""
    from src.repositories.user_groups import UserGroupsRepository
    from src.repositories.user_group_members import UserGroupMembersRepository
    from src.repositories.resource_grants import ResourceGrantsRepository

    grp_name = f"audit-e-{user_id}-{table_id}"[:60]
    grp = UserGroupsRepository(conn).get_by_name(grp_name)
    if not grp:
        grp = UserGroupsRepository(conn).create(
            name=grp_name, description="audit-e-test", created_by="test",
        )
    members = UserGroupMembersRepository(conn)
    if not members.has_membership(user_id, grp["id"]):
        members.add_member(user_id, grp["id"], source="admin", added_by="test")
    grants = ResourceGrantsRepository(conn)
    if not grants.has_grant([grp["id"]], "table", table_id):
        grants.create(
            group_id=grp["id"], resource_type="table", resource_id=table_id,
            assigned_by="test",
        )


def _register_table(client, admin_hdrs, table_id, source_type="keboola", query_mode="local"):
    resp = client.post(
        "/api/admin/register-table",
        json={"name": table_id, "source_type": source_type, "query_mode": query_mode},
        headers=admin_hdrs,
    )
    assert resp.status_code in (200, 201), resp.text
    return resp


def _count_audit_rows(action):
    conn = get_system_db()
    try:
        return conn.execute(
            "SELECT COUNT(*) FROM audit_log WHERE action = ?", [action]
        ).fetchone()[0]
    finally:
        conn.close()


def _last_audit_row(action):
    conn = get_system_db()
    try:
        row = conn.execute(
            "SELECT user_id, action, resource, result, client_kind "
            "FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
            [action],
        ).fetchone()
        return row
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# catalog.list
# ---------------------------------------------------------------------------

class TestCatalogListAudit:
    def test_catalog_list_writes_audit_log(self, seeded_app, analyst_user, mock_extract_factory):
        """GET /api/v2/catalog writes catalog.list audit row."""
        c = seeded_app["client"]
        before = _count_audit_rows("catalog.list")
        resp = c.get("/api/v2/catalog", headers=analyst_user)
        assert resp.status_code == 200
        after = _count_audit_rows("catalog.list")
        assert after == before + 1
        row = _last_audit_row("catalog.list")
        assert row[0] == "analyst1"
        assert row[1] == "catalog.list"
        assert row[2] == "catalog"
        assert row[3] == "success"

    def test_catalog_list_audit_failure_invisible_to_caller(self, seeded_app, analyst_user, monkeypatch):
        """Audit write failure must not 5xx the endpoint."""
        from src.repositories.audit import AuditRepository
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("db gone")))
        c = seeded_app["client"]
        resp = c.get("/api/v2/catalog", headers=analyst_user)
        assert resp.status_code == 200

    def test_catalog_list_error_path_has_audit(self):
        """Source-level verification: catalog.list error path has audit logging."""
        import inspect
        from app.api.v2_catalog import catalog
        src = inspect.getsource(catalog)
        # Verify outer except clause for error path audit
        assert "except Exception as exc:" in src
        assert "error" in src.lower()
        # Verify error audit is logged with error message
        assert 'str(exc)[:200]' in src or "str(exc)" in src


# ---------------------------------------------------------------------------
# catalog.schema
# ---------------------------------------------------------------------------

class TestCatalogSchemaAudit:
    def test_catalog_schema_writes_audit_log(self, seeded_app, admin_user, mock_extract_factory):
        """GET /api/v2/schema/{table_id} writes catalog.schema audit row."""
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "schema_audit_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"x": "1"}]}])
        _register_table(c, admin_hdrs, table_id)

        before = _count_audit_rows("catalog.schema")
        resp = c.get(f"/api/v2/schema/{table_id}", headers=admin_user)
        assert resp.status_code == 200
        after = _count_audit_rows("catalog.schema")
        assert after == before + 1
        row = _last_audit_row("catalog.schema")
        assert row[0] == "admin1"
        assert row[1] == "catalog.schema"
        assert f"table:{table_id}" in row[2]
        assert row[3] == "success"

    def test_catalog_schema_audit_failure_invisible_to_caller(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """Audit write failure must not 5xx the endpoint."""
        from src.repositories.audit import AuditRepository
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "schema_audit_tbl2"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"x": "1"}]}])
        _register_table(c, admin_hdrs, table_id)
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("db gone")))
        resp = c.get(f"/api/v2/schema/{table_id}", headers=admin_user)
        assert resp.status_code == 200

    def test_catalog_schema_error_path_writes_audit_log(self, seeded_app, admin_user):
        """404 on unknown table must write an error audit row."""
        c = seeded_app["client"]
        before = _count_audit_rows("catalog.schema")
        resp = c.get("/api/v2/schema/nonexistent_xyz_table", headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("catalog.schema")
        assert after == before + 1
        row = _last_audit_row("catalog.schema")
        assert row[3].startswith("error.")


# ---------------------------------------------------------------------------
# catalog.sample
# ---------------------------------------------------------------------------

class TestCatalogSampleAudit:
    def test_catalog_sample_writes_audit_log(self, seeded_app, admin_user, mock_extract_factory):
        """GET /api/v2/sample/{table_id} writes catalog.sample audit row."""
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "sample_audit_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"a": "1"}, {"a": "2"}]}])
        _register_table(c, admin_hdrs, table_id)

        before = _count_audit_rows("catalog.sample")
        resp = c.get(f"/api/v2/sample/{table_id}", headers=admin_user)
        assert resp.status_code == 200
        after = _count_audit_rows("catalog.sample")
        assert after == before + 1
        row = _last_audit_row("catalog.sample")
        assert row[0] == "admin1"
        assert row[1] == "catalog.sample"
        assert f"table:{table_id}" in row[2]
        assert row[3] == "success"

    def test_catalog_sample_audit_failure_invisible_to_caller(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """Audit write failure must not 5xx the endpoint."""
        from src.repositories.audit import AuditRepository
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "sample_audit_tbl2"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"a": "1"}]}])
        _register_table(c, admin_hdrs, table_id)
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))
        resp = c.get(f"/api/v2/sample/{table_id}", headers=admin_user)
        assert resp.status_code == 200

    def test_catalog_sample_error_path_writes_audit_log(self, seeded_app, admin_user):
        """404 on unknown table must write an error audit row."""
        c = seeded_app["client"]
        before = _count_audit_rows("catalog.sample")
        resp = c.get("/api/v2/sample/nonexistent_xyz_table", headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("catalog.sample")
        assert after == before + 1
        row = _last_audit_row("catalog.sample")
        assert row[3].startswith("error.")


# ---------------------------------------------------------------------------
# data.access_check
# ---------------------------------------------------------------------------

class TestDataAccessCheckAudit:
    def _setup_table(self, seeded_app, mock_extract_factory, table_id):
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        _register_table(c, admin_hdrs, table_id)
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"v": "1"}]}])
        conn = get_system_db()
        try:
            _grant_table(conn, "analyst1", table_id)
        finally:
            conn.close()

    def test_access_check_granted_writes_audit_log(self, seeded_app, analyst_user, mock_extract_factory):
        """204 check-access must write data.access_check audit row with granted=True."""
        table_id = "check_access_granted_tbl"
        self._setup_table(seeded_app, mock_extract_factory, table_id)
        c = seeded_app["client"]
        before = _count_audit_rows("data.access_check")
        resp = c.get(f"/api/data/{table_id}/check-access", headers=analyst_user)
        assert resp.status_code == 204
        after = _count_audit_rows("data.access_check")
        assert after == before + 1
        row = _last_audit_row("data.access_check")
        assert row[0] == "analyst1"
        assert row[1] == "data.access_check"
        assert f"table:{table_id}" in row[2]
        assert row[3] == "success"

    def test_access_check_denied_writes_audit_log(self, seeded_app, analyst_user, mock_extract_factory):
        """403 check-access must write data.access_check audit row with granted=False."""
        # Register a table but do NOT grant analyst access
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "check_access_denied_tbl"
        _register_table(c, admin_hdrs, table_id)

        before = _count_audit_rows("data.access_check")
        resp = c.get(f"/api/data/{table_id}/check-access", headers=analyst_user)
        assert resp.status_code == 403
        after = _count_audit_rows("data.access_check")
        assert after == before + 1
        row = _last_audit_row("data.access_check")
        assert row[3] == "error.403"

    def test_access_check_audit_failure_invisible_to_caller(
        self, seeded_app, analyst_user, mock_extract_factory, monkeypatch
    ):
        """Audit write failure must not 5xx the endpoint."""
        from src.repositories.audit import AuditRepository
        table_id = "check_access_audit_fail_tbl"
        self._setup_table(seeded_app, mock_extract_factory, table_id)
        c = seeded_app["client"]
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))
        resp = c.get(f"/api/data/{table_id}/check-access", headers=analyst_user)
        assert resp.status_code == 204


# ---------------------------------------------------------------------------
# query.local
# ---------------------------------------------------------------------------

class TestQueryLocalAudit:
    def _setup_local_table(self, seeded_app, mock_extract_factory, table_id="qlocal_tbl"):
        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        _register_table(c, admin_hdrs, table_id)
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"n": "1"}, {"n": "2"}]}])
        # Rebuild analytics DB so master view is visible
        resp = c.post("/api/sync/trigger", headers=admin_hdrs)
        # sync may fail if orchestrator is not available, ignore
        return table_id

    def test_query_local_writes_audit_log(self, seeded_app, admin_user, mock_extract_factory, monkeypatch):
        """POST /api/query on a local table must write query.local audit row."""
        # Monkeypatch get_analytics_db_readonly to return a fresh in-memory DuckDB
        # with the table so we don't need a full orchestrator rebuild.
        import duckdb as _duckdb
        from app.api import query as query_mod

        table_id = "qlocal_audit_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"n": "1"}]}])

        mem_conn = _duckdb.connect(":memory:")
        mem_conn.execute(f"CREATE TABLE {table_id} (n VARCHAR)")
        mem_conn.execute(f"INSERT INTO {table_id} VALUES ('1')")

        monkeypatch.setattr(query_mod, "get_analytics_db_readonly", lambda: mem_conn)
        # No BQ path — ensure _bq_guardrail_inputs returns empty sets
        monkeypatch.setattr(query_mod, "_bq_guardrail_inputs",
                            lambda *a, **kw: ([], [], None))

        c = seeded_app["client"]
        before = _count_audit_rows("query.local")
        resp = c.post(
            "/api/query",
            json={"sql": f"SELECT * FROM {table_id}", "limit": 10},
            headers=admin_user,
        )
        assert resp.status_code == 200, resp.text
        after = _count_audit_rows("query.local")
        assert after == before + 1
        row = _last_audit_row("query.local")
        assert row[0] == "admin1"
        assert row[1] == "query.local"
        assert row[3] == "success"
        mem_conn.close()

    def test_query_local_audit_failure_invisible(self, seeded_app, admin_user, mock_extract_factory, monkeypatch):
        """Audit write failure must not 5xx POST /api/query."""
        import duckdb as _duckdb
        from app.api import query as query_mod
        from src.repositories.audit import AuditRepository

        table_id = "qlocal_audit_fail_tbl"
        mem_conn = _duckdb.connect(":memory:")
        mem_conn.execute(f"CREATE TABLE {table_id} (n VARCHAR)")
        monkeypatch.setattr(query_mod, "get_analytics_db_readonly", lambda: mem_conn)
        monkeypatch.setattr(query_mod, "_bq_guardrail_inputs",
                            lambda *a, **kw: ([], [], None))
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))

        c = seeded_app["client"]
        resp = c.post(
            "/api/query",
            json={"sql": f"SELECT * FROM {table_id}", "limit": 5},
            headers=admin_user,
        )
        assert resp.status_code == 200
        mem_conn.close()


# ---------------------------------------------------------------------------
# query.hybrid
# ---------------------------------------------------------------------------

class TestQueryHybridAudit:
    def test_query_hybrid_writes_audit_log(self, seeded_app, admin_user, monkeypatch):
        """POST /api/query/hybrid must write query.hybrid audit row on success."""
        from src.remote_query import RemoteQueryEngine
        mock_engine = MagicMock()
        mock_engine.execute.return_value = {"columns": ["a"], "rows": [["1"]]}

        monkeypatch.setattr(
            "app.api.query_hybrid.RemoteQueryEngine",
            lambda *a, **kw: mock_engine,
        )
        monkeypatch.setattr(
            "app.api.query_hybrid.load_config",
            lambda: {},
        )
        import duckdb as _duckdb
        mem_conn = _duckdb.connect(":memory:")
        monkeypatch.setattr(
            "app.api.query_hybrid.get_analytics_db_readonly",
            lambda: mem_conn,
        )

        c = seeded_app["client"]
        before = _count_audit_rows("query.hybrid")
        resp = c.post(
            "/api/query/hybrid",
            json={"sql": "SELECT a FROM some_table", "register_bq": {}},
            headers=admin_user,
        )
        assert resp.status_code == 200, resp.text
        after = _count_audit_rows("query.hybrid")
        assert after == before + 1
        row = _last_audit_row("query.hybrid")
        assert row[0] == "admin1"
        assert row[1] == "query.hybrid"
        assert row[3] == "success"
        mem_conn.close()

    def test_query_hybrid_audit_failure_invisible(self, seeded_app, admin_user, monkeypatch):
        """Audit write failure must not 5xx POST /api/query/hybrid."""
        from src.repositories.audit import AuditRepository
        mock_engine = MagicMock()
        mock_engine.execute.return_value = {"columns": [], "rows": []}
        monkeypatch.setattr("app.api.query_hybrid.RemoteQueryEngine", lambda *a, **kw: mock_engine)
        monkeypatch.setattr("app.api.query_hybrid.load_config", lambda: {})
        import duckdb as _duckdb
        mem_conn = _duckdb.connect(":memory:")
        monkeypatch.setattr("app.api.query_hybrid.get_analytics_db_readonly", lambda: mem_conn)
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))

        c = seeded_app["client"]
        resp = c.post(
            "/api/query/hybrid",
            json={"sql": "SELECT 1", "register_bq": {}},
            headers=admin_user,
        )
        assert resp.status_code == 200
        mem_conn.close()


# ---------------------------------------------------------------------------
# snapshot.estimate (POST /api/v2/scan/estimate)
# ---------------------------------------------------------------------------

class TestSnapshotEstimateAudit:
    def test_snapshot_estimate_local_writes_audit_log(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """POST /api/v2/scan/estimate on a local table must write snapshot.estimate audit row."""
        import importlib
        from app.api import v2_scan, v2_schema

        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "est_audit_local_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"col": "x"}]}])
        _register_table(c, admin_hdrs, table_id)

        # Stub build_schema so we don't need a real analytics DB
        monkeypatch.setattr(v2_scan, "_resolve_schema",
                            lambda *a, **kw: {"col": "STRING"})

        before = _count_audit_rows("snapshot.estimate")
        resp = c.post(
            "/api/v2/scan/estimate",
            json={"table_id": table_id},
            headers=admin_user,
        )
        assert resp.status_code == 200, resp.text
        after = _count_audit_rows("snapshot.estimate")
        assert after == before + 1
        row = _last_audit_row("snapshot.estimate")
        assert row[0] == "admin1"
        assert row[1] == "snapshot.estimate"
        assert f"table:{table_id}" in row[2]
        assert row[3] == "success"

    def test_snapshot_estimate_audit_failure_invisible(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """Audit write failure must not 5xx POST /api/v2/scan/estimate."""
        from src.repositories.audit import AuditRepository
        from app.api import v2_scan

        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "est_audit_fail_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"col": "x"}]}])
        _register_table(c, admin_hdrs, table_id)

        monkeypatch.setattr(v2_scan, "_resolve_schema",
                            lambda *a, **kw: {"col": "STRING"})
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))

        resp = c.post(
            "/api/v2/scan/estimate",
            json={"table_id": table_id},
            headers=admin_user,
        )
        assert resp.status_code == 200

    def test_snapshot_estimate_error_path_writes_audit_log(self, seeded_app, admin_user):
        """404 on unknown table must write snapshot.estimate error audit row."""
        c = seeded_app["client"]
        before = _count_audit_rows("snapshot.estimate")
        resp = c.post(
            "/api/v2/scan/estimate",
            json={"table_id": "nonexistent_xyz_estimate"},
            headers=admin_user,
        )
        assert resp.status_code == 404
        after = _count_audit_rows("snapshot.estimate")
        assert after == before + 1
        row = _last_audit_row("snapshot.estimate")
        assert row[3].startswith("error.")


# ---------------------------------------------------------------------------
# snapshot.create (POST /api/v2/scan)
# ---------------------------------------------------------------------------

class TestSnapshotCreateAudit:
    def test_snapshot_create_local_writes_audit_log(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """POST /api/v2/scan on a local table must write snapshot.create audit row."""
        from app.api import v2_scan

        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "scan_audit_local_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"n": "1"}, {"n": "2"}]}])
        _register_table(c, admin_hdrs, table_id)

        monkeypatch.setattr(v2_scan, "_resolve_schema",
                            lambda *a, **kw: {"n": "STRING"})

        before = _count_audit_rows("snapshot.create")
        resp = c.post(
            "/api/v2/scan",
            json={"table_id": table_id},
            headers=admin_user,
        )
        assert resp.status_code == 200, resp.text
        after = _count_audit_rows("snapshot.create")
        assert after == before + 1
        row = _last_audit_row("snapshot.create")
        assert row[0] == "admin1"
        assert row[1] == "snapshot.create"
        assert f"table:{table_id}" in row[2]
        assert row[3] == "success"

    def test_snapshot_create_audit_failure_invisible(
        self, seeded_app, admin_user, mock_extract_factory, monkeypatch
    ):
        """Audit write failure must not 5xx POST /api/v2/scan."""
        from src.repositories.audit import AuditRepository
        from app.api import v2_scan

        c = seeded_app["client"]
        admin_hdrs = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
        table_id = "scan_audit_fail_tbl"
        mock_extract_factory("keboola", [{"name": table_id, "data": [{"n": "1"}]}])
        _register_table(c, admin_hdrs, table_id)

        monkeypatch.setattr(v2_scan, "_resolve_schema",
                            lambda *a, **kw: {"n": "STRING"})
        monkeypatch.setattr(AuditRepository, "log", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("gone")))

        resp = c.post(
            "/api/v2/scan",
            json={"table_id": table_id},
            headers=admin_user,
        )
        assert resp.status_code == 200

    def test_snapshot_create_error_path_writes_audit_log(self, seeded_app, admin_user):
        """404 on unknown table must write snapshot.create error audit row."""
        c = seeded_app["client"]
        before = _count_audit_rows("snapshot.create")
        resp = c.post(
            "/api/v2/scan",
            json={"table_id": "nonexistent_xyz_scan"},
            headers=admin_user,
        )
        assert resp.status_code == 404
        after = _count_audit_rows("snapshot.create")
        assert after == before + 1
        row = _last_audit_row("snapshot.create")
        assert row[3].startswith("error.")


# ---------------------------------------------------------------------------
# Error message capping (Fix 1)
# ---------------------------------------------------------------------------

def _get_last_audit_params(action: str) -> dict:
    """Fetch params dict from the latest audit row matching action."""
    import json
    conn = get_system_db()
    try:
        row = conn.execute(
            "SELECT params FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
            [action],
        ).fetchone()
        if row:
            params_str = row[0]
            if isinstance(params_str, str):
                return json.loads(params_str) or {}
            return params_str or {}
        return {}
    finally:
        conn.close()


class TestErrorMessageCapping:
    """Verify that audit error paths cap error messages at 200 chars.

    Rather than monkeypatching to force errors, we trigger 404 errors
    (table not found) which emit audit rows with error messages.
    The error message from FileNotFoundError (the exception) is capped
    by the fix in each endpoint's error handler.
    """

    def test_v2_schema_error_message_is_capped(self, seeded_app, admin_user):
        """v2/schema error path caps error message at 200 chars."""
        c = seeded_app["client"]
        before = _count_audit_rows("catalog.schema")
        # Request a nonexistent table to trigger FileNotFoundError
        resp = c.get("/api/v2/schema/nonexistent_xyz_123456789", headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("catalog.schema")
        assert after == before + 1

        params = _get_last_audit_params("catalog.schema")
        error_msg = params.get("error", "")
        assert len(error_msg) <= 200, f"error message length {len(error_msg)} exceeds 200"

    def test_v2_sample_error_message_is_capped(self, seeded_app, admin_user):
        """v2/sample error path caps error message at 200 chars."""
        c = seeded_app["client"]
        before = _count_audit_rows("catalog.sample")
        resp = c.get("/api/v2/sample/nonexistent_xyz_sample123", headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("catalog.sample")
        assert after == before + 1

        params = _get_last_audit_params("catalog.sample")
        error_msg = params.get("error", "")
        assert len(error_msg) <= 200, f"error message length {len(error_msg)} exceeds 200"

    def test_v2_scan_estimate_error_message_is_capped(self, seeded_app, admin_user):
        """v2/scan/estimate error path caps error message at 200 chars."""
        c = seeded_app["client"]
        before = _count_audit_rows("snapshot.estimate")
        resp = c.post("/api/v2/scan/estimate", json={"table_id": "nonexistent_xyz_estimate"}, headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("snapshot.estimate")
        assert after == before + 1

        params = _get_last_audit_params("snapshot.estimate")
        error_msg = params.get("error", "")
        assert len(error_msg) <= 200, f"error message length {len(error_msg)} exceeds 200"

    def test_v2_scan_error_message_is_capped(self, seeded_app, admin_user):
        """v2/scan error path caps error message at 200 chars."""
        c = seeded_app["client"]
        before = _count_audit_rows("snapshot.create")
        resp = c.post("/api/v2/scan", json={"table_id": "nonexistent_xyz_scan123"}, headers=admin_user)
        assert resp.status_code == 404
        after = _count_audit_rows("snapshot.create")
        assert after == before + 1

        params = _get_last_audit_params("snapshot.create")
        error_msg = params.get("error", "")
        assert len(error_msg) <= 200, f"error message length {len(error_msg)} exceeds 200"


# ---------------------------------------------------------------------------
# client_kind detection: PAT auth → 'cli', session JWT → 'web'
# ---------------------------------------------------------------------------

class TestClientKindDetection:
    """PAT-authenticated requests must produce client_kind='cli' in audit_log;
    interactive session JWTs must produce 'web'."""

    def _mint_pat_for_admin(self, seeded_app) -> str:
        """Return a PAT bearer token for admin1 by creating a DB record + JWT directly."""
        import hashlib
        import uuid
        from app.auth.jwt import create_access_token
        from src.db import get_system_db

        jti = uuid.uuid4().hex
        raw_jwt = create_access_token(
            user_id="admin1", email="admin@test.com", typ="pat", token_id=jti, omit_exp=True
        )
        token_hash = hashlib.sha256(raw_jwt.encode()).hexdigest()
        conn = get_system_db()
        conn.execute(
            """INSERT INTO personal_access_tokens
               (id, user_id, name, prefix, token_hash, created_at, revoked_at, expires_at, last_used_at, last_used_ip)
               VALUES (?, 'admin1', 'test-pat', 'tst_', ?, now(), NULL, NULL, NULL, NULL)""",
            [jti, token_hash],
        )
        conn.close()
        return raw_jwt

    def test_pat_auth_sets_client_kind_cli(self, seeded_app):
        """Sync trigger via PAT → client_kind='cli' in audit_log."""
        pat_token = self._mint_pat_for_admin(seeded_app)
        pat_headers = {"Authorization": f"Bearer {pat_token}"}

        conn = get_system_db()
        conn.execute("DELETE FROM audit_log WHERE action='sync.trigger'")
        conn.close()

        resp = seeded_app["client"].post("/api/sync/trigger", headers=pat_headers)
        assert resp.status_code in (200, 202, 409)

        conn = get_system_db()
        row = conn.execute(
            "SELECT client_kind FROM audit_log WHERE action='sync.trigger' ORDER BY timestamp DESC LIMIT 1"
        ).fetchone()
        conn.close()
        assert row is not None, "audit_log row was not written"
        assert row[0] == "cli", f"expected client_kind='cli', got {row[0]!r}"

    def test_session_jwt_sets_client_kind_web(self, seeded_app, admin_user):
        """Sync trigger via session JWT → client_kind='web' in audit_log."""
        conn = get_system_db()
        conn.execute("DELETE FROM audit_log WHERE action='sync.trigger'")
        conn.close()

        resp = seeded_app["client"].post("/api/sync/trigger", headers=admin_user)
        assert resp.status_code in (200, 202, 409)

        conn = get_system_db()
        row = conn.execute(
            "SELECT client_kind FROM audit_log WHERE action='sync.trigger' ORDER BY timestamp DESC LIMIT 1"
        ).fetchone()
        conn.close()
        assert row is not None, "audit_log row was not written"
        assert row[0] == "web", f"expected client_kind='web', got {row[0]!r}"
