"""Plants domain service — the plant-group lifecycle.

Behavior port of v1 (/srv/apps/garden/app/main.py, read-only reference):

- pools listing (GET /unplanted payload) ................. v1 ~5006-5051
- create in pool (POST /unplanted) ....................... v1 ~5054-5082
- set_pool_status (idea<->unplanted only) ................ v1 ~5085-5106
- mark_planted (existing planting OR create new) ......... v1 ~5109-5148
- update with the qty->0 => removed+detached rule ........ v1 ~5474-5527
- attach_to_area (SPLIT + merge semantics) ............... v1 ~5640-5778
- detach_from_area (+ pool auto-merge) ................... v1 ~5781-5833
- move (split-aware) ..................................... v1 ~5902-5983
- delete (+ field-note target cleanup) ................... v1 ~5892-5899

This module is also the home of the shared low-level primitives
(parse_quantity, plant_label, delete_target_refs, reconcile_variety,
snapshot_overview, plant_out). services/plantings.py imports from here;
imports are strictly one-directional (plantings -> plants) so there is no
cycle. maybe_archive_planting (the up-cascade) lives in plantings.py and —
matching v1's actual call sites — is triggered by the archive domain, not by
this lifecycle module.

Known-carried v1 warts (on purpose):
- ``plants.quantity`` is free-form Text; all arithmetic goes through
  parse_quantity() with an explicit fallback (0 for pool math, 1 for totals).
- ``plants.positions`` is Text holding JSON-or-'' — parsed defensively.
- attach/detach do NOT write activity_log (v1 parity: sketch/layout
  repositioning is intentionally unlogged — too noisy). move DOES log (v1).
- attach/detach delete exhausted/merged source rows WITHOUT field-note
  target cleanup (v1 parity — the raw DELETE in v1's routes).

Deviations from v1 (deliberate, flagged):
- Plants created through planting flows get status='planted' instead of
  v1's '' (v1 normalized '' -> 'planted' at startup anyway; v2 has a CHECK
  constraint on the vocabulary).
- All lookups are garden-scoped (deviation D1).
- delete_plant 404s on a missing id (v1 silently deleted nothing).
"""
from __future__ import annotations

import json
from datetime import datetime, timezone

from sqlalchemy import delete as sa_delete
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from ..errors import AppError
from ..models.catalog import Species, Variety
from ..models.garden import Area, Year
from ..models.history import OverviewVersion
from ..models.notes import FieldNote, FieldNoteTarget
from ..models.planting import Plant, Planting
from ..schemas.plantings import (
    AttachRequest,
    MarkPlantedRequest,
    MoveRequest,
    PlantUpdate,
    PoolPlantCreate,
)
from .overviews import snapshot_overview  # single source
from .audit import log_add, log_delete, log_edit

# --- error codes (domain: plant) ---
PLANT_NOT_FOUND = "plant_not_found"
PLANT_POOL_STATUS_INVALID = "plant_pool_status_invalid"
PLANT_NOT_IN_POOL = "plant_not_in_pool"
PLANT_SPECIES_OR_VARIETY_REQUIRED = "plant_species_or_variety_required"
PLANT_PLANTING_REQUIRED = "plant_planting_required"
PLANT_PLANTING_NOT_FOUND = "plant_planting_not_found"
PLANT_YEAR_NOT_FOUND = "plant_year_not_found"
PLANT_AREA_NOT_FOUND = "plant_area_not_found"
PLANT_POSITION_INVALID = "plant_position_invalid"
PLANT_NONE_LEFT_TO_PLACE = "plant_none_left_to_place"
PLANT_MOVE_QTY_INVALID = "plant_move_qty_invalid"
PLANT_QTY_NOT_NUMERIC = "plant_quantity_not_numeric"
PLANT_MOVE_QTY_EXCEEDS = "plant_move_qty_exceeds"

# v1 vocabulary for the staging pools (plants outside the planted state).
POOL_STATUSES = ("unplanted", "idea")


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


# ---------------------------------------------------------------------------
# Shared primitives (also imported by services/plantings.py)
# ---------------------------------------------------------------------------

def parse_quantity(value, fallback: int = 0) -> int:
    """Defensive int parse of the free-form quantity Text column.

    v1 had two behaviors, unified here via ``fallback``:
    - pool/attach/move math: non-numeric counts as 0 (can't peel/move it)
    - planting totals: non-numeric counts as 1 (a group exists, count it)
    """
    try:
        return int(str(value if value is not None else "").strip())
    except ValueError:
        return fallback


def plant_display_name(
    species_name: str | None, variety_name: str | None, plant_id: int
) -> str:
    """Port of v1 plant_display_name (~2656): 'Variety Species' or fallback."""
    parts = [p for p in (variety_name, species_name) if p]
    return " ".join(parts) if parts else f"Plant #{plant_id}"


def plant_label(db: Session, plant_id: int) -> str:
    """Port of v1 _entity_label_safe's plant branch (~1084): 'qty× Name'."""
    row = db.execute(
        select(func.coalesce(Variety.name, Species.name), Plant.quantity)
        .select_from(Plant)
        .join(Species, Species.id == Plant.species_id, isouter=True)
        .join(Variety, Variety.id == Plant.variety_id, isouter=True)
        .where(Plant.id == plant_id)
    ).first()
    fallback = f"plant #{plant_id}"
    if row and row[0]:
        qty = (row[1] or "").strip()
        return f"{qty}× {row[0]}" if qty else row[0]
    return fallback


def reconcile_variety(
    db: Session, species_id: int | None, variety_id: int | None
) -> int | None:
    """v1's variety-species mismatch guard: when both are submitted and the
    variety belongs to a DIFFERENT species, the variety is dropped (the
    species wins). Applied uniformly (v1 had it on update/create-in-planting
    and pool-create; the JSON API applies it everywhere)."""
    if not (variety_id and species_id):
        return variety_id
    v_species = db.scalar(select(Variety.species_id).where(Variety.id == variety_id))
    if v_species and v_species != species_id:
        return None
    return variety_id


def delete_target_refs(db: Session, target_type: str, target_id: int) -> None:
    # NOTE: services/notes.py has the richer garden-scoped variant (also
    # cleans pipeline_runs). Unify in the P2 cleanup pass — tracked.
    """Port of v1 delete_target_refs (~2548): remove field_note_targets
    pointing at this entity; delete orphaned field notes (no remaining
    targets); re-point the primary flag if the primary target was lost."""
    affected = db.scalars(
        select(FieldNoteTarget.field_note_id).where(
            FieldNoteTarget.target_type == target_type,
            FieldNoteTarget.target_id == target_id,
        )
    ).all()
    db.execute(
        sa_delete(FieldNoteTarget).where(
            FieldNoteTarget.target_type == target_type,
            FieldNoteTarget.target_id == target_id,
        )
    )
    for note_id in affected:
        remaining = db.scalars(
            select(FieldNoteTarget)
            .where(FieldNoteTarget.field_note_id == note_id)
            .order_by(FieldNoteTarget.is_primary.desc().nulls_last(), FieldNoteTarget.id)
        ).all()
        if not remaining:
            db.execute(sa_delete(FieldNote).where(FieldNote.id == note_id))
            continue
        if not any(t.is_primary for t in remaining):
            remaining[0].is_primary = True
    db.flush()



def plant_out(plant: Plant, **extra) -> dict:
    """Serialize a Plant row to the PlantOut dict shape. ``extra`` carries
    joined display fields (species_name, variety_name, area_name, ...)."""
    d = {
        "id": plant.id,
        "planting_id": plant.planting_id,
        "species_id": plant.species_id,
        "variety_id": plant.variety_id,
        "area_id": plant.area_id,
        "quantity": plant.quantity,
        "source": plant.source,
        "status": plant.status,
        "notes": plant.notes,
        "created_at": plant.created_at,
        "featured_image_path": plant.featured_image_path,
        "positions": plant.positions,
        "is_archived": bool(plant.is_archived),
        "flavor_rating": plant.flavor_rating,
        "vigor_rating": plant.vigor_rating,
        "would_plant_again": plant.would_plant_again,
        "outcome_summary": plant.outcome_summary,
    }
    d.update(extra)
    d["display_name"] = plant_display_name(
        d.get("species_name"), d.get("variety_name"), plant.id
    )
    return d


def _joined_select():
    """The standard plant read: species/variety/area names joined in."""
    return (
        select(
            Plant,
            Species.name.label("species_name"),
            Variety.name.label("variety_name"),
            Area.name.label("area_name"),
        )
        .join(Species, Species.id == Plant.species_id, isouter=True)
        .join(Variety, Variety.id == Plant.variety_id, isouter=True)
        .join(Area, Area.id == Plant.area_id, isouter=True)
    )


def load_plants_for_planting(
    db: Session, garden_id: int, planting_id: int, include_archived: bool = False
) -> list[dict]:
    """Port of v1 load_plants_for_planting (~2636), ordered by plant id."""
    stmt = _joined_select().where(
        Plant.planting_id == planting_id, Plant.garden_id == garden_id
    )
    if not include_archived:
        stmt = stmt.where(Plant.is_archived == False)  # noqa: E712 (v1: = 0)
    stmt = stmt.order_by(Plant.id)
    return [
        plant_out(p, species_name=s, variety_name=v, area_name=a)
        for p, s, v, a in db.execute(stmt)
    ]


def _get_plant(db: Session, garden_id: int, plant_id: int) -> Plant:
    plant = db.scalar(
        select(Plant).where(Plant.id == plant_id, Plant.garden_id == garden_id)
    )
    if plant is None:
        raise AppError(PLANT_NOT_FOUND, "plant not found", 404)
    return plant


def _load_positions(text: str | None) -> list:
    """Defensive parse of the positions Text wart ('' / JSON list / junk)."""
    try:
        positions = json.loads(text or "[]")
    except (ValueError, TypeError):
        return []
    return positions if isinstance(positions, list) else []


# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

def get_plant(db: Session, garden_id: int, plant_id: int) -> dict:
    """Detail payload — v1 plant_detail row (~5542): joined names + the
    owning planting's name/year."""
    row = db.execute(
        _joined_select()
        .add_columns(Planting.name.label("planting_name"), Year.year.label("year_value"))
        .join(Planting, Planting.id == Plant.planting_id, isouter=True)
        .join(Year, Year.id == Planting.year_id, isouter=True)
        .where(Plant.id == plant_id, Plant.garden_id == garden_id)
    ).first()
    if row is None:
        raise AppError(PLANT_NOT_FOUND, "plant not found", 404)
    plant, species_name, variety_name, area_name, planting_name, year_value = row
    return plant_out(
        plant,
        species_name=species_name,
        variety_name=variety_name,
        area_name=area_name,
        planting_name=planting_name,
        year_value=year_value,
    )


def list_pools(db: Session, garden_id: int, status: str | None = None) -> dict:
    """Port of the GET /unplanted payload (~5016): both staging pools plus
    the plantings picker used by 'mark as planted'. ``status`` optionally
    narrows to one pool. (v1's species/varieties form lists come from the
    catalog domain in v2 and are not duplicated here.)"""
    if status is not None and status not in POOL_STATUSES:
        raise AppError(
            PLANT_POOL_STATUS_INVALID, "status must be 'unplanted' or 'idea'", 400
        )
    wanted = (status,) if status else POOL_STATUSES
    rows = db.execute(
        _joined_select()
        .add_columns(
            func.coalesce(
                Variety.plants_per_unit, Species.plants_per_unit, 1
            ).label("plants_per_unit")
        )
        .where(
            Plant.garden_id == garden_id,
            Plant.is_archived == False,  # noqa: E712 (v1: = 0)
            Plant.status.in_(wanted),
        )
        .order_by(
            Plant.status, func.coalesce(Variety.name, Species.name, ""), Plant.id
        )
    ).all()
    unplanted: list[dict] = []
    ideas: list[dict] = []
    for plant, species_name, variety_name, area_name, ppu in rows:
        d = plant_out(
            plant,
            species_name=species_name,
            variety_name=variety_name,
            area_name=area_name,
            plants_per_unit=ppu,
        )
        (unplanted if plant.status == "unplanted" else ideas).append(d)
    pickers = db.execute(
        select(Planting.id, Planting.name, Year.year)
        .join(Year, Year.id == Planting.year_id, isouter=True)
        .where(
            Planting.garden_id == garden_id,
            func.coalesce(Planting.is_archived, False) == False,  # noqa: E712
        )
        .order_by(func.coalesce(Year.year, 0).desc(), Planting.name)
    ).all()
    return {
        "unplanted": unplanted,
        "ideas": ideas,
        "plantings": [
            {"id": pid, "name": name, "year_value": year}
            for pid, name, year in pickers
        ],
    }


def create_pool_plant(db: Session, garden_id: int, payload: PoolPlantCreate) -> dict:
    """Port of POST /unplanted (~5054): create an unplanted/idea plant with
    no planting and no area — that's the point."""
    status = (payload.status or "unplanted").strip()
    if status not in POOL_STATUSES:
        raise AppError(
            PLANT_POOL_STATUS_INVALID, "status must be 'unplanted' or 'idea'", 400
        )
    if not payload.species_id and not payload.variety_id:
        raise AppError(
            PLANT_SPECIES_OR_VARIETY_REQUIRED, "species or variety required", 400
        )
    variety_id = reconcile_variety(db, payload.species_id, payload.variety_id)
    plant = Plant(
        garden_id=garden_id,
        planting_id=None,
        species_id=payload.species_id,
        variety_id=variety_id,
        area_id=None,
        quantity=(payload.quantity or "").strip() or "1",
        source=(payload.source or "").strip(),
        status=status,
        notes=(payload.notes or "").strip(),
        created_at=utc_now(),
        featured_image_path="",
        positions="",
        is_archived=False,
    )
    db.add(plant)
    db.flush()
    log_add(db, garden_id, "plant", plant.id, plant_label(db, plant.id))
    return get_plant(db, garden_id, plant.id)


def set_pool_status(db: Session, garden_id: int, plant_id: int, new_status: str) -> dict:
    """Port of POST /plants/{id}/set-pool-status (~5085): flips idea <->
    unplanted ONLY. Planted/removed plants have their own lifecycle
    endpoints and are refused here."""
    new_status = (new_status or "").strip()
    if new_status not in POOL_STATUSES:
        raise AppError(
            PLANT_POOL_STATUS_INVALID, "status must be 'idea' or 'unplanted'", 400
        )
    plant = _get_plant(db, garden_id, plant_id)
    old = plant.status or ""
    if old not in POOL_STATUSES:
        raise AppError(
            PLANT_NOT_IN_POOL,
            "this endpoint only flips idea<->unplanted; this plant is "
            + (old or "(empty)"),
            400,
        )
    if old != new_status:
        plant.status = new_status
        db.flush()
        log_edit(
            db, garden_id, "plant", plant_id, "status", old, new_status,
            label=plant_label(db, plant_id),
        )
    return get_plant(db, garden_id, plant_id)


def mark_planted(
    db: Session, garden_id: int, plant_id: int, payload: MarkPlantedRequest
) -> dict:
    """Port of POST /plants/{id}/mark-planted (~5109): convert an
    unplanted/idea plant to status='planted', linked to an EXISTING planting
    (planting_id) or a NEW one (planting_name + optional year_id). area_id
    and positions are kept so the placement transfers from plan to real."""
    plant = _get_plant(db, garden_id, plant_id)
    old_status = plant.status
    old_planting_id = plant.planting_id
    new_name = (payload.planting_name or "").strip()

    if payload.planting_id:
        exists = db.scalar(
            select(Planting.id).where(
                Planting.id == payload.planting_id, Planting.garden_id == garden_id
            )
        )
        if exists is None:
            raise AppError(PLANT_PLANTING_NOT_FOUND, "planting not found", 400)
        target_pid = payload.planting_id
    elif new_name:
        if payload.year_id is not None:
            year_ok = db.scalar(
                select(Year.id).where(
                    Year.id == payload.year_id, Year.garden_id == garden_id
                )
            )
            if year_ok is None:
                raise AppError(PLANT_YEAR_NOT_FOUND, "year not found", 400)
        planting = Planting(
            garden_id=garden_id,
            name=new_name,
            year_id=payload.year_id,
            source="",
            status="planted",
            notes="",
            created_at=utc_now(),
            featured_image_path="",
            is_archived=False,
        )
        db.add(planting)
        db.flush()
        target_pid = planting.id
        log_add(db, garden_id, "planting", target_pid, new_name)
    else:
        raise AppError(
            PLANT_PLANTING_REQUIRED, "planting_id or planting_name required", 400
        )

    plant.planting_id = target_pid
    plant.status = "planted"
    db.flush()
    label = plant_label(db, plant_id)
    log_edit(db, garden_id, "plant", plant_id, "status", old_status, "planted", label=label)
    log_edit(
        db, garden_id, "plant", plant_id, "planting_id",
        old_planting_id, target_pid, label=label,
    )
    return get_plant(db, garden_id, plant_id)


_UPDATABLE_PLANT_FIELDS = ("quantity", "species_id", "variety_id", "area_id", "source", "notes")


def update_plant(db: Session, garden_id: int, plant_id: int, patch: PlantUpdate) -> dict:
    """Port of POST /plants/{id} (~5474) with PATCH semantics: only fields
    present in the request change (v1 was a full-replace form).

    The v1 rule is kept exactly: when the (new) quantity parses to an int
    <= 0, the group flips to status='removed' AND detaches from its area so
    it stops counting toward area totals and species pills."""
    plant = _get_plant(db, garden_id, plant_id)
    provided = patch.model_fields_set

    def _eff(field: str):
        return getattr(patch, field) if field in provided else getattr(plant, field)

    qty = _eff("quantity")
    if "quantity" in provided:
        qty = (qty or "").strip()
    species_id = _eff("species_id")
    variety_id = reconcile_variety(db, species_id, _eff("variety_id"))
    area_id = _eff("area_id")
    source = (patch.source or "").strip() if "source" in provided else plant.source
    notes = (patch.notes or "").strip() if "notes" in provided else plant.notes

    snapshot_overview(db, "plant", plant_id, plant.notes, notes)

    # qty -> 0 rule (v1: int(qty) if qty else None; non-numeric = no rule).
    new_status = plant.status
    try:
        qty_int = int(qty) if qty else None
    except (TypeError, ValueError):
        qty_int = None
    if qty_int is not None and qty_int <= 0:
        new_status = "removed"
        area_id = None

    old_values = {f: getattr(plant, f) for f in _UPDATABLE_PLANT_FIELDS}
    old_values["status"] = plant.status
    plant.quantity = qty
    plant.species_id = species_id
    plant.variety_id = variety_id
    plant.area_id = area_id
    plant.source = source
    plant.notes = notes
    plant.status = new_status
    db.flush()

    new_values = {
        "quantity": qty, "species_id": species_id, "variety_id": variety_id,
        "area_id": area_id, "source": source, "notes": notes, "status": new_status,
    }
    label = plant_label(db, plant_id)
    for field, new_val in new_values.items():
        old_val = old_values[field]
        if (old_val if old_val is not None else "") != (
            new_val if new_val is not None else ""
        ):
            log_edit(db, garden_id, "plant", plant_id, field, old_val, new_val, label=label)
    return get_plant(db, garden_id, plant_id)


def _find_merge_target(
    db: Session, garden_id: int, src: Plant, *, area_id: int | None,
    exclude_id: int | None = None,
) -> Plant | None:
    """The v1 merge-partner query: same status + species/variety/planting
    (NULL matches NULL via the COALESCE(-1) trick), not archived, in the
    given area (or unattached when area_id is None)."""
    if src.status is None:
        # v1's `status = ?` with NULL matches nothing — no merge partner.
        return None
    stmt = select(Plant).where(
        Plant.garden_id == garden_id,
        Plant.status == src.status,
        func.coalesce(Plant.species_id, -1)
        == (src.species_id if src.species_id is not None else -1),
        func.coalesce(Plant.variety_id, -1)
        == (src.variety_id if src.variety_id is not None else -1),
        func.coalesce(Plant.planting_id, -1)
        == (src.planting_id if src.planting_id is not None else -1),
        func.coalesce(Plant.is_archived, False) == False,  # noqa: E712
    )
    if area_id is None:
        stmt = stmt.where(Plant.area_id.is_(None))
    else:
        stmt = stmt.where(Plant.area_id == area_id)
    if exclude_id is not None:
        stmt = stmt.where(Plant.id != exclude_id)
    return db.scalars(stmt.order_by(Plant.id).limit(1)).first()


def attach_to_area(
    db: Session, garden_id: int, plant_id: int, payload: AttachRequest
) -> dict:
    """Port of POST /api/plants/{id}/attach-to-area (~5640). Two modes:

    A) source already in the target area: append one position (no split).
    B) cross-area (the pool->canvas flow): SPLIT — peel ``icon_plants``
       plants off the source group into the target area, merging into an
       existing group there when species/variety/planting/status all match
       (bump qty + append position) instead of minting tiny sibling rows.
       The source decrements; at 0 the source row is deleted."""
    src = _get_plant(db, garden_id, plant_id)
    area = db.scalar(
        select(Area).where(Area.id == payload.area_id, Area.garden_id == garden_id)
    )
    if area is None:
        raise AppError(PLANT_AREA_NOT_FOUND, "area not found", 400)
    length = float(area.length_ft or 0) or 1.0
    width = float(area.width_ft or 0) or 1.0

    if payload.position is None:
        raise AppError(PLANT_POSITION_INVALID, "position required (x, y)", 400)
    x = max(0.0, min(length, float(payload.position.x)))
    y = max(0.0, min(width, float(payload.position.y)))
    new_pos = {"x": round(x, 3), "y": round(y, 3)}
    icon_plants = max(1, payload.icon_plants or 1)
    src_qty = parse_quantity(src.quantity, 0)

    # Mode A: same area — just append the position.
    if src.area_id == payload.area_id:
        positions = _load_positions(src.positions)
        positions.append(new_pos)
        src.positions = json.dumps(positions)
        db.flush()
        return {"ok": True, "id": plant_id, "split": False, "saved": len(positions)}

    # Mode B: cross-area split. Cap how many we peel off.
    peel = min(icon_plants, max(0, src_qty))
    if peel < 1:
        raise AppError(
            PLANT_NONE_LEFT_TO_PLACE, "source group has no plants left to place", 400
        )

    target = _find_merge_target(db, garden_id, src, area_id=payload.area_id)
    if target is not None:
        tpositions = _load_positions(target.positions)
        tpositions.append(new_pos)
        target.quantity = str(parse_quantity(target.quantity, 0) + peel)
        target.positions = json.dumps(tpositions)
        target_id = target.id
    else:
        new_plant = Plant(
            garden_id=garden_id,
            planting_id=src.planting_id,
            species_id=src.species_id,
            variety_id=src.variety_id,
            area_id=payload.area_id,
            quantity=str(peel),
            source=src.source or "",
            status=src.status,
            notes=src.notes or "",
            created_at=utc_now(),
            featured_image_path="",
            positions=json.dumps([new_pos]),
            is_archived=False,
        )
        db.add(new_plant)
        db.flush()
        target_id = new_plant.id

    # Decrement source. If exhausted, delete the row (no plants left).
    remaining = src_qty - peel
    if remaining <= 0:
        db.delete(src)  # v1 parity: raw delete, no target-ref cleanup here
    else:
        src.quantity = str(remaining)
    db.flush()
    return {
        "ok": True, "split": True, "source_id": plant_id,
        "target_id": target_id, "peeled": peel, "remaining": remaining,
    }


def detach_from_area(db: Session, garden_id: int, plant_id: int) -> dict:
    """Port of POST /api/plants/{id}/detach-from-area (~5781): clear area_id
    + positions. Unplanted/idea plants AUTO-MERGE into an existing unattached
    group of the same species/variety/status/planting (quantities summed,
    source row deleted). Planted plants are never auto-merged."""
    src = _get_plant(db, garden_id, plant_id)
    src.area_id = None
    src.positions = ""
    merged_into = None
    if src.status in POOL_STATUSES:
        target = _find_merge_target(
            db, garden_id, src, area_id=None, exclude_id=plant_id
        )
        if target is not None:
            target.quantity = str(
                parse_quantity(target.quantity, 0) + parse_quantity(src.quantity, 0)
            )
            db.delete(src)  # v1 parity: raw delete, no target-ref cleanup
            merged_into = target.id
    db.flush()
    return {"ok": True, "id": plant_id, "merged_into": merged_into}


def move_plant(
    db: Session, garden_id: int, plant_id: int, payload: MoveRequest
) -> dict:
    """Port of POST /plants/{id}/move (~5902): move some or all of a group
    to another area. Full quantity = in-place area change; partial = split
    into a new row in the same planting at the target area."""
    if payload.move_qty <= 0:
        raise AppError(PLANT_MOVE_QTY_INVALID, "move_qty must be positive", 400)
    plant = _get_plant(db, garden_id, plant_id)
    area_ok = db.scalar(
        select(Area.id).where(
            Area.id == payload.target_area_id, Area.garden_id == garden_id
        )
    )
    if area_ok is None:
        raise AppError(PLANT_AREA_NOT_FOUND, "target area not found", 400)

    current_qty = parse_quantity(plant.quantity, 0)
    if current_qty <= 0:
        raise AppError(
            PLANT_QTY_NOT_NUMERIC, "can't move plants with a non-numeric quantity", 400
        )
    if payload.move_qty > current_qty:
        raise AppError(PLANT_MOVE_QTY_EXCEEDS, "move_qty exceeds current quantity", 400)

    label = plant_label(db, plant_id)
    prev_area = plant.area_id
    if payload.move_qty == current_qty:
        plant.area_id = payload.target_area_id
        db.flush()
        log_edit(
            db, garden_id, "plant", plant_id, "area_id",
            prev_area, payload.target_area_id,
            label=f"{label} (moved all {payload.move_qty})",
        )
        return {
            "ok": True, "id": plant_id, "split": False,
            "moved_qty": payload.move_qty,
            "target_area_id": payload.target_area_id,
            "new_plant_id": None, "remaining_qty": plant.quantity,
        }

    plant.quantity = str(current_qty - payload.move_qty)
    split_plant = Plant(
        garden_id=garden_id,
        planting_id=plant.planting_id,
        species_id=plant.species_id,
        variety_id=plant.variety_id,
        area_id=payload.target_area_id,
        quantity=str(payload.move_qty),
        source=plant.source or "",
        status=plant.status,
        notes="",
        created_at=utc_now(),
        featured_image_path="",
        positions="",
        is_archived=False,
    )
    db.add(split_plant)
    db.flush()
    log_edit(
        db, garden_id, "plant", plant_id, "quantity",
        current_qty, current_qty - payload.move_qty,
        label=(
            f"{label} (split {payload.move_qty} of {current_qty} "
            f"to area #{payload.target_area_id})"
        ),
    )
    log_add(
        db, garden_id, "plant", split_plant.id,
        plant_label(db, split_plant.id) + f" (split from #{plant_id})",
    )
    return {
        "ok": True, "id": plant_id, "split": True,
        "moved_qty": payload.move_qty,
        "target_area_id": payload.target_area_id,
        "new_plant_id": split_plant.id,
        "remaining_qty": plant.quantity,
    }


def delete_plant(db: Session, garden_id: int, plant_id: int) -> dict:
    """Port of POST /plants/{id}/delete (~5892) with a 404 on unknown ids
    (v1 silently deleted nothing)."""
    plant = _get_plant(db, garden_id, plant_id)
    label = plant_label(db, plant_id)
    delete_target_refs(db, "plant", plant_id)
    db.delete(plant)
    db.flush()
    log_delete(db, garden_id, "plant", plant_id, label)
    return {"ok": True}
