"""Layout domain service — sketch geometry + layout-mode payload + positions.

v1 source (/srv/apps/garden/app/main.py, READ-ONLY reference):

- ``sketch_geometry`` (~4105-4144) — VERBATIM math port
- ``resolve_child_area_positions`` (~4005-4039) — verbatim
- ``resolve_plant_positions`` (~4042-4102) — verbatim (grid auto-fill used by
  the area-detail sketch view)
- ``_plant_positions_strict`` (~4367-4385) — verbatim (layout mode: positions
  JSON is the source of truth, no auto-fill)
- ``species_icon_color`` (~4150-4158) — verbatim (stable HSL from name)
- GET /areas/{id}/layout payload assembly (~4388-4561)
- POST /api/plants/{id}/positions (~5836-5889)

The species emoji/SVG **icon registry** is NOT ported here: per the approved
plan it is served on species payloads by the catalog domain; layout payloads
carry species_id/variety_id (+ the stable color) so the client resolves the
glyph from the catalog. Plant-position saves are intentionally NOT
audit-logged (v1 decision: sketch repositioning is too noisy).
"""
from __future__ import annotations

import json
import hashlib
import math

import sqlalchemy as sa
from sqlalchemy import func, select, update as sa_update
from sqlalchemy.orm import Session, aliased

from ..errors import AppError
from ..models.catalog import Species, Variety
from ..models.garden import Area
from ..models.planting import Plant
from . import areas as areas_svc
from .areas import AREA_NOT_FOUND

# --- error codes (service-owned) --------------------------------------------
AREA_NO_DIMENSIONS = "area_no_dimensions"
PLANT_NOT_FOUND = "plant_not_found"


# ---------------------------------------------------------------------------
# Pure geometry (verbatim v1 ports)
# ---------------------------------------------------------------------------

def sketch_geometry(length_ft: float, width_ft: float, rotation_deg, pad_ratio: float = 0.1) -> dict:
    """Layout values for an SVG sketch of a rectangle rotated by rotation_deg.

    Returns a viewBox sized to fit the rotated bounding box plus padding,
    plus a centered rect (in natural orientation) and the rotation origin
    (viewBox center). The caller draws the rect with these coords and applies
    a single <g transform="rotate(deg cx cy)"> wrapper.

    `pad_ratio` controls breathing room around the rotated bounding box.
    Default 0.1 (10%) for the regular sketch view; layout mode passes a
    smaller value (~0.02) to use more of the available canvas.
    """
    L = float(length_ft or 0)
    W = float(width_ft or 0)
    try:
        rot = int(rotation_deg or 0)
    except (TypeError, ValueError):
        rot = 0
    theta = math.radians(rot)
    c = abs(math.cos(theta))
    s = abs(math.sin(theta))
    bbox_w = L * c + W * s
    bbox_h = L * s + W * c
    pad = max(bbox_w, bbox_h) * pad_ratio
    vb_w = bbox_w + 2 * pad
    vb_h = bbox_h + 2 * pad
    cx = vb_w / 2
    cy = vb_h / 2
    return {
        "vb_w": vb_w,
        "vb_h": vb_h,
        "cx": cx,
        "cy": cy,
        "rect_x": cx - L / 2,
        "rect_y": cy - W / 2,
        "rect_w": L,
        "rect_h": W,
        "pad": pad,
        "rotation": rot,
    }


def resolve_child_area_positions(parent: dict, children: list) -> list:
    """For each child area with dimensions, return a dict augmented with
    `resolved_x` and `resolved_y` (in parent natural-orientation coords, feet)
    representing the child's CENTER position. Uses the saved parent_pos_x/y
    when present; otherwise default-grid layout fits children evenly into
    the parent's rectangle.
    """
    parent_L = float(parent.get("length_ft") or 0)
    parent_W = float(parent.get("width_ft") or 0)
    if parent_L <= 0 or parent_W <= 0 or not children:
        return []

    sized = [c for c in children if (c.get("length_ft") or 0) > 0 and (c.get("width_ft") or 0) > 0]
    n = len(sized)
    if n == 0:
        return []

    cols = max(1, round(math.sqrt(n * parent_L / parent_W)))
    rows = math.ceil(n / cols)
    cell_w = parent_L / cols
    cell_h = parent_W / rows

    out = []
    for i, c in enumerate(sized):
        d = dict(c)
        if d.get("parent_pos_x") is not None and d.get("parent_pos_y") is not None:
            d["resolved_x"] = float(d["parent_pos_x"])
            d["resolved_y"] = float(d["parent_pos_y"])
        else:
            col = i % cols
            row = i // cols
            d["resolved_x"] = (col + 0.5) * cell_w
            d["resolved_y"] = (row + 0.5) * cell_h
        out.append(d)
    return out


def resolve_plant_positions(plant: dict, area_length_ft: float, area_width_ft: float) -> list:
    """Return positions (x, y, plants_in_icon, sqft_per_icon) for each visible
    icon. Icon count = ceil(quantity / plants_per_unit) so multi-plant species
    (carrots, radish, white clover) collapse into fewer, smaller icons.

    Saved positions are honored when their length matches the icon count.
    Otherwise we generate a default grid sized to the icon footprint.
    """
    try:
        qty = max(1, int((plant.get("quantity") or "1").strip()))
    except (TypeError, ValueError):
        qty = 1

    ppu = max(1, int(plant.get("species_plants_per_unit") or 1))
    sqft = float(plant.get("species_space_per_unit_sqft") or 1.0)
    if sqft <= 0:
        sqft = 1.0

    icon_count = max(1, math.ceil(qty / ppu))
    side = math.sqrt(sqft)  # icon footprint side length in ft

    # Each icon represents `ppu` plants except possibly the last (partial).
    icon_plants = [ppu] * icon_count
    leftover = qty - ppu * (icon_count - 1)
    if leftover > 0:
        icon_plants[-1] = leftover

    raw = plant.get("positions") or ""
    saved = []
    if raw:
        try:
            data = json.loads(raw)
            for p in data:
                if isinstance(p, dict) and "x" in p and "y" in p:
                    saved.append((float(p["x"]), float(p["y"])))
        except (ValueError, TypeError):
            saved = []

    out = []
    if len(saved) >= icon_count:
        for i in range(icon_count):
            out.append((saved[i][0], saved[i][1], icon_plants[i], sqft))
        return out

    L = max(side, float(area_length_ft or 1))
    W = max(side, float(area_width_ft or 1))
    # Grid cells sized to the icon's footprint, then scaled to fit the area.
    cols = max(1, min(icon_count, math.floor(L / side) or 1))
    rows = math.ceil(icon_count / cols)
    col_w = L / cols
    row_h = W / max(rows, 1)
    for i in range(icon_count):
        if i < len(saved):
            x, y = saved[i]
        else:
            col = i % cols
            row = i // cols
            x = (col + 0.5) * col_w
            y = (row + 0.5) * row_h
        out.append((x, y, icon_plants[i], sqft))
    return out


def plant_positions_strict(plant: dict) -> list:
    """v1 ``_plant_positions_strict``: ONLY the placed positions (positions
    JSON as-is), no auto-fill. Layout mode treats the positions array as the
    source of truth; anything beyond `quantity - len(positions)` lives in the
    inventory tray."""
    raw = plant.get("positions") or ""
    if not raw:
        return []
    try:
        data = json.loads(raw)
    except (ValueError, TypeError):
        return []
    out = []
    for p in data:
        if isinstance(p, dict) and "x" in p and "y" in p:
            try:
                out.append({"x": float(p["x"]), "y": float(p["y"])})
            except (TypeError, ValueError):
                continue
    return out


def species_icon_color(name) -> str:
    """v1 ``species_icon_color``: stable HSL color from a species name, used
    to tint plant icons so species are visually distinguishable."""
    s = (name or "").strip()
    if not s:
        return "hsl(0, 0%, 70%)"
    digest = hashlib.md5(s.lower().encode("utf-8")).hexdigest()
    hue = int(digest[:4], 16) % 360
    return f"hsl({hue}, 55%, 50%)"


# ---------------------------------------------------------------------------
# Icon-schedule helpers (shared by tiles + placed icons; verbatim v1 math)
# ---------------------------------------------------------------------------

def _icon_schedule(plant: dict) -> tuple[int, int, float, float, list[int]]:
    """(qty, ppu, sqft, side, icon_plants_each) — the per-icon plant counts,
    with a partial last icon. v1 repeats this math inline in three places."""
    try:
        qty = max(1, int((plant.get("quantity") or "1").strip()))
    except (TypeError, ValueError):
        qty = 1
    ppu = max(1, int(plant.get("species_plants_per_unit") or 1))
    sqft = float(plant.get("species_space_per_unit_sqft") or 1.0)
    side = math.sqrt(max(sqft, 0.05))
    icon_count_total = max(1, math.ceil(qty / ppu))
    icon_plants_each = [ppu] * icon_count_total
    leftover = qty - ppu * (icon_count_total - 1)
    if leftover > 0:
        icon_plants_each[-1] = leftover
    return qty, ppu, sqft, side, icon_plants_each


def _build_pool_tile(p: dict, pool_status: str) -> dict:
    """v1 ``_build_pool_tile``: tray tile for the global Unplanted/Idea pools.
    Queue is the FULL icon schedule (none placed in this area yet)."""
    q, ppu, sqft, side, icon_plants_each = _icon_schedule(p)
    return {
        "plant_id": p["id"],
        "species_name": p.get("species_name") or "?",
        "variety_name": p.get("variety_name") or "",
        "species_id": p.get("species_id"),
        "variety_id": p.get("variety_id"),
        "status": pool_status,
        "source_area_name": p.get("source_area_name") or "",
        "qty_total": q,
        "ppu": ppu,
        "sqft": sqft,
        "side": side,
        "count": sum(icon_plants_each),
        "inv_plants_each": icon_plants_each,
        "color": species_icon_color(p.get("species_name") or ""),
    }


# ---------------------------------------------------------------------------
# Layout-mode payload (v1 GET /areas/{id}/layout)
# ---------------------------------------------------------------------------

def get_layout_payload(db: Session, garden_id: int, area_id: int) -> dict:
    """Full-viewport layout editor payload: placed icons (strict positions,
    no auto-fill), per-plant-group inventory tiles, and the global
    Unplanted/Idea pool tiles. The rect renders axis-aligned (rotation 0,
    pad_ratio 0.02); ``compass_rot`` tells the client where north actually is.
    """
    area_rows = areas_svc.load_areas(db, garden_id, include_archived=True)
    _, by_id = areas_svc.build_tree(area_rows)
    area = by_id.get(area_id)
    if not area:
        raise AppError(AREA_NOT_FOUND, "area not found", 404)
    L = float(area.get("length_ft") or 0)
    W = float(area.get("width_ft") or 0)
    if L <= 0 or W <= 0:
        raise AppError(
            AREA_NO_DIMENSIONS,
            "Area must have dimensions to use layout mode.",
            400,
        )

    sketch_plants = areas_svc.load_plants_by_area(
        db, garden_id, plant_statuses=None
    ).get(area_id, [])

    # Global pools — unplanted / idea plants NOT currently attached to THIS
    # area. (Plants of those statuses that ARE attached to this area already
    # show up in sketch_plants -> Inventory tab.)
    source_area = aliased(Area)
    pool_stmt = (
        select(
            Plant,
            Species.name.label("species_name"),
            Variety.name.label("variety_name"),
            func.coalesce(
                Variety.plants_per_unit, Species.plants_per_unit, 1
            ).label("species_plants_per_unit"),
            func.coalesce(
                Variety.space_per_unit_sqft, Species.space_per_unit_sqft, 1.0
            ).label("species_space_per_unit_sqft"),
            source_area.name.label("source_area_name"),
        )
        .select_from(Plant)
        .outerjoin(Species, Species.id == Plant.species_id)
        .outerjoin(Variety, Variety.id == Plant.variety_id)
        .outerjoin(source_area, source_area.id == Plant.area_id)
        .where(
            Plant.garden_id == garden_id,
            Plant.is_archived == sa.false(),
            Plant.status.in_(("unplanted", "idea")),
            sa.or_(Plant.area_id.is_(None), Plant.area_id != area_id),
        )
        .order_by(
            Plant.status, func.coalesce(Variety.name, Species.name, ""), Plant.id
        )
    )
    pool_rows = []
    for plant, sp_name, v_name, ppu, spu, src_name in db.execute(pool_stmt):
        d = areas_svc._row_dict(plant)
        d["species_name"] = sp_name
        d["variety_name"] = v_name
        d["species_plants_per_unit"] = ppu
        d["species_space_per_unit_sqft"] = spu
        d["source_area_name"] = src_name
        pool_rows.append(d)
    unplanted_pool = [r for r in pool_rows if r["status"] == "unplanted"]
    idea_pool = [r for r in pool_rows if r["status"] == "idea"]

    # Build placed list + inventory. Each plant_group is split into "icons"
    # of up to ppu plants each. For qty=15, ppu=4: 4 icons sized [4, 4, 4, 3]
    # (partial last). The inventory tile displays *plants* not icons.
    placed_icons = []     # one entry per canvas icon
    inventory_tiles = []  # one per plant_group (always emitted, even if empty)
    for p in sketch_plants:
        qty, ppu, sqft, side, icon_plants_each = _icon_schedule(p)
        positions = plant_positions_strict(p)
        icon_count_total = len(icon_plants_each)

        # Plant physical state — 'planted' solid, 'unplanted' dashed,
        # 'idea' dotted (client styling).
        pstatus = (p.get("status") or "planted")
        placed_n = min(len(positions), icon_count_total)
        color = species_icon_color(p.get("species_name") or "")
        for i, pos in enumerate(positions[:icon_count_total]):
            placed_icons.append({
                "plant_id": p["id"],
                "icon_index": i,
                "x": pos["x"],
                "y": pos["y"],
                "side": side,
                "sqft": sqft,
                "plant_count": icon_plants_each[i],
                "species_name": p.get("species_name") or "",
                "variety_name": p.get("variety_name") or "",
                "species_id": p.get("species_id"),
                "variety_id": p.get("variety_id"),
                "status": pstatus,
                "color": color,
            })

        # Inventory queue: the unplaced icons (FIFO).
        inv_plants_each = icon_plants_each[placed_n:]
        inventory_tiles.append({
            "plant_id": p["id"],
            "species_name": p.get("species_name") or "?",
            "variety_name": p.get("variety_name") or "",
            "species_id": p.get("species_id"),
            "variety_id": p.get("variety_id"),
            "status": pstatus,
            "source_area_name": "",
            "qty_total": qty,
            "ppu": ppu,
            "sqft": sqft,
            "side": side,
            "count": sum(inv_plants_each),        # in PLANTS (for display)
            "inv_plants_each": inv_plants_each,   # queue of plant_counts
            "color": color,
        })

    unplanted_pool_tiles = [_build_pool_tile(p, "unplanted") for p in unplanted_pool]
    idea_pool_tiles = [_build_pool_tile(p, "idea") for p in idea_pool]

    # Layout mode renders the rect axis-aligned (NOT compass-rotated); the
    # compass overlay tells the user where N actually is.
    compass_rot = (area.get("sketch_rotation") or 0) % 360
    g = sketch_geometry(L, W, 0, pad_ratio=0.02)

    return {
        "area": {
            "id": area["id"],
            "name": area["name"],
            "short_name": (area.get("short_name") or "").strip(),
            "length_ft": L,
            "width_ft": W,
            "sketch_rotation": area.get("sketch_rotation") or 0,
        },
        "L": L,
        "W": W,
        "compass_rot": compass_rot,
        "g": g,
        "placed_icons": placed_icons,
        "inventory_tiles": inventory_tiles,
        "unplanted_pool_tiles": unplanted_pool_tiles,
        "idea_pool_tiles": idea_pool_tiles,
    }


# ---------------------------------------------------------------------------
# Position save (v1 POST /api/plants/{id}/positions)
# ---------------------------------------------------------------------------

def save_plant_positions(
    db: Session, garden_id: int, plant_id: int, raw_positions: list
) -> dict:
    """Save the per-plant icon positions from the sketch/layout UIs.

    Positions are {x, y} in feet, rect-local (0..length_ft x 0..width_ft).
    Invalid entries are skipped (v1 tolerance), coordinates clamp to the
    area's natural bounds, and the list is capped at the group's quantity.
    Not audit-logged (v1 parity: repositioning is too noisy)."""
    cleaned = []
    for p in raw_positions:
        if not isinstance(p, dict):
            continue
        try:
            x = float(p.get("x"))
            y = float(p.get("y"))
        except (TypeError, ValueError):
            continue
        cleaned.append({"x": round(x, 3), "y": round(y, 3)})

    row = db.execute(
        select(Plant.id, Plant.quantity, Area.length_ft, Area.width_ft)
        .select_from(Plant)
        .outerjoin(Area, Area.id == Plant.area_id)
        .where(Plant.id == plant_id, Plant.garden_id == garden_id)
    ).first()
    if row is None:
        raise AppError(PLANT_NOT_FOUND, "plant not found", 404)

    # Trim/clamp to the area's natural bounds + the group's quantity.
    try:
        qty = max(1, int((row.quantity or "1").strip()))
    except (TypeError, ValueError):
        qty = 1
    L = float(row.length_ft or 0) or 1.0
    W = float(row.width_ft or 0) or 1.0
    for p in cleaned:
        p["x"] = max(0.0, min(L, p["x"]))
        p["y"] = max(0.0, min(W, p["y"]))
    cleaned = cleaned[:qty]
    db.execute(
        sa_update(Plant)
        .where(Plant.id == plant_id, Plant.garden_id == garden_id)
        .values(positions=json.dumps(cleaned))
    )
    db.commit()
    return {"ok": True, "id": plant_id, "saved": len(cleaned)}
