"""Catalog domain service: species + varieties.

Port of v1 behavior (main.py):

- SPECIES_TYPES / SPECIES_FUNCTION_OPTIONS / normalize_species_kind (~84-106)
- _parse_spacing / _parse_optional_spacing (~6303-6315, ~6491-6507)
- species routes (~6265-6452), variety routes (~6456-6642)
- inline create APIs POST /api/species (+dedupe) / POST /api/varieties
  (~7502-7543)

Semantics kept from v1 (documented, on purpose):

- Spacing parsing is FORGIVING, not rejecting: empty input means "use the
  default" (species) or "inherit from species" (variety NULL override);
  invalid input falls back the same way. Values are clamped to >= 1 plant
  per unit and >= 0.05 sqft.
- ``POST /species/{id}/spacing`` always writes BOTH fields, with hard
  defaults (1, 1.0) for blank input — exactly the v1 inline-edit endpoint.
- Species create dedupes by exact name (v1 /api/species): posting an
  existing name returns the existing row instead of erroring. Varieties
  dedupe by exact (species_id, name) per the v2 port spec.
- Spacing resolution is COALESCE(variety, species, default) — the variety
  payload carries the effective values so clients never re-implement it.
- New species with a name in SPECIES_SPACING_DEFAULTS get the registry
  spacing when the caller supplies none (v2 stand-in for v1's startup seed).

Every mutation writes activity_log via services/audit.py and commits.
"""
from __future__ import annotations

from datetime import datetime, timezone

import sqlalchemy as sa
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from ..errors import AppError, VALIDATION_FAILED
from ..models.catalog import Species, Variety
from ..models.garden import Year
from ..models.planting import Plant, Planting
from . import audit
from .overviews import snapshot_overview
from .species_icons import get_icon_descriptor, spacing_default_for

# --- error codes (domain-owned, per errors.py convention) ---
SPECIES_NOT_FOUND = "catalog_species_not_found"
VARIETY_NOT_FOUND = "catalog_variety_not_found"
NAME_REQUIRED = "catalog_name_required"

# Species classification. Each species is either a plant or an animal, with a
# primary function constrained by the type. Varieties inherit from their
# species. (v1 main.py:85-89, verbatim.)
SPECIES_TYPES = ["plant", "animal"]
SPECIES_FUNCTION_OPTIONS = {
    "plant": ["edible", "ornamental", "support", "weed"],
    "animal": ["beneficial", "pest"],
}

_SPECIES_PATCH_FIELDS = {
    "name", "common_name", "description", "type", "primary_function",
    "plants_per_unit", "space_per_unit_sqft",
}
_VARIETY_PATCH_FIELDS = {
    "name", "description", "species_id", "plants_per_unit",
    "space_per_unit_sqft",
}


def _now() -> datetime:
    return datetime.now(timezone.utc)


# --- kind + spacing parsing (v1-verbatim semantics) -----------------------

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


def normalize_species_kind(type_val, fn_val) -> tuple[str, str]:
    """Coerce an arbitrary (type, primary_function) pair to a valid combo.
    Falls back to (plant, edible) if the type is unknown; otherwise picks the
    first valid function for the type. (v1 main.py:96-106.)"""
    type_val = (type_val or "").strip().lower()
    if type_val not in SPECIES_FUNCTION_OPTIONS:
        type_val = "plant"
    fn_val = (fn_val or "").strip().lower()
    if not species_function_valid(type_val, fn_val):
        fn_val = SPECIES_FUNCTION_OPTIONS[type_val][0]
    return type_val, fn_val


def _parse_spacing(plants_per_unit, space_per_unit_sqft,
                   default_plants: int = 1, default_space: float = 1.0,
                   ) -> tuple[int, float]:
    """Parse spacing inputs (str/int/float/None). Empty means 'use default';
    invalid input falls back to defaults. Clamped >= (1, 0.05). v1 ~6303."""
    s_p = "" if plants_per_unit is None else str(plants_per_unit).strip()
    s_s = "" if space_per_unit_sqft is None else str(space_per_unit_sqft).strip()
    try:
        ppu = int(s_p) if s_p else default_plants
    except ValueError:
        ppu = default_plants
    try:
        sqft = float(s_s) if s_s else default_space
    except ValueError:
        sqft = default_space
    return max(1, ppu), max(0.05, sqft)


def _parse_optional_spacing(plants_per_unit, space_per_unit_sqft,
                            ) -> tuple[int | None, float | None]:
    """For variety overrides — empty/invalid input means NULL (inherit
    species). Clamped when present. v1 ~6491."""
    ppu: int | None = None
    sqft: float | None = None
    s_p = "" if plants_per_unit is None else str(plants_per_unit).strip()
    s_s = "" if space_per_unit_sqft is None else str(space_per_unit_sqft).strip()
    if s_p:
        try:
            ppu = max(1, int(s_p))
        except ValueError:
            ppu = None
    if s_s:
        try:
            sqft = max(0.05, float(s_s))
        except ValueError:
            sqft = None
    return ppu, sqft


def _require_name(name) -> str:
    name = (name or "").strip()
    if not name:
        raise AppError(NAME_REQUIRED, "name is required", 400)
    return name


# --- payloads --------------------------------------------------------------

def _species_payload(sp: Species) -> dict:
    return {
        "id": sp.id,
        "name": sp.name,
        "common_name": sp.common_name or "",
        "description": sp.description or "",
        "type": sp.type,
        "primary_function": sp.primary_function,
        "plants_per_unit": sp.plants_per_unit,
        "space_per_unit_sqft": sp.space_per_unit_sqft,
        "featured_image_path": sp.featured_image_path or "",
        "is_archived": bool(sp.is_archived),
        "created_at": sp.created_at,
        **get_icon_descriptor(sp.name),
    }


def _variety_payload(v: Variety, sp: Species | None) -> dict:
    # COALESCE(variety, species, default) — v1's effective-spacing rule.
    eff_ppu = v.plants_per_unit
    if eff_ppu is None:
        eff_ppu = sp.plants_per_unit if sp and sp.plants_per_unit is not None else 1
    eff_sqft = v.space_per_unit_sqft
    if eff_sqft is None:
        eff_sqft = (
            sp.space_per_unit_sqft
            if sp and sp.space_per_unit_sqft is not None
            else 1.0
        )
    icon_name = sp.name if sp is not None else v.name
    return {
        "id": v.id,
        "species_id": v.species_id,
        "name": v.name,
        "description": v.description or "",
        "plants_per_unit": v.plants_per_unit,
        "space_per_unit_sqft": v.space_per_unit_sqft,
        "featured_image_path": v.featured_image_path or "",
        "is_archived": bool(v.is_archived),
        "created_at": v.created_at,
        "species_name": sp.name if sp else None,
        "species_type": sp.type if sp else None,
        "species_primary_function": sp.primary_function if sp else None,
        "effective_plants_per_unit": eff_ppu,
        "effective_space_per_unit_sqft": eff_sqft,
        **get_icon_descriptor(icon_name),
    }


# --- row guards ------------------------------------------------------------

def _get_species_row(db: Session, garden_id: int, species_id: int) -> Species:
    sp = db.get(Species, species_id)
    if sp is None or sp.garden_id != garden_id:
        raise AppError(SPECIES_NOT_FOUND, f"species {species_id} not found", 404)
    return sp


def _get_variety_row(
    db: Session, garden_id: int, variety_id: int
) -> tuple[Variety, Species | None]:
    row = db.execute(
        select(Variety, Species)
        .outerjoin(Species, Species.id == Variety.species_id)
        .where(Variety.id == variety_id)
    ).first()
    if row is None:
        raise AppError(VARIETY_NOT_FOUND, f"variety {variety_id} not found", 404)
    v, sp = row
    # Varieties are garden-scoped through their species (models/catalog.py).
    if sp is not None and sp.garden_id != garden_id:
        raise AppError(VARIETY_NOT_FOUND, f"variety {variety_id} not found", 404)
    return v, sp


# --- species ----------------------------------------------------------------

def list_species(
    db: Session,
    garden_id: int,
    include_archived: bool = False,
    type_filter: str = "",
) -> list[dict]:
    """v1 GET /species: name-ordered, optional type filter, per-species
    variety count (archive-filtered like v1) plus plant count."""
    type_filter = (type_filter or "").strip().lower()
    if type_filter not in SPECIES_TYPES:
        type_filter = ""

    stmt = select(Species).where(Species.garden_id == garden_id)
    if not include_archived:
        stmt = stmt.where(Species.is_archived == sa.false())
    if type_filter:
        stmt = stmt.where(Species.type == type_filter)
    rows = db.scalars(stmt.order_by(Species.name)).all()

    vc_stmt = (
        select(Variety.species_id, func.count())
        .join(Species, Species.id == Variety.species_id)
        .where(Species.garden_id == garden_id)
        .group_by(Variety.species_id)
    )
    if not include_archived:
        vc_stmt = vc_stmt.where(Variety.is_archived == sa.false())
    variety_counts = dict(db.execute(vc_stmt).all())

    pc_stmt = (
        select(Plant.species_id, func.count())
        .where(Plant.garden_id == garden_id, Plant.species_id.is_not(None))
        .group_by(Plant.species_id)
    )
    if not include_archived:
        pc_stmt = pc_stmt.where(Plant.is_archived == sa.false())
    plant_counts = dict(db.execute(pc_stmt).all())

    return [
        {
            **_species_payload(sp),
            "variety_count": variety_counts.get(sp.id, 0),
            "plant_count": plant_counts.get(sp.id, 0),
        }
        for sp in rows
    ]


def get_species(
    db: Session, garden_id: int, species_id: int, include_archived: bool = False
) -> dict:
    """v1 GET /species/{id}: the row + its varieties (name-ordered,
    archive-filtered) + plant count + the plantings growing it."""
    sp = _get_species_row(db, garden_id, species_id)

    v_stmt = select(Variety).where(Variety.species_id == species_id)
    if not include_archived:
        v_stmt = v_stmt.where(Variety.is_archived == sa.false())
    varieties = db.scalars(v_stmt.order_by(Variety.name)).all()

    pc_stmt = select(func.count()).where(
        Plant.species_id == species_id, Plant.garden_id == garden_id
    )
    if not include_archived:
        pc_stmt = pc_stmt.where(Plant.is_archived == sa.false())
    plant_count = db.scalar(pc_stmt) or 0

    pl_stmt = (
        select(Planting.id, Planting.name, Planting.year_id, Year.year)
        .join(Plant, Plant.planting_id == Planting.id)
        .outerjoin(Year, Year.id == Planting.year_id)
        .where(Plant.species_id == species_id, Planting.garden_id == garden_id)
        .distinct()
        .order_by(Planting.name)
    )
    if not include_archived:
        pl_stmt = pl_stmt.where(
            Planting.is_archived == sa.false(), Plant.is_archived == sa.false()
        )
    plantings = [
        {"id": r.id, "name": r.name, "year_id": r.year_id, "year": r.year}
        for r in db.execute(pl_stmt).all()
    ]

    return {
        **_species_payload(sp),
        "varieties": [_variety_payload(v, sp) for v in varieties],
        "plant_count": plant_count,
        "plantings": plantings,
    }


def create_species(
    db: Session,
    garden_id: int,
    *,
    name,
    common_name: str = "",
    description: str = "",
    plants_per_unit=None,
    space_per_unit_sqft=None,
    type_: str = "plant",
    primary_function: str = "edible",
) -> dict:
    """Create a species — or return the existing one on an exact name match
    (v1 POST /api/species dedupe). Payload carries ``created``."""
    name = _require_name(name)
    existing = db.scalar(
        select(Species).where(
            Species.garden_id == garden_id, Species.name == name
        )
    )
    if existing is not None:
        return {**_species_payload(existing), "created": False}

    type_v, fn_v = normalize_species_kind(type_, primary_function)
    if plants_per_unit is None and space_per_unit_sqft is None:
        ppu, sqft = spacing_default_for(name) or (1, 1.0)
    else:
        ppu, sqft = _parse_spacing(plants_per_unit, space_per_unit_sqft)

    sp = Species(
        garden_id=garden_id,
        name=name,
        common_name=(common_name or "").strip(),
        description=(description or "").strip(),
        created_at=_now(),
        featured_image_path="",
        plants_per_unit=ppu,
        space_per_unit_sqft=sqft,
        is_archived=False,
        type=type_v,
        primary_function=fn_v,
    )
    db.add(sp)
    db.flush()
    audit.log_add(db, garden_id, "species", sp.id, name)
    db.commit()
    return {**_species_payload(sp), "created": True}


def update_species(
    db: Session, garden_id: int, species_id: int, changes: dict
) -> dict:
    """PATCH semantics over the v1 species form/inline-edit fields. Changing
    ``type`` clamps ``primary_function`` to a valid combo (v1 /api/edit
    side-effect); description edits snapshot the previous overview."""
    unknown = set(changes) - _SPECIES_PATCH_FIELDS
    if unknown:
        raise AppError(
            VALIDATION_FAILED, f"unknown fields: {sorted(unknown)}", 400
        )
    sp = _get_species_row(db, garden_id, species_id)

    normalized: dict = {}
    if "name" in changes:
        normalized["name"] = _require_name(changes["name"])
    if "common_name" in changes:
        normalized["common_name"] = (changes["common_name"] or "").strip()
    if "description" in changes:
        new_desc = (changes["description"] or "").strip()
        snapshot_overview(db, "species", sp.id, sp.description, new_desc)
        normalized["description"] = new_desc
    if "type" in changes or "primary_function" in changes:
        t, f = normalize_species_kind(
            changes.get("type", sp.type),
            changes.get("primary_function", sp.primary_function),
        )
        normalized["type"] = t
        normalized["primary_function"] = f
    if "plants_per_unit" in changes:
        normalized["plants_per_unit"] = _parse_spacing(
            changes["plants_per_unit"], None,
            default_plants=sp.plants_per_unit or 1,
        )[0]
    if "space_per_unit_sqft" in changes:
        normalized["space_per_unit_sqft"] = _parse_spacing(
            None, changes["space_per_unit_sqft"],
            default_space=sp.space_per_unit_sqft or 1.0,
        )[1]

    label = sp.name
    for field, new in normalized.items():
        old = getattr(sp, field)
        if old != new:
            setattr(sp, field, new)
            audit.log_edit(db, garden_id, "species", sp.id, field, old, new,
                           label=label)
    db.commit()
    return _species_payload(sp)


def update_species_spacing(
    db: Session, garden_id: int, species_id: int,
    plants_per_unit=None, space_per_unit_sqft=None,
) -> dict:
    """v1 POST /species/{id}/spacing — updates ONLY the spacing fields;
    blank input resets to the hard defaults (1, 1.0)."""
    sp = _get_species_row(db, garden_id, species_id)
    ppu, sqft = _parse_spacing(plants_per_unit, space_per_unit_sqft)
    for field, new in (("plants_per_unit", ppu), ("space_per_unit_sqft", sqft)):
        old = getattr(sp, field)
        if old != new:
            setattr(sp, field, new)
            audit.log_edit(db, garden_id, "species", sp.id, field, old, new,
                           label=sp.name)
    db.commit()
    return _species_payload(sp)


# --- varieties ---------------------------------------------------------------

def list_varieties(
    db: Session,
    garden_id: int,
    include_archived: bool = False,
    species_id: int | None = None,
) -> list[dict]:
    """v1 GET /varieties: name-ordered, LEFT JOIN species for the inherited
    name/type/function columns. Orphan varieties (NULL species) are included
    — they carry no garden scope of their own (single-garden today)."""
    stmt = (
        select(Variety, Species)
        .outerjoin(Species, Species.id == Variety.species_id)
        .where(
            sa.or_(
                Species.garden_id == garden_id,
                Variety.species_id.is_(None),
            )
        )
    )
    if species_id is not None:
        stmt = stmt.where(Variety.species_id == species_id)
    if not include_archived:
        stmt = stmt.where(Variety.is_archived == sa.false())
    rows = db.execute(stmt.order_by(Variety.name)).all()
    return [_variety_payload(v, sp) for v, sp in rows]


def get_variety(
    db: Session, garden_id: int, variety_id: int, include_archived: bool = False
) -> dict:
    """v1 GET /varieties/{id}: the row + species join fields + the species'
    spacing defaults + plant count."""
    v, sp = _get_variety_row(db, garden_id, variety_id)

    pc_stmt = select(func.count()).where(
        Plant.variety_id == variety_id, Plant.garden_id == garden_id
    )
    if not include_archived:
        pc_stmt = pc_stmt.where(Plant.is_archived == sa.false())
    plant_count = db.scalar(pc_stmt) or 0

    species_default = None
    if sp is not None and (
        sp.plants_per_unit is not None or sp.space_per_unit_sqft is not None
    ):
        species_default = {
            "plants_per_unit": sp.plants_per_unit or 1,
            "space_per_unit_sqft": sp.space_per_unit_sqft or 1.0,
        }
    return {
        **_variety_payload(v, sp),
        "plant_count": plant_count,
        "species_default": species_default,
    }


def create_variety(
    db: Session,
    garden_id: int,
    *,
    name,
    species_id: int | None = None,
    description: str = "",
    plants_per_unit=None,
    space_per_unit_sqft=None,
) -> dict:
    """Create a variety. Guards the species reference (must exist in this
    garden); dedupes by exact (species_id, name); empty spacing = NULL
    override (inherit species). Payload carries ``created``."""
    name = _require_name(name)
    sp: Species | None = None
    if species_id is not None:
        sp = _get_species_row(db, garden_id, species_id)

    dedupe = select(Variety).where(Variety.name == name)
    dedupe = dedupe.where(
        Variety.species_id.is_(None) if species_id is None
        else Variety.species_id == species_id
    )
    existing = db.scalar(dedupe)
    if existing is not None:
        return {**_variety_payload(existing, sp), "created": False}

    ppu, sqft = _parse_optional_spacing(plants_per_unit, space_per_unit_sqft)
    v = Variety(
        species_id=species_id,
        name=name,
        description=(description or "").strip(),
        created_at=_now(),
        featured_image_path="",
        plants_per_unit=ppu,
        space_per_unit_sqft=sqft,
        is_archived=False,
    )
    db.add(v)
    db.flush()
    audit.log_add(db, garden_id, "variety", v.id, name)
    db.commit()
    return {**_variety_payload(v, sp), "created": True}


def update_variety(
    db: Session, garden_id: int, variety_id: int, changes: dict
) -> dict:
    """PATCH semantics over the v1 variety form fields. ``species_id`` is
    guarded (must exist in this garden; null detaches — v1 fk nullable);
    description edits snapshot; spacing keeps NULL-inherit semantics."""
    unknown = set(changes) - _VARIETY_PATCH_FIELDS
    if unknown:
        raise AppError(
            VALIDATION_FAILED, f"unknown fields: {sorted(unknown)}", 400
        )
    v, sp = _get_variety_row(db, garden_id, variety_id)

    normalized: dict = {}
    if "name" in changes:
        normalized["name"] = _require_name(changes["name"])
    if "description" in changes:
        new_desc = (changes["description"] or "").strip()
        snapshot_overview(db, "variety", v.id, v.description, new_desc)
        normalized["description"] = new_desc
    if "species_id" in changes:
        new_sid = changes["species_id"]
        if new_sid is not None:
            sp = _get_species_row(db, garden_id, int(new_sid))
            new_sid = sp.id
        else:
            sp = None
        normalized["species_id"] = new_sid
    if "plants_per_unit" in changes:
        normalized["plants_per_unit"] = _parse_optional_spacing(
            changes["plants_per_unit"], None
        )[0]
    if "space_per_unit_sqft" in changes:
        normalized["space_per_unit_sqft"] = _parse_optional_spacing(
            None, changes["space_per_unit_sqft"]
        )[1]

    label = v.name
    for field, new in normalized.items():
        old = getattr(v, field)
        if old != new:
            setattr(v, field, new)
            audit.log_edit(db, garden_id, "variety", v.id, field, old, new,
                           label=label)
    db.commit()
    return _variety_payload(v, sp)


def update_variety_spacing(
    db: Session, garden_id: int, variety_id: int,
    plants_per_unit=None, space_per_unit_sqft=None,
) -> dict:
    """v1 POST /varieties/{id}/spacing — updates ONLY the override; empty
    inputs mean 'inherit from species' (NULL)."""
    v, sp = _get_variety_row(db, garden_id, variety_id)
    ppu, sqft = _parse_optional_spacing(plants_per_unit, space_per_unit_sqft)
    for field, new in (("plants_per_unit", ppu), ("space_per_unit_sqft", sqft)):
        old = getattr(v, field)
        if old != new:
            setattr(v, field, new)
            audit.log_edit(db, garden_id, "variety", v.id, field, old, new,
                           label=v.name)
    db.commit()
    return _variety_payload(v, sp)
