"""W20A2: floor_plan_geometry_readback pure-function tests.

Deterministic. LLM / image / VLM / DB / ImageAsset write 0.

Covers:
- synthetic_fixture readback shape (status, fp_id, grid_size, cells unique).
- geometry candidates emit per-unit camera + look-at cells + direction
  vectors + view cone records + visible-units / visible-openings supersets
  + wall/door diagnostics.
- W20 boundaries enforced — no decisions, only candidates + diagnostics.
- fail-closed on bad readback (status, grid bounds, dup cell, unknown kind,
  fp_id mismatch).
"""
from __future__ import annotations

import pytest

from app.modules.pipeline.floor_plan_geometry_readback import (
    BASE_OPENING,
    BASE_PERSISTENT_FIXTURE,
    BASE_PERSISTENT_FURNITURE,
    BASE_STRUCTURAL_UNIT,
    VIEW_CONE_LENS_ENUM_MAP,
    GeometryReadbackError,
    compute_geometry_candidates,
    compute_readback,
    compute_synthetic_readback_fixture,
)


# ─────────────────────────────── fixtures ───────────────────────────────


def _dossier() -> dict:
    """Minimal dossier shape (only the keys this module reads)."""
    return {
        "fp_id": "fp_a",
        "fp_image_path": "/p/c/e/floor_plan_render/fp_a.png",
        "grid_size": [10, 10],
        "base_marker_inventory": [
            {
                "number": 1,
                "label": "primary unit",
                "category": "area",
                "position_hint": "center",
                "base_layer_decision": BASE_STRUCTURAL_UNIT,
            },
            {
                "number": 2,
                "label": "secondary unit",
                "category": "area",
                "position_hint": "east",
                "base_layer_decision": BASE_STRUCTURAL_UNIT,
            },
            {
                "number": 3,
                "label": "interior opening",
                "category": "opening",
                "position_hint": "between 1 and 2",
                "base_layer_decision": BASE_OPENING,
            },
            {
                "number": 4,
                "label": "service counter",
                "category": "furniture",
                "position_hint": "north wall of unit 1",
                "base_layer_decision": BASE_PERSISTENT_FIXTURE,
            },
            {
                "number": 5,
                "label": "anchor seating",
                "category": "furniture",
                "position_hint": "south of unit 1",
                "base_layer_decision": BASE_PERSISTENT_FURNITURE,
            },
        ],
        "per_bg_render_facts_by_bg_id": {},
    }


# ────────────────────────── synthetic readback ──────────────────────────


def test_synthetic_readback_emits_status_grid_and_fp_id():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    assert rb["status"] == "synthetic_fixture"
    assert rb["fp_id"] == "fp_a"
    assert rb["grid_size"] == [10, 10]
    assert rb["confidence"] is None
    assert any("synthetic_fixture" in d for d in rb["diagnostics"])


def test_synthetic_readback_unique_cells_and_known_kinds():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    cells = [(e["row"], e["col"]) for e in rb["observed_markers"]]
    assert len(cells) == len(set(cells))
    assert {e["kind"] for e in rb["observed_markers"]} == {
        BASE_STRUCTURAL_UNIT,
        BASE_OPENING,
        BASE_PERSISTENT_FIXTURE,
        BASE_PERSISTENT_FURNITURE,
    }
    for e in rb["observed_markers"]:
        assert 0 <= e["row"] < 10 and 0 <= e["col"] < 10


def test_synthetic_readback_fails_on_empty_base_inventory():
    d = _dossier()
    d["base_marker_inventory"] = []
    with pytest.raises(GeometryReadbackError):
        compute_synthetic_readback_fixture(dossier=d)


def test_synthetic_readback_fails_when_too_many_markers_for_grid():
    d = _dossier()
    d["base_marker_inventory"] = [
        {
            "number": n,
            "label": f"u{n}",
            "category": "area",
            "position_hint": "",
            "base_layer_decision": BASE_STRUCTURAL_UNIT,
        }
        for n in range(1, 6)  # 5 markers
    ]
    with pytest.raises(GeometryReadbackError):
        # 2x2 grid → capacity 4 < 5
        compute_synthetic_readback_fixture(dossier=d, grid_size=(2, 2))


# ────────────────────────── compute_readback dispatch ───────────────────


def test_compute_readback_default_returns_synthetic_fixture():
    rb = compute_readback(dossier=_dossier())
    assert rb["status"] == "synthetic_fixture"


def test_compute_readback_with_provider_returns_ok_and_validates_fp_id():
    captured: dict = {}

    def provider(*, dossier, fp_image_path, grid_size):
        captured["fp_image_path"] = fp_image_path
        captured["grid_size"] = grid_size
        return {
            "status": "ok",
            "fp_id": dossier["fp_id"],
            "grid_size": list(grid_size),
            "observed_markers": [
                {"number": 1, "row": 0, "col": 0, "kind": BASE_STRUCTURAL_UNIT}
            ],
            "missing_markers": [],
            "extra_markers": [],
            "confidence": 0.91,
            "diagnostics": [],
        }

    rb = compute_readback(
        dossier=_dossier(),
        fp_image_path="/img.png",
        vlm_provider=provider,
    )
    assert rb["status"] == "ok"
    assert captured["fp_image_path"] == "/img.png"
    assert captured["grid_size"] == (10, 10)


def test_compute_readback_provider_fail_closed_on_non_ok_status():
    def bad(*, dossier, fp_image_path, grid_size):
        return {"status": "synthetic_fixture", "fp_id": dossier["fp_id"]}

    with pytest.raises(GeometryReadbackError):
        compute_readback(dossier=_dossier(), vlm_provider=bad)


def test_compute_readback_provider_fail_closed_on_fp_id_mismatch():
    def bad(*, dossier, fp_image_path, grid_size):
        return {
            "status": "ok",
            "fp_id": "fp_other",
            "grid_size": list(grid_size),
            "observed_markers": [],
        }

    with pytest.raises(GeometryReadbackError):
        compute_readback(dossier=_dossier(), vlm_provider=bad)


# ─────────────────────────── geometry candidates ────────────────────────


def test_geometry_candidates_emit_per_unit_camera_anchor_cells():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    # 2 base_structural_unit markers → 2 entries.
    assert set(g["camera_cell_candidates_per_unit"].keys()) == {"1", "2"}
    # W20A2 simple: exactly one anchor cell per unit (the unit's own marker cell).
    for cells in g["camera_cell_candidates_per_unit"].values():
        assert len(cells) == 1
        assert isinstance(cells[0], list) and len(cells[0]) == 2


def test_geometry_candidates_look_at_includes_openings_fixtures_furniture():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    # 3 candidate cells (#3 opening, #4 fixture, #5 furniture) per unit.
    for unit_str, cells in g["look_at_cell_candidates_per_unit"].items():
        assert len(cells) == 3


def test_geometry_candidates_direction_vectors_pair_all_units_excluding_self():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    pairs = {(r["camera_unit"], r["look_at_unit"])
             for r in g["direction_vector_records"]}
    # 2 units → 2*1 = 2 pairs (no self-pair).
    assert pairs == {(1, 2), (2, 1)}
    # dx/dy/length_l1 are computed.
    for r in g["direction_vector_records"]:
        assert r["dx"] == r["look_at_cell"][1] - r["camera_cell"][1]
        assert r["dy"] == r["look_at_cell"][0] - r["camera_cell"][0]
        assert r["length_l1"] == abs(r["dx"]) + abs(r["dy"])


def test_geometry_candidates_view_cone_records_per_unit_and_lens():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    # 2 units → 2 direction_vector_records, × 3 lens = 6.
    assert len(g["view_cone_records"]) == 2 * len(VIEW_CONE_LENS_ENUM_MAP)
    lens_set = {r["lens_enum"] for r in g["view_cone_records"]}
    assert lens_set == set(VIEW_CONE_LENS_ENUM_MAP)


def test_geometry_view_cones_bound_to_direction_records():
    """Codex W20A2 review #2: cones cross-product with direction
    candidates, not raw camera units."""
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    n_dir = len(g["direction_vector_records"])
    n_lens = len(VIEW_CONE_LENS_ENUM_MAP)
    assert len(g["view_cone_records"]) == n_dir * n_lens


def test_geometry_view_cones_carry_look_at_and_dxy_fields():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    assert g["view_cone_records"], "fixture has 2 units → expect non-empty"
    required_keys = {
        "camera_unit", "camera_cell",
        "look_at_unit", "look_at_cell",
        "dx", "dy",
        "lens_enum", "fov_deg",
    }
    for record in g["view_cone_records"]:
        assert required_keys <= set(record.keys()), (
            f"view_cone_record missing keys: "
            f"{required_keys - set(record.keys())}"
        )


def test_geometry_view_cones_empty_when_only_one_unit():
    """1 unit → no direction candidate → no cone candidate.
    A diagnostic must surface the empty-cone path."""
    d = _dossier()
    d["base_marker_inventory"] = [
        e for e in d["base_marker_inventory"]
        if e["number"] != 2  # drop the 2nd unit
    ]
    rb = compute_synthetic_readback_fixture(dossier=d)
    g = compute_geometry_candidates(dossier=d, readback=rb)
    assert g["direction_vector_records"] == []
    assert g["view_cone_records"] == []
    assert any(
        "direction-vector candidates are empty" in diag
        for diag in g["diagnostics"]
    )


def test_geometry_view_cone_records_carry_concrete_direction_for_vertical_pair():
    """When the two units sit on the same SVG column but different rows
    (a 'vertical' direction in image space), the cone records' dx must
    be 0 and dy non-zero. Code never picks final camera/look-at —
    the test only verifies the candidate direction is preserved through
    to the cone record so the HTML layer can rotate the polygon."""
    # Hand-crafted readback: two units stacked vertically.
    dossier = _dossier()
    readback = {
        "status": "ok",
        "fp_id": "fp_a",
        "grid_size": [10, 10],
        "observed_markers": [
            {"number": 1, "row": 1, "col": 4, "kind": BASE_STRUCTURAL_UNIT},
            {"number": 2, "row": 7, "col": 4, "kind": BASE_STRUCTURAL_UNIT},
            {"number": 3, "row": 4, "col": 4, "kind": BASE_OPENING},
            {"number": 4, "row": 0, "col": 0, "kind": BASE_PERSISTENT_FIXTURE},
            {"number": 5, "row": 9, "col": 9, "kind": BASE_PERSISTENT_FURNITURE},
        ],
        "missing_markers": [],
        "extra_markers": [],
        "confidence": None,
        "diagnostics": [],
    }
    g = compute_geometry_candidates(dossier=dossier, readback=readback)
    # Both direction records must be vertical (dx = 0, dy != 0).
    assert g["direction_vector_records"], "should have 2 pair records"
    for r in g["direction_vector_records"]:
        assert r["dx"] == 0
        assert r["dy"] != 0
    # Every cone record inherits the same dx/dy from its direction record.
    assert g["view_cone_records"], "should have 2 pairs × 3 lens"
    for record in g["view_cone_records"]:
        assert record["dx"] == 0
        assert record["dy"] != 0


def test_geometry_candidates_visible_units_supersets_excluding_self():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    # For unit 1 the only other unit is 2; symmetric.
    assert g["visible_units_candidates"] == {"1": [2], "2": [1]}


def test_geometry_candidates_visible_openings_lists_every_opening():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    assert g["visible_openings_candidates"] == {"1": [3], "2": [3]}


def test_geometry_candidates_wall_door_diagnostics_present():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    assert any(
        "wall segment polylines" in d
        for d in g["wall_door_invalidation_diagnostics"]
    )
    assert any(
        "supersets" in d
        for d in g["wall_door_invalidation_diagnostics"]
    )


def test_geometry_candidates_diagnostic_when_readback_synthetic():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    assert any("synthetic_fixture" in d for d in g["diagnostics"])
    assert g["readback_status"] == "synthetic_fixture"


# ─────────────────────────── fail-closed paths ──────────────────────────


def test_geometry_fails_on_bad_readback_status():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    rb["status"] = "failed"
    with pytest.raises(GeometryReadbackError):
        compute_geometry_candidates(dossier=_dossier(), readback=rb)


def test_geometry_accepts_cell_collision_and_surfaces_diagnostic():
    """W20E6-A: distinct marker numbers sharing one 10x10 cell must not
    raise. The collision must surface in
    ``geometry.cell_collision_diagnostics`` and be merged into
    ``geometry.diagnostics``. All other invariants stay hard.
    """
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    # Force two distinct markers (units #1 and #2) onto the same cell.
    rb["observed_markers"][1]["row"] = rb["observed_markers"][0]["row"]
    rb["observed_markers"][1]["col"] = rb["observed_markers"][0]["col"]

    geom = compute_geometry_candidates(dossier=_dossier(), readback=rb)

    collisions = geom.get("cell_collision_diagnostics") or []
    assert collisions, "expected at least one cell-collision diagnostic"
    assert any("shares 10x10 cell" in c for c in collisions), collisions
    assert all(c in geom.get("diagnostics", []) for c in collisions), (
        "cell-collision diagnostics must also be merged into "
        "geometry.diagnostics"
    )


def test_geometry_dedupes_look_at_cell_candidates_after_collision():
    """W20E6-A: when an opening marker and a fixture marker share a
    cell, that cell must appear at most once in
    ``look_at_cell_candidates_per_unit[u]``.
    """
    rb = compute_synthetic_readback_fixture(dossier=_dossier())

    opening_idx = next(
        i for i, m in enumerate(rb["observed_markers"])
        if m["kind"] == BASE_OPENING
    )
    fixture_idx = next(
        i for i, m in enumerate(rb["observed_markers"])
        if m["kind"] == BASE_PERSISTENT_FIXTURE
    )
    rb["observed_markers"][fixture_idx]["row"] = (
        rb["observed_markers"][opening_idx]["row"]
    )
    rb["observed_markers"][fixture_idx]["col"] = (
        rb["observed_markers"][opening_idx]["col"]
    )

    geom = compute_geometry_candidates(dossier=_dossier(), readback=rb)

    for unit_id, cells in (
        geom["look_at_cell_candidates_per_unit"] or {}
    ).items():
        as_tuples = [tuple(c) for c in cells]
        assert len(as_tuples) == len(set(as_tuples)), (
            f"unit {unit_id} look_at candidates contain duplicates: "
            f"{cells}"
        )


def test_geometry_still_fails_on_duplicate_marker_number_in_readback():
    """W20E6-A boundary: duplicate marker NUMBERS remain a hard fail."""
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    rb["observed_markers"][1]["number"] = rb["observed_markers"][0]["number"]
    with pytest.raises(GeometryReadbackError):
        compute_geometry_candidates(dossier=_dossier(), readback=rb)


def test_geometry_fails_on_unknown_kind_in_readback():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    rb["observed_markers"][0]["kind"] = "not_a_kind"
    with pytest.raises(GeometryReadbackError):
        compute_geometry_candidates(dossier=_dossier(), readback=rb)


def test_geometry_fails_on_out_of_grid_cell():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    rb["observed_markers"][0]["row"] = 99
    with pytest.raises(GeometryReadbackError):
        compute_geometry_candidates(dossier=_dossier(), readback=rb)


def test_geometry_fails_on_fp_id_mismatch_with_dossier():
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    rb["fp_id"] = "fp_other"
    with pytest.raises(GeometryReadbackError):
        compute_geometry_candidates(dossier=_dossier(), readback=rb)


# ─────────────────────────── boundary surface ───────────────────────────


def test_module_does_not_decide_camera_or_reference_policy():
    """Module never returns a 'chosen' camera or 'selected_ref' field.

    Boundary: code emits only candidate sets + diagnostics.
    """
    rb = compute_synthetic_readback_fixture(dossier=_dossier())
    g = compute_geometry_candidates(dossier=_dossier(), readback=rb)
    forbidden = {
        "chosen_camera_cell",
        "selected_camera",
        "selected_refs",
        "reference_decision",
        "same_physical_space_dedup_decision",
    }
    assert not (forbidden & set(g.keys()))
