from __future__ import annotations

import copy
import hashlib
import sys
from pathlib import Path
from typing import Any

import pytest

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

import stage_normalized_proposal as stage  # noqa: E402

from tests import test_compile_kb_source_packet as source_compiler_fixture  # noqa: E402

PARENT_ID = "77777777-7777-4777-8777-777777777701"
AGENT_ID = "11111111-1111-4111-8111-111111111111"
DOCUMENT = (
    "Operating note: a knowledge change becomes canonical only after reviewer approval and guarded apply. "
    "Review alone does not change canonical rows."
)
POST = "The moment a reviewer approves a Leo proposal, it is already canonical. No apply step is needed."


def _parent() -> dict[str, Any]:
    return {
        "id": PARENT_ID,
        "proposal_type": "attach_evidence",
        "status": "pending_review",
        "proposed_by_handle": "leo",
        "proposed_by_agent_id": AGENT_ID,
        "channel": "clone_composition",
        "source_ref": "composition-fixture",
        "payload": {
            "claim_candidates": [
                {
                    "claim_key": "guarded_apply_required",
                    "text": "Approval alone does not make a knowledge change canonical; guarded apply is required.",
                    "type": "structural",
                    "confidence": 0.98,
                    "evidence": [{"source_key": "operating_document", "role": "grounds"}],
                    "edges": [
                        {
                            "edge_type": "contradicts",
                            "target": "approval_is_canonical_post",
                        }
                    ],
                },
                {
                    "claim_key": "approval_is_canonical_post",
                    "text": "A reviewer approval immediately makes a Leo proposal canonical.",
                    "type": "empirical",
                    "confidence": 0.4,
                    "evidence": [{"source_key": "contradictory_post", "role": "grounds"}],
                },
            ],
            "source_candidates": [
                {
                    "source_key": "operating_document",
                    "source_type": "article",
                    "storage_path": "/composition/operating-note.txt",
                    "title": "Operating note",
                    "source_quality": "internal_operating_document",
                    "key_excerpt": DOCUMENT,
                    "content_sha256": hashlib.sha256(DOCUMENT.encode()).hexdigest(),
                },
                {
                    "source_key": "contradictory_post",
                    "source_type": "post",
                    "url": "https://x.example.test/composition/1",
                    "title": "Contradictory post",
                    "source_quality": "unverified_social_post",
                    "key_excerpt": POST,
                    "content_sha256": hashlib.sha256(POST.encode()).hexdigest(),
                },
            ],
            "dedupe_conflict_assessment": {
                "duplicate_claim_keys": [],
                "conflicts": [
                    {
                        "from_claim_key": "guarded_apply_required",
                        "to_claim_key": "approval_is_canonical_post",
                        "relationship": "contradicts",
                    }
                ],
            },
        },
    }


def test_prepare_normalized_child_is_deterministic_hash_bound_and_applyable() -> None:
    first = stage.prepare_normalized_child(_parent())
    second = stage.prepare_normalized_child(copy.deepcopy(_parent()))

    assert first == second
    assert first["status"] == "pending_review"
    assert first["proposal_type"] == "approve_claim"
    assert first["source_ref"].startswith(f"normalized:{PARENT_ID}:")
    payload = first["payload"]["apply_payload"]
    assert len(payload["claims"]) == 2
    assert len(payload["sources"]) == 4
    assert len(payload["evidence"]) == 4
    assert len(payload["edges"]) == 1
    assert payload["edges"][0]["edge_type"] == "contradicts"
    source_hashes = {row["hash"] for row in payload["sources"]}
    assert hashlib.sha256(DOCUMENT.encode()).hexdigest() in source_hashes
    assert hashlib.sha256(POST.encode()).hexdigest() in source_hashes
    assert payload["normalization_manifest"]["parent_packet_sha256"] == stage.canonical_sha256(_parent())


def test_invalid_source_hash_refuses_to_prepare_a_child() -> None:
    parent = _parent()
    parent["payload"]["source_candidates"][0]["content_sha256"] = "bad"

    with pytest.raises(stage.bound.CheckpointError, match="did not normalize"):
        stage.prepare_normalized_child(parent)


def test_prepare_normalized_child_preserves_replayable_ingestion_manifest() -> None:
    parent = _parent()
    parent["payload"]["ingestion_manifest"] = {
        "artifact_sha256": "a" * 64,
        "extractor": {"name": "deterministic_fixture_replay", "version": "2"},
        "replay_identity_sha256": "b" * 64,
        "segments": [
            {
                "id": "message-1",
                "locator": "telegram://chat/1/message/1",
                "content_sha256": "c" * 64,
            }
        ],
    }

    child = stage.prepare_normalized_child(parent)

    assert (
        child["payload"]["apply_payload"]["normalization_manifest"]["ingestion_manifest"]
        == parent["payload"]["ingestion_manifest"]
    )


def test_prepare_normalized_child_rejects_non_object_ingestion_manifest() -> None:
    parent = _parent()
    parent["payload"]["ingestion_manifest"] = "not-an-object"

    with pytest.raises(stage.bound.CheckpointError, match="ingestion_manifest must be an object"):
        stage.prepare_normalized_child(parent)


def test_stage_sql_validates_exact_pending_row_before_commit_and_never_writes_public() -> None:
    child = stage.prepare_normalized_child(_parent())
    sql = stage.build_stage_sql(child)
    lowered = sql.lower()

    assert "insert into kb_stage.kb_proposals" in lowered
    assert "on conflict (id) do nothing" in lowered
    assert "normalized stage validation failed" in lowered
    assert lowered.index("raise exception") < lowered.index("commit;")
    assert child["id"] in sql
    assert child["payload_sha256"] in sql
    assert "insert into public." not in lowered
    assert "update public." not in lowered
    assert "delete from public." not in lowered


def test_stage_normalized_child_binds_to_full_disposable_container_id(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    target = {
        "id": "clone-container-id",
        "name": "composition-clone",
        "labels": {stage.bound.DISPOSABLE_LABEL_KEY: stage.bound.DISPOSABLE_LABEL_VALUE},
        "network_mode": "none",
        "ports": {},
        "mount_sources": ["/clone-data"],
    }
    production = {
        "id": "production-container-id",
        "name": stage.bound.PRODUCTION_CONTAINER,
        "labels": {},
        "network_mode": "bridge",
        "ports": {},
        "mount_sources": ["/production-data"],
    }
    monkeypatch.setattr(
        stage.bound,
        "container_identity",
        lambda name: target if name == "composition-clone" else production,
    )
    observed: dict[str, Any] = {}

    def fake_psql(container: str, database: str, sql: str) -> dict[str, Any]:
        child = stage.prepare_normalized_child(_parent())
        observed.update({"container": container, "database": database, "sql": sql})
        return {
            "created": True,
            "proposal_id": child["id"],
            "proposal_type": child["proposal_type"],
            "status": "pending_review",
            "source_ref": child["source_ref"],
            "payload_sha256": child["payload_sha256"],
        }

    monkeypatch.setattr(stage.bound, "_psql_json", fake_psql)

    result = stage.stage_normalized_child("composition-clone", "teleo", _parent())

    assert observed["container"] == "clone-container-id"
    assert observed["database"] == "teleo"
    assert result["bound_container_id"] == "clone-container-id"
    assert result["database_receipt"]["created"] is True


def test_stage_normalized_child_does_not_require_an_ambient_production_container(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    target = {
        "id": "clone-container-id",
        "name": "composition-clone",
        "labels": {stage.bound.DISPOSABLE_LABEL_KEY: stage.bound.DISPOSABLE_LABEL_VALUE},
        "network_mode": "none",
        "ports": {},
        "mount_sources": [],
    }

    def identity(name: str) -> dict[str, Any]:
        if name == "composition-clone":
            return target
        raise stage.bound.CheckpointError("docker inspect teleo-pg: no such object: teleo-pg")

    monkeypatch.setattr(stage.bound, "container_identity", identity)
    child = stage.prepare_normalized_child(_parent())
    monkeypatch.setattr(
        stage.bound,
        "_psql_json",
        lambda container, database, sql: {
            "created": True,
            "proposal_id": child["id"],
            "proposal_type": child["proposal_type"],
            "status": "pending_review",
            "source_ref": child["source_ref"],
            "payload_sha256": child["payload_sha256"],
        },
    )

    result = stage.stage_normalized_child("composition-clone", "teleo", _parent())

    assert result["bound_container_id"] == target["id"]


def test_stage_normalized_child_refuses_unexpected_production_inspection_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    target = {
        "id": "clone-container-id",
        "name": "composition-clone",
        "labels": {stage.bound.DISPOSABLE_LABEL_KEY: stage.bound.DISPOSABLE_LABEL_VALUE},
        "network_mode": "none",
        "ports": {},
        "mount_sources": [],
    }

    def identity(name: str) -> dict[str, Any]:
        if name == "composition-clone":
            return target
        raise stage.bound.CheckpointError("docker inspect teleo-pg: permission denied")

    monkeypatch.setattr(stage.bound, "container_identity", identity)

    with pytest.raises(stage.bound.CheckpointError, match="permission denied"):
        stage.stage_normalized_child("composition-clone", "teleo", _parent())


def test_compiled_v3_child_revalidates_exact_bundle_and_rejects_tampering(tmp_path: Path) -> None:
    bundle = source_compiler_fixture._compile_v3(tmp_path / "source")

    child = stage.prepare_compiled_v3_child(bundle)

    assert child == bundle["strict_child_proposal"]
    assert child is not bundle["strict_child_proposal"]
    assert child["payload"]["apply_payload"]["contract_version"] == 3

    tampered = copy.deepcopy(bundle)
    tampered["strict_child_proposal"]["payload"]["apply_payload"]["claims"][0]["proposition"] += " tampered"
    with pytest.raises(stage.bound.CheckpointError, match="bundle content hash does not match"):
        stage.prepare_compiled_v3_child(tampered)


def test_stage_compiled_v3_bundle_binds_to_disposable_container(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    bundle = source_compiler_fixture._compile_v3(tmp_path / "source")
    child = bundle["strict_child_proposal"]
    target = {
        "id": "clone-container-id",
        "name": "composition-clone",
        "labels": {stage.bound.DISPOSABLE_LABEL_KEY: stage.bound.DISPOSABLE_LABEL_VALUE},
        "network_mode": "none",
        "ports": {},
        "mount_sources": ["/clone-data"],
    }
    production = {
        "id": "production-container-id",
        "name": stage.bound.PRODUCTION_CONTAINER,
        "labels": {},
        "network_mode": "bridge",
        "ports": {},
        "mount_sources": ["/production-data"],
    }
    monkeypatch.setattr(
        stage.bound,
        "container_identity",
        lambda name: target if name == "composition-clone" else production,
    )
    observed: dict[str, Any] = {}

    def fake_psql(container: str, database: str, sql: str) -> dict[str, Any]:
        observed.update({"container": container, "database": database, "sql": sql})
        return {
            "created": True,
            "proposal_id": child["id"],
            "proposal_type": child["proposal_type"],
            "status": "pending_review",
            "source_ref": child["source_ref"],
            "payload_sha256": child["payload_sha256"],
        }

    monkeypatch.setattr(stage.bound, "_psql_json", fake_psql)

    result = stage.stage_compiled_v3_bundle("composition-clone", "teleo", bundle)

    assert observed["container"] == "clone-container-id"
    assert observed["database"] == "teleo"
    assert "insert into kb_stage.kb_proposals" in observed["sql"].lower()
    assert result["child"]["id"] == child["id"]


def test_main_rejects_the_production_container_without_reading_the_packet(tmp_path: Path) -> None:
    missing = tmp_path / "missing.json"

    assert (
        stage.main(
            [
                "--container",
                stage.bound.PRODUCTION_CONTAINER,
                "--db",
                stage.bound.PRODUCTION_DB,
                "--proposal-file",
                str(missing),
            ]
        )
        == 2
    )
