"""Inventory domain service: supplies + watering stations (+ area M2M).

Port of v1 behavior (main.py):

- supplies routes (~5988-6092): category+name ordering, string quantity
  fields (kept as Text — v1 wart), ``needed`` shopping-list flag, hard
  delete with an activity-log row.
- station routes (~6097-6244): name ordering, per-station area counts
  (unfiltered M2M count, exactly v1), hard delete (M2M rows CASCADE).
- POST /api/station/{id}/areas (~7688-7739) becomes set_station_areas():
  explicit full replace of the areas-served set — validate EVERY id before
  touching anything (partial updates would surprise the user), then
  delete-all + insert, logging an ``areas_served`` edit only when the
  sorted set actually changed.

Deviations from v1 (documented):

- v1's station create/update inserted area_stations rows without checking
  the areas existed (SQLite tolerated it); v2 Postgres enforces the FK, so
  every path validates ids via the same guard as set_station_areas.
- set_station_areas returns the stored (deduplicated, sorted) id list
  rather than echoing the raw input list back.

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 delete, func, select
from sqlalchemy.orm import Session

from ..errors import AppError, VALIDATION_FAILED
from ..models.garden import Area, AreaStation, Supply, WateringStation
from . import audit

# --- error codes (domain-owned, per errors.py convention) ---
SUPPLY_NOT_FOUND = "inventory_supply_not_found"
STATION_NOT_FOUND = "inventory_station_not_found"
UNKNOWN_AREA_IDS = "inventory_unknown_area_ids"
NAME_REQUIRED = "inventory_name_required"

_SUPPLY_PATCH_FIELDS = {
    "name", "category", "quantity_on_hand", "quantity_needed", "needed",
    "notes",
}
_STATION_PATCH_FIELDS = {"name", "notes", "current_schedule"}


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


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


# --- supplies ----------------------------------------------------------------

def _supply_payload(s: Supply) -> dict:
    return {
        "id": s.id,
        "name": s.name,
        "category": s.category or "",
        "amount": s.amount or "",
        "quantity_on_hand": s.quantity_on_hand or "",
        "quantity_needed": s.quantity_needed or "",
        "needed": bool(s.needed),
        "notes": s.notes or "",
        "featured_image_path": s.featured_image_path or "",
        "is_archived": bool(s.is_archived),
        "created_at": s.created_at,
    }


def _get_supply_row(db: Session, garden_id: int, supply_id: int) -> Supply:
    s = db.get(Supply, supply_id)
    if s is None or s.garden_id != garden_id:
        raise AppError(SUPPLY_NOT_FOUND, f"supply {supply_id} not found", 404)
    return s


def list_supplies(
    db: Session, garden_id: int, include_archived: bool = False
) -> list[dict]:
    """v1 load_supplies: ORDER BY category, name. Grouping-by-category and
    the shopping list (needed=true) are client-side derivations."""
    stmt = select(Supply).where(Supply.garden_id == garden_id)
    if not include_archived:
        stmt = stmt.where(Supply.is_archived == sa.false())
    rows = db.scalars(stmt.order_by(Supply.category, Supply.name)).all()
    return [_supply_payload(s) for s in rows]


def get_supply(db: Session, garden_id: int, supply_id: int) -> dict:
    return _supply_payload(_get_supply_row(db, garden_id, supply_id))


def create_supply(
    db: Session,
    garden_id: int,
    *,
    name,
    category: str = "",
    quantity_on_hand: str = "",
    quantity_needed: str = "",
    needed: bool = False,
    notes: str = "",
) -> dict:
    """v1 POST /supplies (amount is the legacy column, always '')."""
    name = _require_name(name)
    s = Supply(
        garden_id=garden_id,
        name=name,
        category=(category or "").strip(),
        amount="",
        quantity_on_hand=(quantity_on_hand or "").strip(),
        quantity_needed=(quantity_needed or "").strip(),
        needed=bool(needed),
        notes=(notes or "").strip(),
        created_at=_now(),
        featured_image_path="",
        is_archived=False,
    )
    db.add(s)
    db.flush()
    audit.log_add(db, garden_id, "supply", s.id, name)
    db.commit()
    return _supply_payload(s)


def update_supply(
    db: Session, garden_id: int, supply_id: int, changes: dict
) -> dict:
    """PATCH semantics over the v1 supply form fields."""
    unknown = set(changes) - _SUPPLY_PATCH_FIELDS
    if unknown:
        raise AppError(
            VALIDATION_FAILED, f"unknown fields: {sorted(unknown)}", 400
        )
    s = _get_supply_row(db, garden_id, supply_id)

    normalized: dict = {}
    if "name" in changes:
        normalized["name"] = _require_name(changes["name"])
    for field in ("category", "quantity_on_hand", "quantity_needed", "notes"):
        if field in changes:
            normalized[field] = (changes[field] or "").strip()
    if "needed" in changes:
        normalized["needed"] = bool(changes["needed"])

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


def delete_supply(db: Session, garden_id: int, supply_id: int) -> None:
    """v1 POST /supplies/{id}/delete — hard delete + log row."""
    s = _get_supply_row(db, garden_id, supply_id)
    label = s.name
    db.delete(s)
    audit.log_delete(db, garden_id, "supply", supply_id, label)
    db.commit()


# --- stations ------------------------------------------------------------------

def _station_payload(st: WateringStation) -> dict:
    return {
        "id": st.id,
        "name": st.name,
        "notes": st.notes or "",
        "current_schedule": st.current_schedule or "",
        "featured_image_path": st.featured_image_path or "",
        "is_archived": bool(st.is_archived),
        "created_at": st.created_at,
    }


def _get_station_row(
    db: Session, garden_id: int, station_id: int
) -> WateringStation:
    st = db.get(WateringStation, station_id)
    if st is None or st.garden_id != garden_id:
        raise AppError(
            STATION_NOT_FOUND, f"station {station_id} not found", 404
        )
    return st


def _validated_area_ids(
    db: Session, garden_id: int, area_ids: list | None
) -> list[int]:
    """Coerce to ints, dedupe (v1 INSERT OR IGNORE), and require every id to
    exist in this garden — all-or-nothing, exactly v1's guard."""
    if not area_ids:
        return []
    try:
        ids = list(dict.fromkeys(int(x) for x in area_ids))
    except (TypeError, ValueError):
        raise AppError(
            VALIDATION_FAILED, "area_ids must be a list of integers", 400
        )
    found = set(
        db.scalars(
            select(Area.id).where(
                Area.id.in_(ids), Area.garden_id == garden_id
            )
        ).all()
    )
    missing = [i for i in ids if i not in found]
    if missing:
        raise AppError(UNKNOWN_AREA_IDS, f"unknown area ids: {missing}", 400)
    return ids


def _station_area_ids(db: Session, station_id: int) -> list[int]:
    return sorted(
        db.scalars(
            select(AreaStation.area_id).where(
                AreaStation.station_id == station_id
            )
        ).all()
    )


def list_stations(
    db: Session, garden_id: int, include_archived: bool = False
) -> list[dict]:
    """v1 GET /stations: name-ordered + per-station area count (v1 counts
    all M2M rows, no archive filter on the areas — kept)."""
    stmt = select(WateringStation).where(
        WateringStation.garden_id == garden_id
    )
    if not include_archived:
        stmt = stmt.where(WateringStation.is_archived == sa.false())
    rows = db.scalars(stmt.order_by(WateringStation.name)).all()

    counts = dict(
        db.execute(
            select(AreaStation.station_id, func.count())
            .group_by(AreaStation.station_id)
        ).all()
    )
    return [
        {**_station_payload(st), "area_count": counts.get(st.id, 0)}
        for st in rows
    ]


def get_station(
    db: Session, garden_id: int, station_id: int, include_archived: bool = False
) -> dict:
    """v1 GET /stations/{id}: the row + the areas it serves (archive-filtered
    like v1's load_areas_for_station), name-ordered."""
    st = _get_station_row(db, garden_id, station_id)
    a_stmt = (
        select(Area.id, Area.name)
        .join(AreaStation, AreaStation.area_id == Area.id)
        .where(AreaStation.station_id == station_id)
    )
    if not include_archived:
        a_stmt = a_stmt.where(Area.is_archived == sa.false())
    areas = [
        {"id": r.id, "name": r.name}
        for r in db.execute(a_stmt.order_by(Area.name)).all()
    ]
    return {
        **_station_payload(st),
        "areas": areas,
        "area_count": len(_station_area_ids(db, station_id)),
    }


def create_station(
    db: Session,
    garden_id: int,
    *,
    name,
    notes: str = "",
    current_schedule: str = "",
    area_ids: list | None = None,
) -> dict:
    """v1 POST /stations — create + assign the initial areas-served set."""
    name = _require_name(name)
    ids = _validated_area_ids(db, garden_id, area_ids)
    st = WateringStation(
        garden_id=garden_id,
        name=name,
        notes=(notes or "").strip(),
        current_schedule=(current_schedule or "").strip(),
        created_at=_now(),
        featured_image_path="",
        is_archived=False,
    )
    db.add(st)
    db.flush()
    for aid in ids:
        db.add(AreaStation(area_id=aid, station_id=st.id))
    audit.log_add(db, garden_id, "station", st.id, name)
    db.commit()
    return get_station(db, garden_id, st.id)


def update_station(
    db: Session, garden_id: int, station_id: int, changes: dict
) -> dict:
    """PATCH semantics over the v1 station form's scalar fields. The M2M
    set is managed separately via set_station_areas (PUT .../areas)."""
    unknown = set(changes) - _STATION_PATCH_FIELDS
    if unknown:
        raise AppError(
            VALIDATION_FAILED, f"unknown fields: {sorted(unknown)}", 400
        )
    st = _get_station_row(db, garden_id, station_id)

    normalized: dict = {}
    if "name" in changes:
        normalized["name"] = _require_name(changes["name"])
    for field in ("notes", "current_schedule"):
        if field in changes:
            normalized[field] = (changes[field] or "").strip()

    label = st.name
    for field, new in normalized.items():
        old = getattr(st, field)
        if old != new:
            setattr(st, field, new)
            audit.log_edit(db, garden_id, "station", st.id, field, old, new,
                           label=label)
    db.commit()
    return get_station(db, garden_id, station_id)


def set_station_areas(
    db: Session, garden_id: int, station_id: int, area_ids: list
) -> list[int]:
    """Replace a station's areas-served set in one shot (v1 POST
    /api/station/{id}/areas). All-or-nothing validation, then delete-all +
    insert; logs an ``areas_served`` edit only on actual change. Returns the
    stored sorted id list."""
    st = _get_station_row(db, garden_id, station_id)
    if not isinstance(area_ids, list):
        raise AppError(
            VALIDATION_FAILED, "area_ids must be a list of integers", 400
        )
    ids = _validated_area_ids(db, garden_id, area_ids)

    prev_ids = _station_area_ids(db, station_id)
    db.execute(
        delete(AreaStation).where(AreaStation.station_id == station_id)
    )
    for aid in ids:
        db.add(AreaStation(area_id=aid, station_id=station_id))
    db.flush()

    new_sorted = sorted(ids)
    if prev_ids != new_sorted:
        audit.log_edit(
            db, garden_id, "station", station_id, "areas_served",
            prev_ids, new_sorted, label=st.name,
        )
    db.commit()
    return new_sorted


def delete_station(db: Session, garden_id: int, station_id: int) -> None:
    """v1 POST /stations/{id}/delete — hard delete; area_stations rows go
    with it (FK ON DELETE CASCADE)."""
    st = _get_station_row(db, garden_id, station_id)
    label = st.name
    db.delete(st)
    audit.log_delete(db, garden_id, "station", station_id, label)
    db.commit()
