from __future__ import annotations

import importlib.util
import json
import stat
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = REPO_ROOT / "scripts" / "prepare_kb_context_from_canonical_dump.py"
SPEC = importlib.util.spec_from_file_location("prepare_kb_context_from_canonical_dump", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
context_builder = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = context_builder
SPEC.loader.exec_module(context_builder)

CLAIM_A = "11111111-1111-4111-8111-111111111111"
CLAIM_B = "22222222-2222-4222-8222-222222222222"
SOURCE_ID = "33333333-3333-4333-8333-333333333333"


def _copy(table: str, columns: list[str], rows: list[list[str]]) -> str:
    rendered = [f"COPY public.{table} ({', '.join(columns)}) FROM stdin;"]
    rendered.extend("\t".join(row) for row in rows)
    rendered.append(r"\.")
    return "\n".join(rendered) + "\n"


def test_build_context_matches_readonly_claim_ranking_and_counts(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    dump = tmp_path / "canonical.dump"
    dump.write_bytes(b"fixture dump")
    artifact = tmp_path / "segment.txt"
    artifact.write_text("Exact source quotes keep staged proposals reviewable.\n", encoding="utf-8")
    sql = {
        "claims": _copy(
            "claims",
            [
                "id",
                "type",
                "text",
                "status",
                "confidence",
                "tags",
                "created_by",
                "superseded_by",
                "created_at",
                "updated_at",
            ],
            [
                [
                    CLAIM_A,
                    "structural",
                    "Exact source quotes keep proposals reviewable.",
                    "open",
                    "0.8",
                    "{provenance}",
                    r"\N",
                    r"\N",
                    "2026-01-01",
                    "2026-01-01",
                ],
                [
                    CLAIM_B,
                    "structural",
                    "Staged proposals need guarded apply.",
                    "open",
                    "0.9",
                    "{review}",
                    r"\N",
                    r"\N",
                    "2026-01-01",
                    "2026-01-01",
                ],
            ],
        ),
        "claim_evidence": _copy(
            "claim_evidence",
            ["claim_id", "source_id", "role", "weight", "created_by", "created_at"],
            [[CLAIM_A, SOURCE_ID, "grounds", "1", r"\N", "2026-01-01"]],
        ),
        "claim_edges": _copy(
            "claim_edges",
            ["id", "from_claim", "to_claim", "edge_type", "weight", "created_by", "created_at"],
            [["44444444-4444-4444-8444-444444444444", CLAIM_A, CLAIM_B, "supports", "1", r"\N", "2026-01-01"]],
        ),
    }
    monkeypatch.setattr(context_builder, "restore_table", lambda _dump, table, _image, **_kwargs: sql[table])

    context, receipt = context_builder.build_context(
        dump_path=dump,
        artifact_path=artifact,
        title="Telegram segment",
        limit=12,
        docker_image="fixture-postgres",
        repo_root=REPO_ROOT,
    )

    assert context["candidate_claims"][0]["id"] == CLAIM_A
    assert context["candidate_claims"][0]["evidence_count"] == 1
    assert context["candidate_claims"][0]["edge_count"] == 1
    assert receipt["database_write_performed"] is False
    assert receipt["model_call_performed"] is False
    assert receipt["runtime"]["network_disabled"] is True
    assert receipt["runtime"]["pg_restore_output_retained"] is False
    assert receipt["runtime"]["copy_encoding"] == "utf-8"
    assert receipt["runtime"]["copy_decode_errors"] == "strict"


def test_copy_parser_preserves_escaped_source_text() -> None:
    sql = _copy("claims", ["id", "text"], [[CLAIM_A, r"line one\nline two\tvalue\\tail"]])

    rows = context_builder.parse_copy_table(sql, "claims")

    assert rows == [{"id": CLAIM_A, "text": "line one\nline two\tvalue\\tail"}]


@pytest.mark.parametrize(
    ("encoded", "decoded"),
    [
        (r"caf\303\251", "caf\N{LATIN SMALL LETTER E WITH ACUTE}"),
        (r"caf\xc3\xa9", "caf\N{LATIN SMALL LETTER E WITH ACUTE}"),
        (r"price \342\202\2545", "price \N{EURO SIGN}5"),
        (r"literal\qescape", "literalqescape"),
        (r"literal\x", "literalx"),
        (r"literal\xg1", "literalxg1"),
    ],
)
def test_copy_parser_decodes_octal_and_hex_as_encoded_bytes(encoded: str, decoded: str) -> None:
    assert context_builder._decode_copy_field(encoded) == decoded


def test_copy_parser_uses_explicit_encoding() -> None:
    assert context_builder._decode_copy_field(r"caf\351", encoding="latin-1") == "caf\N{LATIN SMALL LETTER E WITH ACUTE}"
    with pytest.raises(context_builder.ContextError, match="valid utf-8"):
        context_builder._decode_copy_field(r"caf\351", encoding="utf-8")


@pytest.mark.parametrize("encoded", [r"\xff", r"\303", r"\400", r"\000", "dangling\\"])
def test_copy_parser_fails_closed_on_invalid_byte_escapes(encoded: str) -> None:
    with pytest.raises(context_builder.ContextError):
        context_builder._decode_copy_field(encoded)


@pytest.mark.parametrize("line_ending", ["\n", "\r\n"])
def test_copy_parser_splits_only_copy_row_boundaries(line_ending: str) -> None:
    source_text = "before\N{LINE SEPARATOR}middle\N{PARAGRAPH SEPARATOR}after"
    sql = line_ending.join(
        [
            "COPY public.claims (id, text) FROM stdin;",
            f"{CLAIM_A}\t{source_text}",
            r"\.",
            "",
        ]
    )

    rows = context_builder.parse_copy_table(sql, "claims")

    assert rows == [{"id": CLAIM_A, "text": source_text}]


def test_private_json_is_owner_only_and_refuses_overwrite(tmp_path: Path) -> None:
    output = tmp_path / "private" / "context.json"

    context_builder._private_json(output, {"safe": True})

    assert json.loads(output.read_text(encoding="utf-8")) == {"safe": True}
    assert stat.S_IMODE(output.parent.stat().st_mode) == 0o700
    assert stat.S_IMODE(output.stat().st_mode) == 0o600
    with pytest.raises(context_builder.ContextError, match="refusing to overwrite"):
        context_builder._private_json(output, {"safe": False})


def test_cli_keeps_private_context_receipt_paths_and_details_off_stdout(
    tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
    sentinel = "PRIVATE-CANONICAL-DETAIL-SENTINEL"
    dump = tmp_path / "private-dump-sentinel.dump"
    artifact = tmp_path / "private-source-sentinel.txt"
    context_output = tmp_path / "private-output" / "context.json"
    receipt_output = tmp_path / "private-output" / "receipt.json"
    context = {"database_search_query": sentinel, "candidate_claims": [{"id": CLAIM_A, "text": sentinel}]}
    receipt = {"source": {"path": str(artifact), "private_detail": sentinel}}
    monkeypatch.setattr(context_builder, "build_context", lambda **_kwargs: (context, receipt))

    status = context_builder.main(
        [
            "--dump",
            str(dump),
            "--artifact",
            str(artifact),
            "--title",
            sentinel,
            "--context-output",
            str(context_output),
            "--receipt-output",
            str(receipt_output),
        ]
    )

    stdout = capsys.readouterr().out
    public_status = json.loads(stdout)
    assert status == 0
    assert public_status == {
        "schema": context_builder.STATUS_SCHEMA,
        "status": "prepared",
        "database_write_performed": False,
        "model_call_performed": False,
        "private_context_written": True,
        "private_receipt_written": True,
    }
    assert sentinel in context_output.read_text(encoding="utf-8")
    assert sentinel in receipt_output.read_text(encoding="utf-8")
    for private_value in (sentinel, str(dump), str(artifact), str(context_output), str(receipt_output)):
        assert private_value not in stdout


def test_cli_redacts_private_failure_details_from_stdout(
    tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
    sentinel = "PRIVATE-CONTEXT-FAILURE-SENTINEL"
    private_path = tmp_path / sentinel / "source.txt"

    def fail(**_kwargs):
        raise context_builder.ContextError(f"{sentinel}: {private_path}")

    monkeypatch.setattr(context_builder, "build_context", fail)
    status = context_builder.main(
        [
            "--dump",
            str(tmp_path / "dump"),
            "--artifact",
            str(private_path),
            "--title",
            sentinel,
            "--context-output",
            str(tmp_path / "context.json"),
            "--receipt-output",
            str(tmp_path / "receipt.json"),
        ]
    )

    stdout = capsys.readouterr().out
    public_status = json.loads(stdout)
    assert status == 2
    assert public_status["reason"] == "context_preparation_failed"
    assert sentinel not in stdout
    assert str(private_path) not in stdout


@pytest.mark.parametrize(
    ("fail_on_replace", "expected_context", "expected_receipt"),
    [(1, False, False), (2, True, False)],
)
def test_cli_reports_exact_state_after_each_context_publication_boundary(
    tmp_path: Path,
    capsys: pytest.CaptureFixture,
    monkeypatch: pytest.MonkeyPatch,
    fail_on_replace: int,
    expected_context: bool,
    expected_receipt: bool,
) -> None:
    context_output = tmp_path / "private-output" / "context.json"
    receipt_output = tmp_path / "private-output" / "receipt.json"
    context = {"database_search_query": "private", "candidate_claims": []}
    receipt = {"retrieval": {"candidate_count": 0}}
    monkeypatch.setattr(context_builder, "build_context", lambda **_kwargs: (context, receipt))
    real_replace = context_builder.os.replace
    replace_count = 0

    def faulted_replace(source: Path, destination: Path) -> None:
        nonlocal replace_count
        replace_count += 1
        if replace_count == fail_on_replace:
            raise OSError("publication fault detail must stay private")
        real_replace(source, destination)

    monkeypatch.setattr(context_builder.os, "replace", faulted_replace)
    status = context_builder.main(
        [
            "--dump",
            str(tmp_path / "dump"),
            "--artifact",
            str(tmp_path / "artifact"),
            "--title",
            "private",
            "--context-output",
            str(context_output),
            "--receipt-output",
            str(receipt_output),
        ]
    )

    public_status = json.loads(capsys.readouterr().out)
    assert status == 2
    assert public_status["private_context_written"] is expected_context
    assert public_status["private_receipt_written"] is expected_receipt
    assert context_output.is_file() is expected_context
    assert receipt_output.is_file() is expected_receipt
