"""Inline click-to-edit domain — port of v1's EDITABLE_FIELDS system.

v1 source: /srv/apps/garden/app/main.py — ``EDITABLE_FIELDS`` +
``EDITABLE_TABLES`` (≈122-178), the ``POST /api/edit/{entity}/{id}`` handler
(≈7546-7685), ``SPECIES_FUNCTION_OPTIONS`` / ``species_function_valid``
(≈83-106), and ``OVERVIEW_ENTITIES`` / ``snapshot_overview`` (≈2073-2119).

Whitelists carried EXACTLY as v1 (including ``planting.status`` kept as the
single-value select ["planted"] — status is retired in the unplanted-plants
model but the endpoint shape is preserved).

Field spec vocabulary (unchanged from v1):
  "text"     plain string (strip; ``required`` supported)
  "textarea" multi-line string; ``snapshot: True`` records the previous
             value into overview_versions before the change
  "int"      integer with optional min/max
  "float"    floating point with optional min ("" coerces to 0.0 — the
             historical default for numeric area columns)
  "select"   one of ``options`` (loose string compare so int options match
             their string form, e.g. sketch rotation)
  "fk"       id into another table (``fk_table``; ``nullable`` allows
             None/""/0/"0" to clear); row existence validated, garden-scoped

v2 deviations (documented, deliberate):

- Errors are structured ``AppError``s (codes below) instead of bare
  HTTPExceptions.
- Garden scoping (D1): the target row and any fk target must belong to the
  caller's garden or the edit 404s/400s.
- **snapshot fix**: v1's inline-edit handler (main.py:7648) passed the
  CURRENT value to ``snapshot_overview``, whose no-change guard then always
  short-circuited — the inline-edit path never actually snapshotted. Every
  other v1 call site passes the NEW value. v2 ports the intent: the previous
  value is snapshotted whenever the new value differs.
- Audit rows go through ``services/audit.py`` (one row per changed field;
  side-effect fields get their own row labeled "(auto)"). Sketch
  repositioning stays unlogged (not an editable field here anyway).

``snapshot_overview`` here keeps v1's call shape (reads the CURRENT value
from the DB via ``OVERVIEW_ENTITIES``, caller passes only the new content)
and delegates the actual overview_versions insert to
``services/overviews.py`` so there is exactly one writer of snapshot rows.
"""
from __future__ import annotations

from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session

from ..errors import AppError, NOT_FOUND
from ..models.catalog import Species, Variety
from ..models.garden import Area, WateringStation, Year
from ..models.planting import Plant, Planting
from .archive import entity_label
from .audit import log_edit
from .overviews import snapshot_overview as _write_snapshot

# --- error codes (this domain) ---
EDIT_ENTITY_UNKNOWN = "edit_entity_unknown"
EDIT_FIELD_NOT_ALLOWED = "edit_field_not_allowed"
EDIT_VALUE_INVALID = "edit_value_invalid"

# Species classification (v1 ≈83-106). Each species is a plant or an animal,
# with a primary function constrained by the type.
SPECIES_TYPES = ["plant", "animal"]
SPECIES_FUNCTION_OPTIONS = {
    "plant": ["edible", "ornamental", "support", "weed"],
    "animal": ["beneficial", "pest"],
}


def species_function_valid(type_val: str, fn_val: str) -> bool:
    return fn_val in SPECIES_FUNCTION_OPTIONS.get(type_val, [])


# Inline click-to-edit whitelists — EXACT port of v1 EDITABLE_FIELDS.
EDITABLE_FIELDS: dict[str, dict[str, dict]] = {
    "area": {
        "name":               {"type": "text", "required": True},
        "short_name":         {"type": "text"},
        "length_ft":          {"type": "float", "min": 0},
        "width_ft":           {"type": "float", "min": 0},
        "garden_area_sqft":   {"type": "float", "min": 0},
        "structure_features": {"type": "textarea"},
        "sunlight":           {"type": "textarea"},
        "soil_environment":   {"type": "textarea"},
        "notes":              {"type": "textarea", "snapshot": True},
        "sketch_rotation":    {"type": "select",
                               "options": [0, 45, 90, 135, 180, 225, 270, 315]},
    },
    "planting": {
        "name":   {"type": "text", "required": True},
        "source": {"type": "text"},
        # Status retired in the unplanted-plants model — kept as a
        # single-value select to preserve the endpoint shape; no UI exposes it.
        "status": {"type": "select",
                   "options": ["planted"]},
        "notes":  {"type": "textarea", "snapshot": True},
        "year_id": {"type": "fk", "fk_table": "years", "nullable": True},
    },
    "year": {
        "year":  {"type": "int", "required": True, "min": 1900, "max": 2100},
        "notes": {"type": "textarea", "snapshot": True},
    },
    "species": {
        "name":             {"type": "text", "required": True},
        "common_name":      {"type": "text"},
        "description":      {"type": "textarea", "snapshot": True},
        "type":             {"type": "select", "options": ["plant", "animal"]},
        "primary_function": {"type": "select", "options": [
            "edible", "ornamental", "support", "weed", "beneficial", "pest"]},
    },
    "variety": {
        "name":        {"type": "text", "required": True},
        "description": {"type": "textarea", "snapshot": True},
        "species_id":  {"type": "fk", "fk_table": "species", "nullable": True},
    },
    "station": {
        "name":             {"type": "text", "required": True},
        "notes":            {"type": "textarea"},
        "current_schedule": {"type": "textarea"},
    },
}

# entity_type -> model (v1 EDITABLE_TABLES mapped to table names; v2 maps to
# the SQLAlchemy models).
EDITABLE_MODELS = {
    "area": Area,
    "planting": Planting,
    "year": Year,
    "species": Species,
    "variety": Variety,
    "station": WateringStation,
}

# fk_table spec strings (kept verbatim from v1) -> model.
_FK_MODELS = {
    "years": Year,
    "species": Species,
}

# Entity types that have an overview (notes/description column) with version
# history — v1 OVERVIEW_ENTITIES, mapped to (model, column attribute name).
OVERVIEW_ENTITIES = {
    "area": (Area, "notes"),
    "planting": (Planting, "notes"),
    "plant": (Plant, "notes"),
    "species": (Species, "description"),
    "variety": (Variety, "description"),
    "year": (Year, "notes"),
}


def snapshot_overview(
    db: Session,
    entity_type: str,
    entity_id: int,
    new_content: str,
    author: str = "user",
    change_note: str = "",
) -> None:
    """If the current overview differs from ``new_content``, snapshot the
    CURRENT value into overview_versions before the caller writes the new
    content. Idempotent: returns silently when the entity type is unknown,
    the row is missing, or the content is unchanged. (Port of v1 ≈2096-2119.
    The insert itself is delegated to services/overviews.py — one writer.)"""
    spec = OVERVIEW_ENTITIES.get(entity_type)
    if spec is None:
        return
    model, col_name = spec
    row = db.execute(
        select(getattr(model, col_name)).where(model.id == entity_id)
    ).first()
    if row is None:
        return  # entity not found
    _write_snapshot(
        db, entity_type, entity_id,
        current_content=row[0],
        new_content=new_content,
        author=author,
        change_note=change_note,
    )


def coerce_value(field: str, spec: dict, raw: Any):
    """Type coercion + constraint validation. Returns the value to STORE.
    Raises AppError(EDIT_VALUE_INVALID) on any violation. Exact port of the
    ``coerce`` closure in v1's edit handler (≈7574-7623)."""
    ftype = spec["type"]
    if ftype in ("text", "textarea"):
        v = "" if raw is None else str(raw).strip()
        if spec.get("required") and not v:
            raise AppError(EDIT_VALUE_INVALID, f"{field} is required", 400)
        return v
    if ftype == "int":
        if raw in (None, ""):
            if spec.get("required"):
                raise AppError(EDIT_VALUE_INVALID, f"{field} is required", 400)
            return None
        try:
            v = int(raw)
        except (TypeError, ValueError):
            raise AppError(
                EDIT_VALUE_INVALID, f"{field} must be an integer", 400
            )
        if "min" in spec and v < spec["min"]:
            raise AppError(
                EDIT_VALUE_INVALID, f"{field} must be ≥ {spec['min']}", 400
            )
        if "max" in spec and v > spec["max"]:
            raise AppError(
                EDIT_VALUE_INVALID, f"{field} must be ≤ {spec['max']}", 400
            )
        return v
    if ftype == "float":
        if raw in (None, ""):
            return 0.0  # historical default for numeric area columns
        try:
            v = float(raw)
        except (TypeError, ValueError):
            raise AppError(EDIT_VALUE_INVALID, f"{field} must be a number", 400)
        if "min" in spec and v < spec["min"]:
            raise AppError(
                EDIT_VALUE_INVALID, f"{field} must be ≥ {spec['min']}", 400
            )
        return v
    if ftype == "select":
        allowed = spec["options"]
        # Compare loosely: ints compare to int strings (rotation is int).
        sval = str(raw).strip() if raw is not None else ""
        for opt in allowed:
            if str(opt) == sval:
                return opt
        raise AppError(
            EDIT_VALUE_INVALID, f"{field} must be one of {allowed}", 400
        )
    if ftype == "fk":
        if raw in (None, "", 0, "0"):
            if not spec.get("nullable", False):
                raise AppError(EDIT_VALUE_INVALID, f"{field} is required", 400)
            return None
        try:
            return int(raw)
        except (TypeError, ValueError):
            raise AppError(
                EDIT_VALUE_INVALID, f"{field} must be an integer id", 400
            )
    # Unreachable with the whitelists above; guards future spec typos.
    raise AppError(EDIT_VALUE_INVALID, f"unknown field type {ftype}", 500)


def _get_scoped(db: Session, entity_type: str, entity_id: int, garden_id: int):
    """Fetch the target ORM row, enforcing garden scope (D1). Varieties scope
    through their species (NULL species link = in scope, single-garden
    reality). Raises AppError(NOT_FOUND) when missing or out of scope."""
    model = EDITABLE_MODELS[entity_type]
    row = db.get(model, entity_id)
    if row is None:
        raise AppError(NOT_FOUND, f"{entity_type} #{entity_id} not found", 404)
    if entity_type == "variety":
        if row.species_id is not None:
            sp_gid = db.scalar(
                select(Species.garden_id).where(Species.id == row.species_id)
            )
            if sp_gid is not None and sp_gid != garden_id:
                raise AppError(
                    NOT_FOUND, f"{entity_type} #{entity_id} not found", 404
                )
        return row
    if getattr(row, "garden_id", garden_id) != garden_id:
        raise AppError(NOT_FOUND, f"{entity_type} #{entity_id} not found", 404)
    return row


def edit_field(
    db: Session,
    garden_id: int,
    entity_type: str,
    entity_id: int,
    field: str,
    raw_value: Any,
) -> dict:
    """Apply one whitelisted per-field edit. Returns
    ``{"ok": True, "value": <stored>, "side_effects": {...}}``.

    Validates the (entity_type, field) whitelist, coerces + validates the
    value, checks fk row existence (garden-scoped), snapshots the previous
    overview for ``snapshot: True`` fields, applies the update, enforces the
    species (type, primary_function) combo, and writes one activity-log row
    per changed field.
    """
    if entity_type not in EDITABLE_FIELDS:
        raise AppError(
            EDIT_ENTITY_UNKNOWN, f"unknown entity type {entity_type!r}", 404
        )
    fields = EDITABLE_FIELDS[entity_type]
    if field not in fields:
        raise AppError(
            EDIT_FIELD_NOT_ALLOWED, f"field {field!r} is not editable", 400
        )
    spec = fields[field]
    new_value = coerce_value(field, spec, raw_value)

    row = _get_scoped(db, entity_type, entity_id, garden_id)
    prev_value = getattr(row, field, None)
    ent_label = entity_label(db, entity_type, entity_id)

    # FK existence check, garden-scoped (years + species both carry garden_id).
    if spec["type"] == "fk" and new_value is not None:
        fk_model = _FK_MODELS[spec["fk_table"]]
        fk_ok = db.scalar(
            select(fk_model.id).where(
                fk_model.id == new_value, fk_model.garden_id == garden_id
            )
        )
        if fk_ok is None:
            raise AppError(
                EDIT_VALUE_INVALID,
                f"{field} references missing {spec['fk_table']} id",
                400,
            )

    # Snapshot the previous overview/notes value before the change.
    # (v1's inline-edit path passed the CURRENT value here, defeating the
    # no-change guard — fixed to pass the NEW value; see module docstring.)
    if spec.get("snapshot"):
        snapshot_overview(db, entity_type, entity_id, new_value or "")

    # Species (type, primary_function) combo enforcement — BEFORE the write
    # so an invalid function never lands (v1 ordered these after its UPDATE;
    # same visible outcome since its rejection aborted the transaction).
    side_effects: dict = {}
    if entity_type == "species" and field == "primary_function":
        # Defense-in-depth: the client filters options at activate time.
        cur_type = (row.type or "plant").strip().lower()
        if not species_function_valid(cur_type, str(new_value)):
            raise AppError(
                EDIT_VALUE_INVALID,
                f"{new_value} is not a valid function for type {cur_type}",
                400,
            )

    setattr(row, field, new_value)

    prev_side_effects: dict = {}
    if entity_type == "species" and field == "type":
        # If the user just changed `type`, clamp `primary_function` to the
        # first valid option when the existing one is incompatible. Returned
        # as a side_effect so the client can update the display + dropdown
        # without a page reload (mirrors v1 normalize_species_kind).
        prev_fn = row.primary_function
        cur_fn = (prev_fn or "edible").strip().lower()
        valid = SPECIES_FUNCTION_OPTIONS.get(new_value, [])
        if cur_fn not in valid and valid:
            row.primary_function = valid[0]
            side_effects["primary_function"] = valid[0]
            prev_side_effects["primary_function"] = prev_fn

    db.flush()

    # Activity log: one row per primary edit; side-effects get their own row
    # so cascades are visible in the detailed view.
    log_edit(
        db, garden_id, entity_type, entity_id, field,
        prev_value, new_value, label=ent_label,
    )
    for se_field, se_value in side_effects.items():
        log_edit(
            db, garden_id, entity_type, entity_id, se_field,
            prev_side_effects.get(se_field), se_value,
            label=ent_label + " (auto)",
        )

    return {"ok": True, "value": new_value, "side_effects": side_effects}
