"""Behavior tests for the plantings + plants domain port.

Three layers, per conftest.py:

1. Pure-unit tests (schemas, parse helpers, route registration) — run
   anywhere, including the host without a DB.
2. Migrated-data READ assertions through the API (app_client) — require
   DATABASE_URL (in-container runs); read-only, no commits.
3. Write-path tests calling the services directly on the SAVEPOINT-wrapped
   db_session fixture — every change rolls back, migrated data stays clean.
"""
from __future__ import annotations

import json
import os
import uuid
from datetime import datetime, timezone

import pytest
from fastapi.routing import APIRoute

requires_db = pytest.mark.skipif(
    not os.environ.get("DATABASE_URL"),
    reason="DATABASE_URL not set — DB tests run in-container",
)

GARDEN_ID = 1


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


def _uniq(prefix: str) -> str:
    return f"{prefix}-{uuid.uuid4().hex[:8]}"


# ---------------------------------------------------------------------------
# 1. Pure-unit (no DB)
# ---------------------------------------------------------------------------

def test_routes_registered():
    from app.main import create_app

    app = create_app()
    paths = {
        (route.path, method)
        for route in app.routes
        if isinstance(route, APIRoute)
        for method in route.methods
    }
    expected = [
        ("/api/plantings", "GET"),
        ("/api/plantings", "POST"),
        ("/api/plantings/{planting_id}", "GET"),
        ("/api/plantings/{planting_id}", "PATCH"),
        ("/api/plantings/{planting_id}", "DELETE"),
        ("/api/plantings/{planting_id}/plants", "POST"),
        ("/api/plants/pools", "GET"),
        ("/api/plants", "POST"),
        ("/api/plants/{plant_id}", "GET"),
        ("/api/plants/{plant_id}", "PATCH"),
        ("/api/plants/{plant_id}", "DELETE"),
        ("/api/plants/{plant_id}/pool-status", "POST"),
        ("/api/plants/{plant_id}/mark-planted", "POST"),
        ("/api/plants/{plant_id}/attach-to-area", "POST"),
        ("/api/plants/{plant_id}/detach-from-area", "POST"),
        ("/api/plants/{plant_id}/move", "POST"),
    ]
    for pair in expected:
        assert pair in paths, f"route missing: {pair}"


def test_parse_quantity_defensive():
    from app.services.plants import parse_quantity

    assert parse_quantity("5") == 5
    assert parse_quantity(" 7 ") == 7
    assert parse_quantity(7) == 7
    assert parse_quantity("-2") == -2
    # non-numeric / empty fall back — 0 for pool math, 1 for totals
    assert parse_quantity("a few") == 0
    assert parse_quantity("a few", 1) == 1
    assert parse_quantity("", 1) == 1
    assert parse_quantity(None) == 0
    assert parse_quantity(None, 1) == 1


def test_quantity_schema_coerces_ints_to_str():
    from app.schemas.plantings import PlantRowIn, PlantUpdate, PoolPlantCreate

    assert PlantRowIn(quantity=3).quantity == "3"
    assert PlantRowIn(quantity=" 4 ").quantity == "4"
    assert PlantUpdate(quantity=0).quantity == "0"
    assert PoolPlantCreate().quantity == "1"


def test_normalize_planting_status_wart():
    """v1 main.py:81 — anything but 'planted' collapses to 'planned'."""
    from app.services.plantings import normalize_planting_status

    assert normalize_planting_status("planted") == "planted"
    assert normalize_planting_status(None) == "planned"
    assert normalize_planting_status("") == "planned"
    assert normalize_planting_status("idea") == "planned"
    assert normalize_planting_status("bogus") == "planned"


def test_plant_display_name():
    from app.services.plants import plant_display_name

    assert plant_display_name("Tomato", "Roma", 5) == "Roma Tomato"
    assert plant_display_name("Tomato", None, 5) == "Tomato"
    assert plant_display_name(None, None, 5) == "Plant #5"


# ---------------------------------------------------------------------------
# 2. Migrated-data READ assertions (API level, read-only)
# ---------------------------------------------------------------------------

@requires_db
def test_list_plantings_migrated_counts(app_client, auth_headers):
    resp = app_client.get("/api/plantings", headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert data["total_plantings"] == 54
    group_2026 = next(g for g in data["year_groups"] if g["year"] == 2026)
    p60 = next(p for p in group_2026["plantings"] if p["id"] == 60)
    assert p60["name"] == "Spring 2026 - Fenced Garden mass planting"


@requires_db
def test_planting_60_detail_migrated(app_client, auth_headers):
    # All 64 plant groups (29 are archived) — visible with archived=1.
    resp = app_client.get("/api/plantings/60?archived=1", headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert data["name"] == "Spring 2026 - Fenced Garden mass planting"
    assert data["year_value"] == 2026
    assert data["group_count"] == 64
    assert len(data["plants"]) == 64
    # Default view hides the archived groups.
    resp = app_client.get("/api/plantings/60", headers=auth_headers)
    assert len(resp.json()["plants"]) < 64


@requires_db
def test_pools_endpoint_shape(app_client, auth_headers):
    resp = app_client.get("/api/plants/pools", headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert set(data.keys()) == {"unplanted", "ideas", "plantings"}
    assert all(p["status"] == "unplanted" for p in data["unplanted"])
    assert all(p["status"] == "idea" for p in data["ideas"])
    assert len(data["plantings"]) == 54  # picker: all non-archived plantings
    resp = app_client.get("/api/plants/pools?status=idea", headers=auth_headers)
    assert resp.json()["unplanted"] == []


@requires_db
def test_planting_not_found_error_shape(app_client, auth_headers):
    resp = app_client.get("/api/plantings/999999", headers=auth_headers)
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == "planting_not_found"


# ---------------------------------------------------------------------------
# 3. Write paths (service level, rolled back by db_session)
# ---------------------------------------------------------------------------

@pytest.fixture()
def factory(db_session):
    """Row factory for isolated fixtures inside the rollback transaction."""
    from app.models.catalog import Species, Variety
    from app.models.garden import Area
    from app.models.planting import Plant, Planting

    class Factory:
        db = db_session

        def species(self, name=None):
            row = Species(
                garden_id=GARDEN_ID, name=name or _uniq("TestSpecies"),
                created_at=_now(),
            )
            db_session.add(row)
            db_session.flush()
            return row

        def variety(self, species_id, name=None):
            row = Variety(
                species_id=species_id, name=name or _uniq("TestVariety"),
                created_at=_now(),
            )
            db_session.add(row)
            db_session.flush()
            return row

        def area(self, length=10.0, width=5.0, name=None):
            row = Area(
                garden_id=GARDEN_ID, name=name or _uniq("TestArea"),
                created_at=_now(), length_ft=length, width_ft=width,
            )
            db_session.add(row)
            db_session.flush()
            return row

        def planting(self, name=None, status="planted", year_id=None):
            row = Planting(
                garden_id=GARDEN_ID, name=name or _uniq("TestPlanting"),
                year_id=year_id, source="", notes="", status=status,
                created_at=_now(), is_archived=False,
            )
            db_session.add(row)
            db_session.flush()
            return row

        def plant(self, **kwargs):
            defaults = dict(
                garden_id=GARDEN_ID, planting_id=None, species_id=None,
                variety_id=None, area_id=None, quantity="5", source="",
                status="unplanted", notes="", created_at=_now(),
                featured_image_path="", positions="", is_archived=False,
            )
            defaults.update(kwargs)
            row = Plant(**defaults)
            db_session.add(row)
            db_session.flush()
            return row

    return Factory()


def _audit_rows(db, entity_type, entity_id):
    from sqlalchemy import select

    from app.models.history import ActivityLog

    return db.scalars(
        select(ActivityLog).where(
            ActivityLog.entity_type == entity_type,
            ActivityLog.entity_id == entity_id,
        )
    ).all()


def test_attach_cross_area_splits_then_merges(factory):
    """The pool->canvas flow: peel plants off into the target area, then a
    second attach merges into the existing matching group there."""
    from app.schemas.plantings import AttachRequest, Position
    from app.services.plants import attach_to_area

    db = factory.db
    sp = factory.species()
    a1, a2 = factory.area(), factory.area()
    src = factory.plant(species_id=sp.id, area_id=a1.id, quantity="5")

    out = attach_to_area(
        db, GARDEN_ID, src.id,
        AttachRequest(area_id=a2.id, position=Position(x=2.0, y=1.0), icon_plants=2),
    )
    assert out["split"] is True and out["peeled"] == 2 and out["remaining"] == 3
    assert src.quantity == "3"
    from app.models.planting import Plant
    target = db.get(Plant, out["target_id"])
    assert target.area_id == a2.id
    assert target.quantity == "2"
    assert json.loads(target.positions) == [{"x": 2.0, "y": 1.0}]

    # Second attach MERGES into the existing target group (no new row).
    out2 = attach_to_area(
        db, GARDEN_ID, src.id,
        AttachRequest(area_id=a2.id, position=Position(x=3.0, y=1.5), icon_plants=2),
    )
    assert out2["target_id"] == target.id
    assert target.quantity == "4"
    assert len(json.loads(target.positions)) == 2
    assert src.quantity == "1"

    # Exhausting the source deletes its row.
    out3 = attach_to_area(
        db, GARDEN_ID, src.id,
        AttachRequest(area_id=a2.id, position=Position(x=4.0, y=2.0), icon_plants=9),
    )
    assert out3["peeled"] == 1 and out3["remaining"] == 0
    db.expire_all()
    assert db.get(Plant, src.id) is None
    assert db.get(Plant, target.id).quantity == "5"


def test_attach_same_area_appends_position_no_split(factory):
    from app.schemas.plantings import AttachRequest, Position
    from app.services.plants import attach_to_area

    area = factory.area()
    src = factory.plant(
        species_id=factory.species().id, area_id=area.id,
        quantity="4", positions=json.dumps([{"x": 1.0, "y": 1.0}]),
    )
    out = attach_to_area(
        factory.db, GARDEN_ID, src.id,
        AttachRequest(area_id=area.id, position=Position(x=2.0, y=2.0)),
    )
    assert out == {"ok": True, "id": src.id, "split": False, "saved": 2}
    assert src.quantity == "4"  # no split, no decrement
    assert json.loads(src.positions)[-1] == {"x": 2.0, "y": 2.0}


def test_attach_clamps_position_and_requires_it(factory):
    from app.errors import AppError
    from app.schemas.plantings import AttachRequest, Position
    from app.services.plants import attach_to_area

    area = factory.area(length=10.0, width=5.0)
    src = factory.plant(species_id=factory.species().id, quantity="3")
    out = attach_to_area(
        factory.db, GARDEN_ID, src.id,
        AttachRequest(area_id=area.id, position=Position(x=99.0, y=-4.0)),
    )
    from app.models.planting import Plant
    target = factory.db.get(Plant, out["target_id"])
    assert json.loads(target.positions) == [{"x": 10.0, "y": 0.0}]

    with pytest.raises(AppError) as err:
        attach_to_area(factory.db, GARDEN_ID, src.id, AttachRequest(area_id=area.id))
    assert err.value.code == "plant_position_invalid"


def test_attach_refuses_empty_source(factory):
    from app.errors import AppError
    from app.schemas.plantings import AttachRequest, Position
    from app.services.plants import attach_to_area

    area = factory.area()
    src = factory.plant(species_id=factory.species().id, quantity="a few")
    with pytest.raises(AppError) as err:
        attach_to_area(
            factory.db, GARDEN_ID, src.id,
            AttachRequest(area_id=area.id, position=Position(x=1, y=1)),
        )
    assert err.value.code == "plant_none_left_to_place"


def test_detach_auto_merges_unattached_duplicate(factory):
    from app.models.planting import Plant
    from app.services.plants import detach_from_area

    db = factory.db
    sp = factory.species()
    area = factory.area()
    floater = factory.plant(species_id=sp.id, quantity="1")  # unattached pool row
    attached = factory.plant(
        species_id=sp.id, area_id=area.id, quantity="4",
        positions=json.dumps([{"x": 1, "y": 1}]),
    )
    out = detach_from_area(db, GARDEN_ID, attached.id)
    assert out["merged_into"] == floater.id
    assert floater.quantity == "5"
    db.expire_all()
    assert db.get(Plant, attached.id) is None


def test_detach_planted_plant_does_not_merge(factory):
    from app.services.plants import detach_from_area

    sp = factory.species()
    area = factory.area()
    planting = factory.planting()
    factory.plant(species_id=sp.id, status="planted", planting_id=planting.id)
    attached = factory.plant(
        species_id=sp.id, status="planted", planting_id=planting.id,
        area_id=area.id, positions=json.dumps([{"x": 1, "y": 1}]),
    )
    out = detach_from_area(factory.db, GARDEN_ID, attached.id)
    assert out["merged_into"] is None
    assert attached.area_id is None
    assert attached.positions == ""


def test_update_quantity_zero_sets_removed_and_detaches(factory):
    from app.schemas.plantings import PlantUpdate
    from app.services.plants import update_plant

    area = factory.area()
    plant = factory.plant(
        species_id=factory.species().id, status="planted",
        planting_id=factory.planting().id, area_id=area.id, quantity="6",
    )
    out = update_plant(factory.db, GARDEN_ID, plant.id, PlantUpdate(quantity=0))
    assert out["status"] == "removed"
    assert out["area_id"] is None
    assert out["quantity"] == "0"
    # audit: quantity, area_id and status edits logged
    fields = {r.field for r in _audit_rows(factory.db, "plant", plant.id)}
    assert {"quantity", "area_id", "status"} <= fields
    # a non-numeric quantity does NOT trigger the rule (v1 parity)
    plant2 = factory.plant(species_id=factory.species().id, status="planted", quantity="6")
    out2 = update_plant(factory.db, GARDEN_ID, plant2.id, PlantUpdate(quantity="a few"))
    assert out2["status"] == "planted"


def test_update_plant_variety_mismatch_guard(factory):
    from app.schemas.plantings import PlantUpdate
    from app.services.plants import update_plant

    sp1, sp2 = factory.species(), factory.species()
    v2 = factory.variety(sp2.id)
    plant = factory.plant(species_id=sp1.id)
    out = update_plant(
        factory.db, GARDEN_ID, plant.id,
        PlantUpdate(species_id=sp1.id, variety_id=v2.id),
    )
    assert out["variety_id"] is None  # variety of another species dropped


def test_set_pool_status_flips_and_refuses_planted(factory):
    from app.errors import AppError
    from app.services.plants import set_pool_status

    plant = factory.plant(species_id=factory.species().id, status="idea")
    out = set_pool_status(factory.db, GARDEN_ID, plant.id, "unplanted")
    assert out["status"] == "unplanted"
    planted = factory.plant(species_id=factory.species().id, status="planted")
    with pytest.raises(AppError) as err:
        set_pool_status(factory.db, GARDEN_ID, planted.id, "idea")
    assert err.value.code == "plant_not_in_pool"
    with pytest.raises(AppError) as err:
        set_pool_status(factory.db, GARDEN_ID, plant.id, "bogus")
    assert err.value.code == "plant_pool_status_invalid"


def test_mark_planted_existing_planting(factory):
    from app.schemas.plantings import MarkPlantedRequest
    from app.services.plants import mark_planted

    area = factory.area()
    planting = factory.planting()
    plant = factory.plant(
        species_id=factory.species().id, status="unplanted", area_id=area.id,
        positions=json.dumps([{"x": 1, "y": 1}]), quantity="3",
    )
    out = mark_planted(
        factory.db, GARDEN_ID, plant.id, MarkPlantedRequest(planting_id=planting.id)
    )
    assert out["status"] == "planted"
    assert out["planting_id"] == planting.id
    # placement transfers: area + positions kept
    assert out["area_id"] == area.id
    assert json.loads(out["positions"]) == [{"x": 1, "y": 1}]
    fields = {r.field for r in _audit_rows(factory.db, "plant", plant.id)}
    assert {"status", "planting_id"} <= fields


def test_mark_planted_new_planting_and_validation(factory):
    from app.errors import AppError
    from app.models.planting import Planting
    from app.schemas.plantings import MarkPlantedRequest
    from app.services.plants import mark_planted
    from app.services.plantings import get_or_create_year

    db = factory.db
    year_id = get_or_create_year(db, GARDEN_ID, 2026)
    plant = factory.plant(species_id=factory.species().id, status="idea")
    name = _uniq("Fall planting")
    out = mark_planted(
        db, GARDEN_ID, plant.id,
        MarkPlantedRequest(planting_name=name, year_id=year_id),
    )
    planting = db.get(Planting, out["planting_id"])
    assert planting.name == name
    assert planting.year_id == year_id
    assert planting.status == "planted"

    other = factory.plant(species_id=factory.species().id, status="idea")
    with pytest.raises(AppError) as err:
        mark_planted(db, GARDEN_ID, other.id, MarkPlantedRequest())
    assert err.value.code == "plant_planting_required"
    with pytest.raises(AppError) as err:
        mark_planted(db, GARDEN_ID, other.id, MarkPlantedRequest(planting_id=999999))
    assert err.value.code == "plant_planting_not_found"


def test_create_pool_plant_rules(factory):
    from app.errors import AppError
    from app.schemas.plantings import PoolPlantCreate
    from app.services.plants import create_pool_plant

    db = factory.db
    sp1, sp2 = factory.species(), factory.species()
    v2 = factory.variety(sp2.id)
    with pytest.raises(AppError) as err:
        create_pool_plant(db, GARDEN_ID, PoolPlantCreate(status="unplanted"))
    assert err.value.code == "plant_species_or_variety_required"

    out = create_pool_plant(
        db, GARDEN_ID,
        PoolPlantCreate(status="idea", species_id=sp1.id, variety_id=v2.id),
    )
    assert out["status"] == "idea"
    assert out["planting_id"] is None and out["area_id"] is None
    assert out["quantity"] == "1"  # default
    assert out["variety_id"] is None  # mismatch guard
    assert len(_audit_rows(db, "plant", out["id"])) == 1  # add logged


def test_create_planting_inline_rows(factory):
    from app.schemas.plantings import PlantingCreate, PlantRowIn
    from app.services.plantings import create_planting

    db = factory.db
    sp1, sp2 = factory.species(), factory.species()
    v2 = factory.variety(sp2.id)
    area = factory.area()
    out = create_planting(
        db, GARDEN_ID,
        PlantingCreate(
            name=_uniq("Spring"),
            new_year=2031,
            status="bogus",
            plants=[
                PlantRowIn(quantity=3, species_id=sp1.id, area_id=area.id),
                PlantRowIn(),  # empty row skipped
                PlantRowIn(quantity=2, species_id=sp1.id, variety_id=v2.id),
            ],
        ),
    )
    assert out["status"] == "planned"  # v1 wart: non-'planted' -> 'planned'
    assert out["year_value"] == 2031  # new_year created the year row
    assert out["group_count"] == 2  # empty row skipped
    assert out["total_qty"] == 5
    by_qty = {p["quantity"]: p for p in out["plants"]}
    assert by_qty["3"]["area_id"] == area.id
    assert by_qty["3"]["status"] == "planted"
    assert by_qty["2"]["variety_id"] is None  # mismatch guard on create too


def test_update_planting_row_sync_deletes_missing(factory):
    from app.schemas.plantings import PlantingUpdate, PlantRowIn
    from app.services.plantings import update_planting

    db = factory.db
    sp = factory.species()
    planting = factory.planting()
    keep = factory.plant(
        planting_id=planting.id, species_id=sp.id, status="planted", quantity="4"
    )
    drop = factory.plant(
        planting_id=planting.id, species_id=sp.id, status="planted", quantity="2"
    )
    out = update_planting(
        db, GARDEN_ID, planting.id,
        PlantingUpdate(
            notes="new overview",
            plants=[
                PlantRowIn(id=keep.id, quantity="9", species_id=sp.id),
                PlantRowIn(quantity="1", species_id=sp.id),  # new row
            ],
        ),
    )
    ids = {p["id"] for p in out["plants"]}
    assert keep.id in ids
    assert drop.id not in ids  # missing from the array -> deleted
    assert len(ids) == 2
    assert keep.quantity == "9"
    assert out["notes"] == "new overview"
    from app.models.planting import Plant
    db.expire_all()
    assert db.get(Plant, drop.id) is None
    # overview snapshot: planting.notes changed from '' -> snapshot of ''
    # only happens when there WAS content; here old was '' so no snapshot,
    # but a second notes change must snapshot the previous text.
    update_planting(db, GARDEN_ID, planting.id, PlantingUpdate(notes="second"))
    from sqlalchemy import select

    from app.models.history import OverviewVersion
    snaps = db.scalars(
        select(OverviewVersion).where(
            OverviewVersion.entity_type == "planting",
            OverviewVersion.entity_id == planting.id,
        )
    ).all()
    assert any(s.content == "new overview" for s in snaps)


def test_update_planting_name_required_and_year_validation(factory):
    from app.errors import AppError
    from app.schemas.plantings import PlantingUpdate
    from app.services.plantings import update_planting

    planting = factory.planting()
    with pytest.raises(AppError) as err:
        update_planting(factory.db, GARDEN_ID, planting.id, PlantingUpdate(name="  "))
    assert err.value.code == "planting_name_required"
    with pytest.raises(AppError) as err:
        update_planting(
            factory.db, GARDEN_ID, planting.id, PlantingUpdate(year_id=999999)
        )
    assert err.value.code == "planting_year_not_found"


def test_delete_planting_orphans_plants_v1_parity(factory):
    """v1 quirk carried: planting delete leaves plants with planting_id NULL
    (FK SET NULL) while logging cascade delete rows for them."""
    from app.models.planting import Plant, Planting
    from app.services.plantings import delete_planting

    db = factory.db
    planting = factory.planting()
    plant = factory.plant(
        planting_id=planting.id, species_id=factory.species().id, status="planted"
    )
    delete_planting(db, GARDEN_ID, planting.id)
    db.expire_all()
    assert db.get(Planting, planting.id) is None
    survivor = db.get(Plant, plant.id)
    assert survivor is not None
    assert survivor.planting_id is None
    categories = {r.category for r in _audit_rows(db, "plant", plant.id)}
    assert "delete" in categories  # the v1 cascade log row


def test_delete_plant_cleans_field_note_targets(factory):
    from sqlalchemy import select

    from app.models.notes import FieldNote, FieldNoteTarget
    from app.models.planting import Plant
    from app.services.plants import delete_plant

    db = factory.db
    plant = factory.plant(species_id=factory.species().id)
    note = FieldNote(
        garden_id=GARDEN_ID, created_at=_now(), status="processed",
        body="note on the plant", kind="observation",
    )
    db.add(note)
    db.flush()
    db.add(
        FieldNoteTarget(
            field_note_id=note.id, target_type="plant", target_id=plant.id,
            is_primary=True,
        )
    )
    db.flush()
    delete_plant(db, GARDEN_ID, plant.id)
    db.expire_all()
    assert db.get(Plant, plant.id) is None
    # orphaned note (no remaining targets) is deleted too — v1 semantics
    assert db.get(FieldNote, note.id) is None
    assert (
        db.scalars(
            select(FieldNoteTarget).where(FieldNoteTarget.field_note_id == note.id)
        ).first()
        is None
    )


def test_move_split_and_full(factory):
    from app.errors import AppError
    from app.models.planting import Plant
    from app.schemas.plantings import MoveRequest
    from app.services.plants import move_plant

    db = factory.db
    sp = factory.species()
    a1, a2 = factory.area(), factory.area()
    planting = factory.planting()
    plant = factory.plant(
        planting_id=planting.id, species_id=sp.id, status="planted",
        area_id=a1.id, quantity="5",
    )
    out = move_plant(
        db, GARDEN_ID, plant.id, MoveRequest(target_area_id=a2.id, move_qty=2)
    )
    assert out["split"] is True
    assert plant.quantity == "3" and plant.area_id == a1.id
    split_row = db.get(Plant, out["new_plant_id"])
    assert split_row.area_id == a2.id
    assert split_row.quantity == "2"
    assert split_row.planting_id == planting.id

    out2 = move_plant(
        db, GARDEN_ID, plant.id, MoveRequest(target_area_id=a2.id, move_qty=3)
    )
    assert out2["split"] is False
    assert plant.area_id == a2.id  # in-place move

    with pytest.raises(AppError) as err:
        move_plant(db, GARDEN_ID, plant.id, MoveRequest(target_area_id=a2.id, move_qty=0))
    assert err.value.code == "plant_move_qty_invalid"
    with pytest.raises(AppError) as err:
        move_plant(db, GARDEN_ID, plant.id, MoveRequest(target_area_id=a2.id, move_qty=99))
    assert err.value.code == "plant_move_qty_exceeds"
    fuzzy = factory.plant(species_id=sp.id, quantity="a few")
    with pytest.raises(AppError) as err:
        move_plant(db, GARDEN_ID, fuzzy.id, MoveRequest(target_area_id=a2.id, move_qty=1))
    assert err.value.code == "plant_quantity_not_numeric"


def test_maybe_archive_planting_up_cascade(factory):
    from app.services.plantings import maybe_archive_planting

    db = factory.db
    sp = factory.species()
    planting = factory.planting()
    p1 = factory.plant(planting_id=planting.id, species_id=sp.id, status="planted")
    p2 = factory.plant(planting_id=planting.id, species_id=sp.id, status="planted")
    assert maybe_archive_planting(db, GARDEN_ID, planting.id) is False  # none archived
    p1.is_archived = True
    db.flush()
    assert maybe_archive_planting(db, GARDEN_ID, planting.id) is False  # one left
    p2.is_archived = True
    db.flush()
    assert maybe_archive_planting(db, GARDEN_ID, planting.id) is True
    assert planting.is_archived is True
    assert maybe_archive_planting(db, GARDEN_ID, planting.id) is False  # idempotent
    rows = _audit_rows(db, "planting", planting.id)
    assert any("auto: all plants archived" in r.summary for r in rows)
    # a planting with NO plants is never auto-archived
    empty = factory.planting()
    assert maybe_archive_planting(db, GARDEN_ID, empty.id) is False


def test_planting_total_qty_non_numeric_counts_as_one(factory):
    from app.services.plantings import planting_total_qty

    sp = factory.species()
    planting = factory.planting()
    factory.plant(planting_id=planting.id, species_id=sp.id, quantity="4", status="planted")
    factory.plant(planting_id=planting.id, species_id=sp.id, quantity="a few", status="planted")
    archived = factory.plant(
        planting_id=planting.id, species_id=sp.id, quantity="7",
        status="planted", is_archived=True,
    )
    groups, total = planting_total_qty(factory.db, GARDEN_ID, planting.id)
    assert (groups, total) == (2, 5)  # archived excluded; 'a few' counts as 1
    groups, total = planting_total_qty(
        factory.db, GARDEN_ID, planting.id, include_archived=True
    )
    assert (groups, total) == (3, 12)
    assert archived.quantity == "7"


def test_add_plant_to_planting(factory):
    from app.errors import AppError
    from app.schemas.plantings import PlantRowIn
    from app.services.plantings import add_plant_to_planting

    sp = factory.species()
    planting = factory.planting()
    out = add_plant_to_planting(
        factory.db, GARDEN_ID, planting.id,
        PlantRowIn(quantity=6, species_id=sp.id),
    )
    assert out["planting_id"] == planting.id
    assert out["status"] == "planted"
    assert out["quantity"] == "6"
    with pytest.raises(AppError) as err:
        add_plant_to_planting(
            factory.db, GARDEN_ID, 999999, PlantRowIn(quantity=1, species_id=sp.id)
        )
    assert err.value.code == "planting_not_found"
