"""Tests for canonical KB claim routes."""

from __future__ import annotations

import asyncio
import json
import sys
import threading
import time
from pathlib import Path

import pytest

pytest.importorskip("aiohttp")

from aiohttp import web
from aiohttp.test_utils import make_mocked_request

REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "diagnostics"))

import app as argus_app  # noqa: E402
import kb_claim_routes as routes  # noqa: E402

CLAIM_ID = "d0000000-0000-0000-0000-000000000005"
CONNECTED_ID = "e0000000-0000-0000-0000-000000000006"
SECOND_CLAIM_ID = "c0000000-0000-0000-0000-000000000004"
THIRD_CLAIM_ID = "b0000000-0000-0000-0000-000000000003"
API_KEY = "test-argus-api-key"


def _claim_list_app() -> web.Application:
    app = web.Application()
    app[routes.KB_CLAIM_LIST_API_KEY] = API_KEY
    return app


def _claim_list_request(path: str, *, app: web.Application):
    return make_mocked_request("GET", path, app=app, headers={"X-Api-Key": API_KEY})


def _claim_data():
    return {
        "claim": {
            "id": CLAIM_ID,
            "type": "strategy_kernel",
            "text": "Claims should be falsifiable enough to support review.",
            "status": "open",
            "confidence": "0.72",
            "tags": ["claims", "review"],
            "created_at": "2026-07-09T00:00:00Z",
            "updated_at": "2026-07-09T00:00:00Z",
        },
        "evidence": [
            {
                "role": "grounds",
                "weight": "0.8",
                "source_type": "telegram",
                "url": "https://example.test/source",
                "storage_path": None,
                "excerpt": "m3taversal asked Leo to split a broad claim into atomic claims.",
            }
        ],
        "edges": [
            {
                "direction": "outgoing",
                "edge_type": "supports",
                "connected_id": CONNECTED_ID,
                "connected_text": "Reviewable claims need supporting evidence and edges.",
                "connected_status": "open",
            }
        ],
    }


def _claim_summary(claim_id: str, *, timestamp: str, text: str = "Canonical claim"):
    return {
        "id": claim_id,
        "type": "strategy_kernel",
        "text": text,
        "text_truncated": False,
        "status": "open",
        "confidence": "0.72",
        "tags": ["claims", "review"],
        "created_at": timestamp,
        "updated_at": timestamp,
        "evidence_count": 3,
        "edge_count": 5,
        "_cursor_timestamp": timestamp,
        "internal_secret": "must-not-leak",
    }


def test_claim_link_html_links_valid_claim_ids() -> None:
    html = routes.claim_link_html(CLAIM_ID)

    assert f'href="/kb/claims/{CLAIM_ID}"' in html
    assert f"<code>{CLAIM_ID}</code>" in html


def test_render_kb_claim_page_shows_body_evidence_and_linked_edges() -> None:
    html = routes.render_kb_claim_page(_claim_data())

    assert "Claims should be falsifiable enough to support review." in html
    assert "m3taversal asked Leo" in html
    assert f'href="/kb/claims/{CONNECTED_ID}"' in html
    assert "<code>supports</code>" in html
    assert "<code>open</code>" in html


def test_api_kb_claim_uses_loader_and_returns_claim_graph() -> None:
    app = web.Application()
    app[routes.KB_CLAIM_LOADER_KEY] = lambda claim_id: _claim_data() if claim_id == CLAIM_ID else None
    req = make_mocked_request(
        "GET",
        f"/api/kb/claims/{CLAIM_ID}",
        app=app,
        match_info={"claim_id": CLAIM_ID},
    )

    response = asyncio.run(routes.handle_api_kb_claim(req))
    data = json.loads(response.body.decode())

    assert response.status == 200
    assert data["claim"]["id"] == CLAIM_ID
    assert data["edges"][0]["connected_id"] == CONNECTED_ID
    assert response.headers["Cache-Control"] == "private, no-store, max-age=0"


def test_claim_cursor_round_trip() -> None:
    timestamp = "2026-07-13T21:12:00+00:00"
    cursor = routes.encode_claim_cursor(timestamp, CLAIM_ID)

    assert routes.decode_claim_cursor(cursor) == (timestamp, CLAIM_ID)


def test_canonical_claim_list_database_role_fails_closed(monkeypatch, tmp_path) -> None:
    monkeypatch.delenv("KB_CLAIM_BROWSER_ROLE", raising=False)
    monkeypatch.delenv("KB_CLAIM_BROWSER_SECRETS_FILE", raising=False)
    default_secrets_file = tmp_path / "missing-kb-observatory-read-password"
    monkeypatch.setattr(routes, "CLAIM_LIST_DEFAULT_SECRETS_FILE", str(default_secrets_file))

    with pytest.raises(RuntimeError, match="read role is not configured"):
        routes._claim_list_db_args()

    default_secrets_file.write_text("PGPASSWORD=not-printed\n", encoding="utf-8")
    default_secrets_file.chmod(0o600)
    default_args = routes._claim_list_db_args()

    assert default_args.role == routes.CLAIM_LIST_REQUIRED_ROLE
    assert default_args.secrets_file == str(default_secrets_file)

    monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", "kb_apply")
    monkeypatch.setenv("KB_CLAIM_BROWSER_SECRETS_FILE", "/run/secrets/read-password")
    with pytest.raises(RuntimeError, match=routes.CLAIM_LIST_REQUIRED_ROLE):
        routes._claim_list_db_args()

    monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", routes.CLAIM_LIST_REQUIRED_ROLE)
    args = routes._claim_list_db_args()

    assert args.role == routes.CLAIM_LIST_REQUIRED_ROLE
    assert args.secrets_file == "/run/secrets/read-password"


def test_canonical_claim_list_database_session_must_be_read_only(monkeypatch, tmp_path) -> None:
    secrets_file = tmp_path / "kb-observatory-read.env"
    secrets_file.write_text("PGPASSWORD=not-printed\n", encoding="utf-8")
    secrets_file.chmod(0o640)
    monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", routes.CLAIM_LIST_REQUIRED_ROLE)
    monkeypatch.setenv("KB_CLAIM_BROWSER_SECRETS_FILE", str(secrets_file))
    observed_sql = {}

    def run_psql(_args, sql, _password):
        observed_sql["value"] = sql
        return json.dumps({"session_read_only": "off", "total": 0, "rows": []})

    monkeypatch.setattr(routes, "_run_claim_list_psql", run_psql)
    filters = {
        "q": "",
        "status": "all",
        "type": "",
        "tag": "",
        "limit": 25,
        "cursor": "",
        "cursor_values": None,
    }

    with pytest.raises(RuntimeError, match="session is not read-only"):
        routes._load_claim_list_from_db(filters)

    assert "current_setting('transaction_read_only')" in observed_sql["value"]
    assert "set statement_timeout = '5000ms'" in observed_sql["value"]
    assert observed_sql["value"].index("page_rows as") < observed_sql["value"].index(
        "from public.claim_evidence"
    )


def test_canonical_claim_list_secrets_file_accepts_only_pgpassword(tmp_path) -> None:
    secrets_file = tmp_path / "kb-observatory-read.env"
    secrets_file.write_text("# dedicated read role\nPGPASSWORD='read-only-secret'\n", encoding="utf-8")
    secrets_file.chmod(0o640)

    assert routes._load_claim_list_password(str(secrets_file)) == "read-only-secret"

    secrets_file.write_text("KB_APPLY_PASSWORD=wrong-boundary\n", encoding="utf-8")
    with pytest.raises(RuntimeError, match="invalid format"):
        routes._load_claim_list_password(str(secrets_file))

    secrets_file.write_text("PGPASSWORD=secret\n", encoding="utf-8")
    secrets_file.chmod(0o644)
    with pytest.raises(RuntimeError, match="world-accessible"):
        routes._load_claim_list_password(str(secrets_file))


def test_canonical_claim_list_process_timeout_is_enforced_and_sanitized(monkeypatch) -> None:
    args = routes.argparse.Namespace(
        container="postgres",
        db="teleo",
        host="127.0.0.1",
        role=routes.CLAIM_LIST_REQUIRED_ROLE,
    )
    observed = {}

    def timeout_run(command, **kwargs):
        observed["command"] = command
        observed.update(kwargs)
        raise routes.subprocess.TimeoutExpired(command, kwargs["timeout"], stderr="secret")

    monkeypatch.setattr(routes.subprocess, "run", timeout_run)

    with pytest.raises(TimeoutError, match="database query timed out") as exc_info:
        routes._run_claim_list_psql(args, "select 1", "must-not-leak")

    assert "secret" not in str(exc_info.value)
    assert observed["timeout"] == routes.CLAIM_LIST_PROCESS_TIMEOUT_SECONDS
    assert f"PGCONNECT_TIMEOUT={routes.CLAIM_LIST_CONNECT_TIMEOUT_SECONDS}" in observed[
        "command"
    ]


def test_canonical_claim_list_api_key_loads_only_from_dedicated_file(
    monkeypatch, tmp_path
) -> None:
    api_key_file = tmp_path / "kb-observatory-api-key"
    api_key_file.write_text("route-scoped-observatory-key\n", encoding="utf-8")
    api_key_file.chmod(0o640)
    monkeypatch.setenv("KB_CLAIM_BROWSER_API_KEY_FILE", str(api_key_file))

    assert routes.load_claim_list_api_key() == "route-scoped-observatory-key"

    api_key_file.write_text("two tokens\n", encoding="utf-8")
    assert routes.load_claim_list_api_key() is None

    api_key_file.write_text("é" * 24, encoding="utf-8")
    assert routes.load_claim_list_api_key() is None

    api_key_file.write_text("route-scoped-observatory-key\n", encoding="utf-8")
    api_key_file.chmod(0o644)
    assert routes.load_claim_list_api_key() is None


def test_canonical_claim_list_is_versioned_paginated_and_sanitized() -> None:
    app = _claim_list_app()
    captured_filters = {}

    def loader(filters):
        captured_filters.update(filters)
        return {
            "total": 1837,
            "rows": [
                _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00"),
                _claim_summary(SECOND_CLAIM_ID, timestamp="2026-07-13T20:12:00+00:00"),
            ],
        }

    app[routes.KB_CLAIM_LIST_LOADER_KEY] = loader
    req = _claim_list_request(
        "/api/kb/claims?limit=1&q=falsifiable&type=strategy_kernel&tag=review",
        app=app,
    )

    response = asyncio.run(routes.handle_api_kb_claims(req))
    data = json.loads(response.body.decode())

    assert response.status == 200
    assert response.headers["Cache-Control"] == "private, no-store, max-age=0"
    assert response.headers["Vary"] == "X-Api-Key"
    assert data["schema"] == routes.CLAIM_LIST_SCHEMA
    assert data["read_only"] is True
    assert data["source"]["store"] == "canonical_postgres"
    assert data["page"] == {
        "limit": 1,
        "returned": 1,
        "total": 1837,
        "has_more": True,
        "next_cursor": data["page"]["next_cursor"],
    }
    assert routes.decode_claim_cursor(data["page"]["next_cursor"])[1] == CLAIM_ID
    assert data["claims"][0]["id"] == CLAIM_ID
    assert "internal_secret" not in data["claims"][0]
    assert captured_filters["q"] == "falsifiable"
    assert captured_filters["type"] == "strategy_kernel"
    assert captured_filters["tag"] == "review"


def test_canonical_claim_list_caps_response_bytes_without_skipping_rows(monkeypatch) -> None:
    app = _claim_list_app()
    rows = [
        _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00", text="🔥" * 1200),
        _claim_summary(
            SECOND_CLAIM_ID,
            timestamp="2026-07-13T20:12:00+00:00",
            text="🔥" * 1200,
        ),
        _claim_summary(
            THIRD_CLAIM_ID,
            timestamp="2026-07-13T19:12:00+00:00",
            text="🔥" * 1200,
        ),
    ]
    for row in rows:
        row["tags"] = ["🔥" * 128] * 32
    app[routes.KB_CLAIM_LIST_LOADER_KEY] = lambda _filters: {"total": 3, "rows": rows}
    assert routes.CLAIM_LIST_MAX_RESPONSE_BYTES < 512_000
    byte_limit = 100_000
    monkeypatch.setattr(routes, "CLAIM_LIST_MAX_RESPONSE_BYTES", byte_limit)
    request = _claim_list_request("/api/kb/claims?limit=3&status=all", app=app)

    response = asyncio.run(routes.handle_api_kb_claims(request))
    data = json.loads(response.body.decode())

    assert response.status == 200
    assert len(response.body) <= byte_limit
    assert data["page"]["limit"] == 3
    assert data["page"]["returned"] == 1
    assert data["page"]["has_more"] is True
    assert routes.decode_claim_cursor(data["page"]["next_cursor"])[1] == CLAIM_ID
    assert [claim["id"] for claim in data["claims"]] == [CLAIM_ID]


def test_canonical_claim_list_clamps_limit_and_rejects_bad_cursor() -> None:
    app = _claim_list_app()
    observed = {}
    app[routes.KB_CLAIM_LIST_LOADER_KEY] = lambda filters: observed.update(filters) or {"total": 0, "rows": []}
    large_req = _claim_list_request("/api/kb/claims?limit=999", app=app)

    large_response = asyncio.run(routes.handle_api_kb_claims(large_req))
    large_data = json.loads(large_response.body.decode())

    assert large_response.status == 200
    assert large_data["page"]["limit"] == routes.CLAIM_LIST_MAX_LIMIT
    assert observed["limit"] == routes.CLAIM_LIST_MAX_LIMIT

    bad_req = _claim_list_request("/api/kb/claims?cursor=not-a-cursor", app=app)
    bad_response = asyncio.run(routes.handle_api_kb_claims(bad_req))
    bad_data = json.loads(bad_response.body.decode())

    assert bad_response.status == 400
    assert bad_data == {"error": "invalid cursor"}

    long_req = _claim_list_request(f"/api/kb/claims?cursor={'x' * 513}", app=app)
    long_response = asyncio.run(routes.handle_api_kb_claims(long_req))

    assert long_response.status == 400


def test_canonical_claim_list_failure_is_sanitized() -> None:
    app = _claim_list_app()

    def fail(_filters):
        raise SystemExit("database password would have appeared here")

    app[routes.KB_CLAIM_LIST_LOADER_KEY] = fail
    req = _claim_list_request("/api/kb/claims", app=app)

    response = asyncio.run(routes.handle_api_kb_claims(req))
    data = json.loads(response.body.decode())

    assert response.status == 503
    assert data == {"error": "canonical_claims_unavailable"}
    assert b"password" not in response.body


def test_canonical_claim_auth_boundaries_are_exact() -> None:
    list_path = "/api/kb/claims"

    assert list_path in routes.KB_CLAIM_SELF_AUTH_PATHS
    assert f"{list_path}/{CLAIM_ID}" not in routes.KB_CLAIM_SELF_AUTH_PATHS
    assert f"/api/kb/claims/{CLAIM_ID}".startswith(routes.KB_CLAIM_GLOBAL_AUTH_PREFIXES)
    assert f"/kb/claims/{CLAIM_ID}".startswith(routes.KB_CLAIM_GLOBAL_AUTH_PREFIXES)


def test_canonical_claim_list_authentication_fails_closed() -> None:
    app_without_key = web.Application()
    unconfigured_req = make_mocked_request("GET", "/api/kb/claims", app=app_without_key)
    unconfigured_response = asyncio.run(routes.handle_api_kb_claims(unconfigured_req))

    assert unconfigured_response.status == 503
    assert json.loads(unconfigured_response.body.decode()) == {"error": "canonical_claims_auth_unconfigured"}

    configured_app = _claim_list_app()
    unauthorized_req = make_mocked_request("GET", "/api/kb/claims", app=configured_app)
    unauthorized_response = asyncio.run(routes.handle_api_kb_claims(unauthorized_req))

    assert unauthorized_response.status == 401
    assert json.loads(unauthorized_response.body.decode()) == {"error": "unauthorized"}

    non_ascii_request = make_mocked_request(
        "GET",
        "/api/kb/claims",
        app=configured_app,
        headers={"X-Api-Key": "é" * 24},
    )
    non_ascii_response = asyncio.run(routes.handle_api_kb_claims(non_ascii_request))

    assert non_ascii_response.status == 401
    assert json.loads(non_ascii_response.body.decode()) == {"error": "unauthorized"}


def test_argus_middleware_only_bypasses_global_key_for_exact_list_path() -> None:
    async def ok_handler(_request):
        return web.Response(status=204)

    app = web.Application()
    list_request = make_mocked_request("GET", "/api/kb/claims", app=app)
    detail_request = make_mocked_request(
        "GET", f"/api/kb/claims/{CLAIM_ID}", app=app
    )
    html_request = make_mocked_request("GET", f"/kb/claims/{CLAIM_ID}", app=app)

    list_response = asyncio.run(argus_app.auth_middleware(list_request, ok_handler))
    detail_response = asyncio.run(argus_app.auth_middleware(detail_request, ok_handler))
    html_response = asyncio.run(argus_app.auth_middleware(html_request, ok_handler))

    assert list_response.status == 204
    assert detail_response.status == 503
    assert html_response.status == 503


def test_canonical_claim_list_loader_runs_off_the_event_loop() -> None:
    app = _claim_list_app()
    event_loop_thread = threading.get_ident()
    loader_thread = None

    def loader(_filters):
        nonlocal loader_thread
        loader_thread = threading.get_ident()
        return {"total": 0, "rows": []}

    app[routes.KB_CLAIM_LIST_LOADER_KEY] = loader
    request = _claim_list_request("/api/kb/claims", app=app)

    response = asyncio.run(routes.handle_api_kb_claims(request))

    assert response.status == 200
    assert loader_thread is not None
    assert loader_thread != event_loop_thread


def test_canonical_claim_list_loader_timeout_is_sanitized(monkeypatch) -> None:
    app = _claim_list_app()

    def slow_loader(_filters):
        time.sleep(0.03)
        return {"total": 0, "rows": []}

    app[routes.KB_CLAIM_LIST_LOADER_KEY] = slow_loader
    monkeypatch.setattr(routes, "CLAIM_LIST_HANDLER_TIMEOUT_SECONDS", 0.001)
    request = _claim_list_request("/api/kb/claims", app=app)

    response = asyncio.run(routes.handle_api_kb_claims(request))

    assert response.status == 503
    assert json.loads(response.body.decode()) == {"error": "canonical_claims_unavailable"}


def test_canonical_claim_list_rejects_duplicate_or_out_of_order_pages() -> None:
    filters = {
        "q": "",
        "status": "all",
        "type": "",
        "tag": "",
        "limit": 2,
        "cursor": "",
        "cursor_values": None,
    }
    newest = _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00")
    older = _claim_summary(SECOND_CLAIM_ID, timestamp="2026-07-13T20:12:00+00:00")

    with pytest.raises(ValueError, match="duplicate ids"):
        routes._claim_list_payload({"total": 2, "rows": [newest, newest]}, filters)

    with pytest.raises(ValueError, match="strictly ordered"):
        routes._claim_list_payload({"total": 2, "rows": [older, newest]}, filters)


def test_canonical_claim_list_rejects_rows_at_or_before_wrong_cursor_side() -> None:
    cursor_timestamp = "2026-07-13T20:12:00+00:00"
    filters = {
        "q": "",
        "status": "all",
        "type": "",
        "tag": "",
        "limit": 1,
        "cursor": routes.encode_claim_cursor(cursor_timestamp, SECOND_CLAIM_ID),
        "cursor_values": (cursor_timestamp, SECOND_CLAIM_ID),
    }
    newer = _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00")

    with pytest.raises(ValueError, match="crossed its request cursor"):
        routes._claim_list_payload({"total": 2, "rows": [newer]}, filters)


def test_legacy_claim_errors_do_not_expose_exception_text() -> None:
    app = web.Application()

    def fail(_claim_id):
        raise RuntimeError("PGPASSWORD=must-not-leak")

    app[routes.KB_CLAIM_LOADER_KEY] = fail
    api_request = make_mocked_request(
        "GET",
        f"/api/kb/claims/{CLAIM_ID}",
        app=app,
        match_info={"claim_id": CLAIM_ID},
    )
    html_request = make_mocked_request(
        "GET",
        f"/kb/claims/{CLAIM_ID}",
        app=app,
        match_info={"claim_id": CLAIM_ID},
    )

    api_response = asyncio.run(routes.handle_api_kb_claim(api_request))
    html_response = asyncio.run(routes.handle_kb_claim_page(html_request))

    assert api_response.status == 503
    assert html_response.status == 500
    assert b"PGPASSWORD" not in api_response.body
    assert b"PGPASSWORD" not in html_response.body
