"""Areas + layout domain tests.

Two tiers:

- Pure-unit tests (always run): the verbatim-ported tree/geometry math.
- DB tests (db_session fixture): run in-container against the migrated
  snapshot inside a savepoint that always rolls back; skip on the host.

Route-shape tests use app_client + the default-deny middleware only (no DB
needed): auth rejection is decided by path before any dependency runs. The
routers are included in-test because main.py wiring is the integrator's
change, not this domain's.
"""
from __future__ import annotations

import json

import pytest

from app.errors import AppError
from app.services import areas as sa_svc
from app.services import layout as sl_svc


# ---------------------------------------------------------------------------
# Fixture data for pure tree tests
# ---------------------------------------------------------------------------

def _rows():
    # Deliberately shuffled input; sort_order + name-fallback ordering applies.
    return [
        {"id": 1, "name": "Home", "parent_id": None, "sort_order": 10, "short_name": ""},
        {"id": 2, "name": "Back Yard", "parent_id": 1, "sort_order": 20, "short_name": "BY"},
        {"id": 3, "name": "Front Yard", "parent_id": 1, "sort_order": 10, "short_name": ""},
        {"id": 4, "name": "Fenced Garden", "parent_id": 2, "sort_order": None, "short_name": "FG"},
        {"id": 5, "name": "apple corner", "parent_id": 2, "sort_order": None, "short_name": ""},
        {"id": 6, "name": "Bed 1", "parent_id": 4, "sort_order": 10, "short_name": ""},
        # Orphan parent (99 unknown) -> becomes a root, as in v1.
        {"id": 7, "name": "Greenhouse", "parent_id": 99, "sort_order": None, "short_name": ""},
    ]


def test_build_tree_ordering_and_orphans():
    roots, by_id = sa_svc.build_tree(_rows())
    # Roots: Home (sort 10), then orphan Greenhouse (None -> 999999).
    assert [r["id"] for r in roots] == [1, 7]
    home = by_id[1]
    # Children by sort_order asc: Front Yard (10) before Back Yard (20).
    assert [c["id"] for c in home["children"]] == [3, 2]
    # None sort_order children fall back to case-insensitive name order.
    assert [c["id"] for c in by_id[2]["children"]] == [5, 4]  # "apple" < "fenced"
    assert len(by_id) == 7


def test_get_ancestors_and_descendants():
    _, by_id = sa_svc.build_tree(_rows())
    assert [a["id"] for a in sa_svc.get_ancestors(6, by_id)] == [1, 2, 4, 6]
    assert sa_svc.get_descendant_ids(1, by_id) == {2, 3, 4, 5, 6}
    assert sa_svc.get_descendant_ids(4, by_id) == {6}
    assert sa_svc.get_descendant_ids(6, by_id) == set()


def test_area_path_and_compact_path_skips_home():
    _, by_id = sa_svc.build_tree(_rows())
    assert sa_svc.area_path_name(4, by_id) == "Home > Back Yard > Fenced Garden"
    cp = sa_svc.area_compact_path(4, by_id)
    # Top-level "Home" is skipped; short_name preferred for parents.
    assert cp == {"parent": "BY", "name": "Fenced Garden", "full": "BY > Fenced Garden"}
    # The area itself keeps its full name even when it IS home.
    assert sa_svc.area_compact_path(1, by_id)["name"] == "Home"


def test_flattened_area_options_sorted_and_excludes():
    _, by_id = sa_svc.build_tree(_rows())
    opts = sa_svc.flattened_area_options(by_id)
    paths = [o["path"] for o in opts]
    assert paths == sorted(paths, key=str.lower)
    assert {o["id"] for o in opts} == set(by_id)
    excl = sa_svc.flattened_area_options(by_id, exclude_ids={4, 6})
    assert {o["id"] for o in excl} == {1, 2, 3, 5, 7}


def test_area_tree_payload_shape_and_exclusion():
    roots, by_id = sa_svc.build_tree(_rows())
    payload = sa_svc.area_tree_payload(roots, by_id, exclude_ids={6})
    assert payload["roots"] == [1, 7]
    assert 6 not in payload["areas"]
    fg = payload["areas"][4]
    assert fg["path_short"] == "Home › BY"  # short_name preferred
    assert fg["children"] == []  # 6 excluded


def test_plant_count_and_species_summary_maps():
    roots, by_id = sa_svc.build_tree(_rows())
    plants_by_area = {
        6: [
            {"quantity": "3", "species_name": "Tomato"},
            {"quantity": "a few", "species_name": "Basil"},  # free-form -> 1
        ],
        2: [{"quantity": "2", "species_name": "Tomato"}],
    }
    counts = sa_svc.get_plant_count_map(roots, plants_by_area)
    assert counts[6] == 4
    assert counts[4] == 4      # rolls up from Bed 1
    assert counts[2] == 6      # own 2 + subtree 4
    assert counts[1] == 6
    summary = sa_svc.get_species_summary_map(roots, plants_by_area)
    assert summary[2] == [{"name": "Tomato", "count": 5}, {"name": "Basil", "count": 1}]


def test_normalize_rotation_and_parse_dim():
    assert sa_svc.normalize_rotation(45) == 45
    assert sa_svc.normalize_rotation("315") == 315
    assert sa_svc.normalize_rotation(360) == 0
    assert sa_svc.normalize_rotation(405) == 45   # mod 360 then snap
    assert sa_svc.normalize_rotation(46) == 0     # not a 45-degree step
    assert sa_svc.normalize_rotation("garbage") == 0
    assert sa_svc.normalize_rotation(None) == 0

    assert sa_svc.parse_dim("") == 0.0
    assert sa_svc.parse_dim("  ") == 0.0
    assert sa_svc.parse_dim("2.5") == 2.5
    assert sa_svc.parse_dim("abc") == 0.0
    assert sa_svc.parse_dim(None) == 0.0
    assert sa_svc.parse_dim(4) == 4.0

    assert sa_svc.parse_parent_id("") is None
    assert sa_svc.parse_parent_id("7") == 7
    assert sa_svc.parse_parent_id(7) == 7
    assert sa_svc.parse_parent_id(None) is None


def test_format_dims():
    assert sa_svc.format_dims({"length_ft": 10.0, "width_ft": 4.5}) == "10×4.5 ft"
    assert sa_svc.format_dims(
        {"length_ft": 10.0, "width_ft": 4.0, "garden_area_sqft": 40.0}
    ) == "10×4 ft · 40 sqft"
    assert sa_svc.format_dims({"length_ft": 0, "width_ft": 5}) == ""


# ---------------------------------------------------------------------------
# Geometry (verbatim v1 math)
# ---------------------------------------------------------------------------

def test_sketch_geometry_axis_aligned():
    g = sl_svc.sketch_geometry(10, 5, 0)
    assert g["rotation"] == 0
    assert g["pad"] == pytest.approx(1.0)       # max(10,5) * 0.1
    assert g["vb_w"] == pytest.approx(12.0)
    assert g["vb_h"] == pytest.approx(7.0)
    assert (g["cx"], g["cy"]) == (pytest.approx(6.0), pytest.approx(3.5))
    assert g["rect_x"] == pytest.approx(1.0)
    assert g["rect_y"] == pytest.approx(1.0)
    assert (g["rect_w"], g["rect_h"]) == (10.0, 5.0)


def test_sketch_geometry_rotated_90_and_45():
    g = sl_svc.sketch_geometry(10, 5, 90)
    assert g["vb_w"] == pytest.approx(5 + 2.0)   # bbox 5x10, pad = 1.0
    assert g["vb_h"] == pytest.approx(10 + 2.0)
    assert g["rect_x"] == pytest.approx(g["cx"] - 5.0)
    assert g["rect_y"] == pytest.approx(g["cy"] - 2.5)

    import math
    g45 = sl_svc.sketch_geometry(10, 5, 45)
    b = 15 * math.cos(math.radians(45))
    assert g45["vb_w"] == pytest.approx(b * 1.2)
    assert g45["vb_h"] == pytest.approx(b * 1.2)
    assert g45["rotation"] == 45


def test_sketch_geometry_tolerates_bad_rotation_and_custom_pad():
    assert sl_svc.sketch_geometry(10, 5, "abc")["rotation"] == 0
    assert sl_svc.sketch_geometry(10, 5, None)["rotation"] == 0
    g = sl_svc.sketch_geometry(10, 5, 0, pad_ratio=0.02)
    assert g["pad"] == pytest.approx(0.2)


def test_resolve_child_area_positions_grid_and_saved():
    parent = {"length_ft": 10.0, "width_ft": 10.0}
    children = [
        {"id": 1, "length_ft": 2, "width_ft": 2, "parent_pos_x": 7.5, "parent_pos_y": 2.5},
        {"id": 2, "length_ft": 3, "width_ft": 1, "parent_pos_x": None, "parent_pos_y": None},
        {"id": 3, "length_ft": 0, "width_ft": 4},  # unsized -> skipped
    ]
    out = sl_svc.resolve_child_area_positions(parent, children)
    assert [c["id"] for c in out] == [1, 2]
    assert (out[0]["resolved_x"], out[0]["resolved_y"]) == (7.5, 2.5)  # saved honored
    # n=2, cols = round(sqrt(2*10/10)) = 1 -> vertical grid, cell 10x5;
    # child 2 is index 1 -> row 1 center (5.0, 7.5).
    assert (out[1]["resolved_x"], out[1]["resolved_y"]) == (5.0, 7.5)
    # Degenerate parents produce no placements.
    assert sl_svc.resolve_child_area_positions({"length_ft": 0, "width_ft": 5}, children) == []


def test_resolve_plant_positions_default_grid_and_partial_icons():
    # qty 15, ppu 4 -> 4 icons [4, 4, 4, 3]
    plant = {
        "quantity": "15",
        "species_plants_per_unit": 4,
        "species_space_per_unit_sqft": 1.0,
        "positions": "",
    }
    out = sl_svc.resolve_plant_positions(plant, 4.0, 4.0)
    assert [n for (_, _, n, _) in out] == [4, 4, 4, 3]
    # cols = min(4, floor(4/1)) = 4 -> single row at y = 2.0 (row_h 4/1 / 2)
    assert [x for (x, _, _, _) in out] == [0.5, 1.5, 2.5, 3.5]
    assert all(y == 2.0 for (_, y, _, _) in out)
    assert all(s == 1.0 for (_, _, _, s) in out)


def test_resolve_plant_positions_saved_and_junk():
    plant = {
        "quantity": "2",
        "species_plants_per_unit": 1,
        "species_space_per_unit_sqft": 1.0,
        "positions": json.dumps([{"x": 1.25, "y": 0.75}, {"x": 3.0, "y": 2.0}]),
    }
    out = sl_svc.resolve_plant_positions(plant, 10, 5)
    assert [(x, y) for (x, y, _, _) in out] == [(1.25, 0.75), (3.0, 2.0)]

    plant["positions"] = "not json"
    out = sl_svc.resolve_plant_positions(plant, 10, 5)
    assert len(out) == 2  # falls back to grid, one icon per plant

    # Free-form quantity -> 1
    junk = {"quantity": "a few", "species_plants_per_unit": 1,
            "species_space_per_unit_sqft": 1.0, "positions": ""}
    assert len(sl_svc.resolve_plant_positions(junk, 10, 5)) == 1


def test_plant_positions_strict():
    assert sl_svc.plant_positions_strict({"positions": ""}) == []
    assert sl_svc.plant_positions_strict({"positions": "garbage"}) == []
    raw = json.dumps([{"x": 1, "y": 2}, {"y": 9}, "junk", {"x": "bad", "y": 0}])
    assert sl_svc.plant_positions_strict({"positions": raw}) == [{"x": 1.0, "y": 2.0}]


def test_species_icon_color_stable():
    a = sl_svc.species_icon_color("Tomato")
    assert a == sl_svc.species_icon_color("tomato")  # case-insensitive digest
    assert a.startswith("hsl(") and a.endswith(", 55%, 50%)")
    assert sl_svc.species_icon_color("") == "hsl(0, 0%, 70%)"
    assert sl_svc.species_icon_color(None) == "hsl(0, 0%, 70%)"


# ---------------------------------------------------------------------------
# Route shape (no DB): default-deny + OpenAPI schema coherence
# ---------------------------------------------------------------------------

EXPECTED_PATHS = {
    "/api/areas",
    "/api/areas/reorder",
    "/api/areas/{area_id}",
    "/api/areas/{area_id}/parent-position",
    "/api/areas/{area_id}/layout",
    "/api/plants/{plant_id}/positions",
}


@pytest.fixture()
def wired_client(app_client):
    """app_client with the areas + layout routers included (idempotent: once
    main.py wires them, the include here becomes a no-op)."""
    app = app_client.app
    existing = {getattr(r, "path", None) for r in app.routes}
    if "/api/areas" not in existing:
        from app.routers import areas as areas_router
        from app.routers import layout as layout_router

        app.include_router(areas_router.router)
        app.include_router(layout_router.router)
    return app_client


def test_area_routes_denied_without_grant(wired_client):
    for method, path in [
        ("GET", "/api/areas"),
        ("POST", "/api/areas"),
        ("POST", "/api/areas/reorder"),
        ("GET", "/api/areas/1"),
        ("PATCH", "/api/areas/1"),
        ("DELETE", "/api/areas/1"),
        ("POST", "/api/areas/1/parent-position"),
        ("GET", "/api/areas/1/layout"),
        ("POST", "/api/plants/1/positions"),
    ]:
        r = wired_client.request(method, path)
        assert r.status_code == 401, f"{method} {path}: {r.status_code}"
        assert r.json()["error"]["code"] == "auth_session_missing"


def test_openapi_includes_all_domain_routes(wired_client, auth_headers):
    """Exercises schema generation end-to-end (validates the pydantic
    response models are coherent) and pins the route surface."""
    r = wired_client.get("/api/openapi.json", headers=auth_headers)
    assert r.status_code == 200
    paths = set(r.json()["paths"])
    assert EXPECTED_PATHS <= paths


# ---------------------------------------------------------------------------
# DB tests — migrated-snapshot facts + mutations (savepoint, always rolled back)
# ---------------------------------------------------------------------------

GID = 1  # single garden today


def test_migrated_area_count(db_session):
    rows = sa_svc.load_areas(db_session, GID, include_archived=True)
    assert len(rows) == 128


def test_migrated_tree_breadcrumbs_area7(db_session):
    rows = sa_svc.load_areas(db_session, GID, include_archived=True)
    _, by_id = sa_svc.build_tree(rows)
    crumbs = sa_svc.get_ancestors(7, by_id)
    assert [c["id"] for c in crumbs] == [4, 6, 7]
    assert [c["name"] for c in crumbs] == ["Home", "Back Yard", "Fenced Garden"]
    assert sa_svc.area_path_name(7, by_id) == "Home > Back Yard > Fenced Garden"
    cp = sa_svc.area_compact_path(7, by_id)
    assert cp["name"] == "Fenced Garden"
    assert "Home" not in cp["parent"]  # top-level Home skipped


def test_migrated_area51_dims(db_session):
    rows = sa_svc.load_areas(db_session, GID, include_archived=True)
    _, by_id = sa_svc.build_tree(rows)
    a51 = by_id[51]
    assert a51["name"] == "Center Container"
    assert float(a51["length_ft"]) == 12.5
    assert float(a51["width_ft"]) == 2.5


def test_reorder_rejects_cycles(db_session):
    # 7 is a descendant of 6 -> re-parenting 6 under 7 must be rejected.
    with pytest.raises(AppError) as exc:
        sa_svc.reorder_area(db_session, GID, area_id=6, parent_id=7, new_index=0)
    assert exc.value.code == "area_cycle_rejected"
    # Self-parenting is also a cycle.
    with pytest.raises(AppError) as exc:
        sa_svc.reorder_area(db_session, GID, area_id=7, parent_id=7, new_index=0)
    assert exc.value.code == "area_cycle_rejected"


def test_update_rejects_cycle_via_parent_change(db_session):
    with pytest.raises(AppError) as exc:
        sa_svc.update_area(db_session, GID, 4, {"parent_id": 7})
    assert exc.value.code == "area_cycle_rejected"


def test_reorder_moves_and_resequences(db_session):
    from sqlalchemy import select
    from app.models.garden import Area

    out = sa_svc.reorder_area(db_session, GID, area_id=7, parent_id=None, new_index=0)
    assert out == {"ok": True}
    a7 = db_session.scalar(select(Area).where(Area.id == 7))
    assert a7.parent_id is None
    assert a7.sort_order == 0
    # Root siblings resequenced at index * 10.
    roots = db_session.scalars(
        select(Area.sort_order)
        .where(Area.garden_id == GID, Area.parent_id.is_(None))
        .order_by(Area.sort_order)
    ).all()
    assert roots == [i * 10 for i in range(len(roots))]


def test_create_update_delete_with_audit(db_session):
    from sqlalchemy import select
    from app.models.garden import Area
    from app.models.history import ActivityLog
    from app.models.planting import Plant

    created = sa_svc.create_area(
        db_session, GID,
        name="  _Port Test Area  ", parent_id="7",
        length_ft="10", width_ft="4.5", garden_area_sqft="",
        sketch_rotation="405",
    )
    aid = created["id"]
    assert created["name"] == "_Port Test Area"           # stripped
    assert created["parent_id"] == 7
    assert created["length_ft"] == 10.0                   # str-parsed
    assert created["garden_area_sqft"] == 0.0             # blank -> 0.0
    assert created["sketch_rotation"] == 45               # 405 % 360 snapped
    assert created["dims"] == "10×4.5 ft"
    add_log = db_session.scalar(
        select(ActivityLog).where(
            ActivityLog.entity_type == "area",
            ActivityLog.entity_id == aid,
            ActivityLog.category == "add",
        )
    )
    assert add_log is not None and "_Port Test Area" in add_log.summary

    # sort_order = sibling max + 10
    sibling_max = max(
        (s or 0)
        for s in db_session.scalars(
            select(Area.sort_order).where(Area.parent_id == 7, Area.id != aid)
        ).all()
        or [0]
    )
    assert created["sort_order"] == sibling_max + 10

    updated = sa_svc.update_area(
        db_session, GID, aid,
        {"sketch_rotation": 46, "length_ft": "", "sunlight": " full sun "},
    )
    assert updated["sketch_rotation"] == 0                # 46 not a step
    assert updated["length_ft"] == 0.0                    # blank tolerated
    assert updated["sunlight"] == "full sun"
    edit_fields = set(
        db_session.scalars(
            select(ActivityLog.field).where(
                ActivityLog.entity_type == "area",
                ActivityLog.entity_id == aid,
                ActivityLog.category == "edit",
            )
        ).all()
    )
    assert {"sketch_rotation", "length_ft", "sunlight"} <= edit_fields

    with pytest.raises(AppError) as exc:
        sa_svc.update_area(db_session, GID, aid, {"name": "   "})
    assert exc.value.code == "area_name_required"

    # Recursive delete: child removed, plants unlinked (not deleted).
    child = sa_svc.create_area(db_session, GID, name="_Port Test Child", parent_id=aid)
    plant = Plant(garden_id=GID, quantity="2", status="planted",
                  area_id=child["id"], positions="", notes="", source="",
                  featured_image_path="", created_at=sa_svc.now_utc())
    db_session.add(plant)
    db_session.flush()
    out = sa_svc.delete_area(db_session, GID, aid)
    assert out["ok"] is True and out["parent_id"] == 7
    assert db_session.scalar(select(Area.id).where(Area.id == child["id"])) is None
    assert db_session.scalar(select(Area.id).where(Area.id == aid)) is None
    db_session.expire_all()
    assert db_session.scalar(select(Plant.area_id).where(Plant.id == plant.id)) is None
    del_log = db_session.scalar(
        select(ActivityLog).where(
            ActivityLog.entity_type == "area",
            ActivityLog.entity_id == aid,
            ActivityLog.category == "delete",
        )
    )
    assert del_log is not None


def test_parent_position_clamps_and_requires_parent(db_session):
    parent = sa_svc.create_area(db_session, GID, name="_PP Parent",
                                length_ft="10", width_ft="4")
    child = sa_svc.create_area(db_session, GID, name="_PP Child",
                               parent_id=parent["id"])
    out = sa_svc.set_parent_position(db_session, GID, child["id"], 999.0, -3.0)
    assert out == {"ok": True, "id": child["id"], "x": 10.0, "y": 0.0}
    # Root area has nothing to position within.
    with pytest.raises(AppError) as exc:
        sa_svc.set_parent_position(db_session, GID, parent["id"], 1.0, 1.0)
    assert exc.value.code == "area_no_parent"
    with pytest.raises(AppError) as exc:
        sa_svc.set_parent_position(db_session, GID, 999999, 1.0, 1.0)
    assert exc.value.code == "area_not_found"


def test_layout_payload_area51(db_session):
    payload = sl_svc.get_layout_payload(db_session, GID, 51)
    assert payload["area"]["name"] == "Center Container"
    assert payload["L"] == 12.5 and payload["W"] == 2.5
    assert payload["g"]["rotation"] == 0                 # axis-aligned render
    assert payload["g"]["rect_w"] == 12.5
    assert payload["g"]["pad"] == pytest.approx(12.5 * 0.02)
    assert isinstance(payload["compass_rot"], int)
    assert isinstance(payload["placed_icons"], list)
    assert isinstance(payload["inventory_tiles"], list)
    # Pool tiles never contain plants attached to THIS area.
    for tile in payload["unplanted_pool_tiles"] + payload["idea_pool_tiles"]:
        assert tile["status"] in ("unplanted", "idea")
        assert tile["inv_plants_each"], "pool queue must carry the full schedule"
        assert sum(tile["inv_plants_each"]) == tile["count"]


def test_layout_requires_dimensions(db_session):
    bare = sa_svc.create_area(db_session, GID, name="_No Dims")
    with pytest.raises(AppError) as exc:
        sl_svc.get_layout_payload(db_session, GID, bare["id"])
    assert exc.value.code == "area_no_dimensions"


def test_save_plant_positions_clamps_and_caps(db_session):
    from sqlalchemy import select
    from app.models.planting import Plant

    plant = Plant(garden_id=GID, quantity="2", status="planted", area_id=51,
                  positions="", notes="", source="", featured_image_path="",
                  created_at=sa_svc.now_utc())
    db_session.add(plant)
    db_session.flush()
    out = sl_svc.save_plant_positions(
        db_session, GID, plant.id,
        [{"x": 100, "y": 100}, {"x": 1.23456, "y": 1.0}, {"x": 2, "y": 2},
         "junk", {"x": "bad", "y": 0}],
    )
    # Junk skipped, then capped at quantity (2).
    assert out == {"ok": True, "id": plant.id, "saved": 2}
    db_session.expire_all()
    saved = json.loads(
        db_session.scalar(select(Plant.positions).where(Plant.id == plant.id))
    )
    # Area 51 is 12.5 x 2.5 -> clamped.
    assert saved == [{"x": 12.5, "y": 2.5}, {"x": 1.235, "y": 1.0}]

    with pytest.raises(AppError) as exc:
        sl_svc.save_plant_positions(db_session, GID, 99999999, [])
    assert exc.value.code == "plant_not_found"


def test_area_detail_payload_area7(db_session):
    d = sa_svc.get_area_detail(db_session, GID, 7)
    assert d["area"]["id"] == 7 and d["area"]["name"] == "Fenced Garden"
    assert [b["id"] for b in d["breadcrumbs"]] == [4, 6, 7]
    assert d["status_filter"] == "planted"
    assert isinstance(d["children"], list)
    assert isinstance(d["plants"], list)
    assert {"geometry", "plants", "children", "child_label_size_ft"} <= set(d["sketch"])
    assert {"total_qty", "species_count", "variety_count", "planting_count",
            "plant_list"} <= set(d["summary"])
    # Unknown status falls back to planted (v1 .get() fallback).
    d2 = sa_svc.get_area_detail(db_session, GID, 7, status="bogus")
    assert d2["status_filter"] == "planted"
    with pytest.raises(AppError) as exc:
        sa_svc.get_area_detail(db_session, GID, 99999999)
    assert exc.value.code == "area_not_found"


def test_areas_index_payload(db_session):
    idx = sa_svc.get_areas_index(db_session, GID)
    assert idx["tree"], "migrated garden has roots"
    assert idx["options"], "flattened options present"
    opt_by_id = {o["id"]: o for o in idx["options"]}
    assert opt_by_id[7]["path"] == "Home > Back Yard > Fenced Garden"
    assert set(idx["picker"]) == {"roots", "areas"}
    # Node shape (first root).
    node = idx["tree"][0]
    assert {"id", "name", "dims", "child_count", "plant_count", "summary",
            "children"} <= set(node)
