"""Behavior tests for the CATALOG + SUPPLIES + STATIONS + YEARS port.

Two tiers:

- Pure-unit tests (registry, kind normalization, spacing parsers) run
  anywhere, including the host without a DB.
- DB tests use the conftest ``db_session`` fixture (SAVEPOINT-wrapped on the
  real Postgres, always rolled back; skipped when DATABASE_URL is unset).
  Read assertions target the migrated v1 data (garden 1): 78 species /
  191 varieties / 15 stations / 8 supplies / 8 years.
"""
from __future__ import annotations

import pytest

from app.errors import AppError, VALIDATION_FAILED
from app.services import catalog, inventory, years
from app.services.species_icons import (
    DEFAULT_SPECIES_ICON,
    SPECIES_ICONS,
    SPECIES_SPACING_DEFAULTS,
    get_icon_descriptor,
    spacing_default_for,
    species_icon,
    species_icon_color,
)

GARDEN = 1


# --- registry (verbatim port) -------------------------------------------------

def test_icon_registry_is_complete():
    # Guard against accidental truncation of the verbatim port.
    assert len(SPECIES_ICONS) == 64
    assert len(SPECIES_SPACING_DEFAULTS) == 63
    # v1 kept the "Brocolli" typo key alongside the canonical spelling.
    assert "Brocolli" in SPECIES_ICONS and "Broccoli" in SPECIES_ICONS


def test_species_icon_lookup_and_fallback():
    assert species_icon("Strawberry") == {"type": "emoji", "char": "\U0001F353"}
    assert species_icon("Mason Bee") == {"type": "svg", "id": "icon-mason-bee"}
    # Branchberry intentionally reuses the blackberry artwork.
    assert species_icon("Branchberry") == {"type": "svg", "id": "icon-blackberry"}
    assert species_icon("Totally Unknown") == DEFAULT_SPECIES_ICON
    assert species_icon(None) == DEFAULT_SPECIES_ICON
    assert DEFAULT_SPECIES_ICON == {"type": "svg", "id": "icon-leaf"}


def test_species_icon_color_stable_hash():
    a = species_icon_color("Tomato")
    assert a == species_icon_color("Tomato")          # stable
    assert a == species_icon_color("  tomato  ")      # case/space-insensitive
    assert a.startswith("hsl(") and a.endswith(", 55%, 50%)")
    assert species_icon_color("") == "hsl(0, 0%, 70%)"
    assert species_icon_color(None) == "hsl(0, 0%, 70%)"
    assert species_icon_color("Tomato") != species_icon_color("Basil")


def test_get_icon_descriptor_shape():
    d = get_icon_descriptor("Tomato")
    assert set(d) == {"icon", "color"}
    assert d["icon"] == {"type": "emoji", "char": "\U0001F345"}


def test_spacing_defaults_registry():
    assert ("Tomato", 1, 2.0) in SPECIES_SPACING_DEFAULTS
    assert ("White Clover", 16, 0.5) in SPECIES_SPACING_DEFAULTS
    assert ("Mason Bee", 1, 1.0) in SPECIES_SPACING_DEFAULTS
    assert spacing_default_for("tomato") == (1, 2.0)   # case-insensitive
    assert spacing_default_for(" Radish ") == (9, 0.5)
    assert spacing_default_for("Nope") is None
    assert spacing_default_for(None) is None


# --- normalize_species_kind (v1 main.py:96-106) --------------------------------

@pytest.mark.parametrize(
    ("type_in", "fn_in", "expected"),
    [
        ("plant", "edible", ("plant", "edible")),
        ("plant", "weed", ("plant", "weed")),
        ("animal", "pest", ("animal", "pest")),
        # invalid function for the type -> first valid function
        ("animal", "edible", ("animal", "beneficial")),
        ("plant", "pest", ("plant", "edible")),
        # unknown type -> plant (+ function re-checked against plant)
        ("fungus", "beneficial", ("plant", "edible")),
        # empty / None -> full fallback
        ("", "", ("plant", "edible")),
        (None, None, ("plant", "edible")),
        # case / whitespace coercion
        ("ANIMAL", "  Pest ", ("animal", "pest")),
    ],
)
def test_normalize_species_kind(type_in, fn_in, expected):
    assert catalog.normalize_species_kind(type_in, fn_in) == expected


# --- spacing parsers (v1 semantics: forgiving, clamped) -------------------------

def test_parse_spacing_defaults_and_clamps():
    assert catalog._parse_spacing("", "") == (1, 1.0)
    assert catalog._parse_spacing(None, None) == (1, 1.0)
    assert catalog._parse_spacing("3", "2") == (3, 2.0)
    assert catalog._parse_spacing(5, 2.5) == (5, 2.5)     # non-str input
    assert catalog._parse_spacing("0", "0.01") == (1, 0.05)  # clamped
    assert catalog._parse_spacing("-4", "-1") == (1, 0.05)
    assert catalog._parse_spacing("abc", "xyz") == (1, 1.0)  # invalid -> default
    assert catalog._parse_spacing("", "", 4, 0.5) == (4, 0.5)


def test_parse_optional_spacing_null_means_inherit():
    assert catalog._parse_optional_spacing("", "") == (None, None)
    assert catalog._parse_optional_spacing(None, None) == (None, None)
    assert catalog._parse_optional_spacing("2", "1.5") == (2, 1.5)
    assert catalog._parse_optional_spacing("0", "0.01") == (1, 0.05)  # clamped
    assert catalog._parse_optional_spacing("junk", "junk") == (None, None)


# --- DB reads: migrated v1 data --------------------------------------------------

def test_migrated_species(db_session):
    rows = catalog.list_species(db_session, GARDEN, include_archived=True)
    assert len(rows) == 78
    by_name = {r["name"]: r for r in rows}
    assert "Strawberry" in by_name
    assert by_name["Strawberry"]["icon"] == {"type": "emoji", "char": "\U0001F353"}
    mason = by_name["Mason Bee"]
    assert mason["type"] == "animal"
    assert mason["icon"] == {"type": "svg", "id": "icon-mason-bee"}
    # every payload carries the icon descriptor
    assert all("icon" in r and "color" in r for r in rows)
    # type filter
    animals = catalog.list_species(
        db_session, GARDEN, include_archived=True, type_filter="animal"
    )
    assert all(r["type"] == "animal" for r in animals)
    assert any(r["name"] == "Mason Bee" for r in animals)


def test_migrated_varieties(db_session):
    rows = catalog.list_varieties(db_session, GARDEN, include_archived=True)
    assert len(rows) == 191
    # effective spacing is always resolved (COALESCE chain)
    assert all(r["effective_plants_per_unit"] >= 1 for r in rows)
    assert all(r["effective_space_per_unit_sqft"] >= 0.05 for r in rows)


def test_migrated_stations_supplies_years(db_session):
    assert len(inventory.list_stations(db_session, GARDEN, include_archived=True)) == 15
    assert len(inventory.list_supplies(db_session, GARDEN, include_archived=True)) == 8
    year_rows = years.list_years(db_session, GARDEN, include_archived=True)
    assert len(year_rows) == 8
    # v1 ordering: year DESC
    values = [y["year"] for y in year_rows]
    assert values == sorted(values, reverse=True)


def test_species_detail_shape(db_session):
    rows = catalog.list_species(db_session, GARDEN, include_archived=True)
    strawberry = next(r for r in rows if r["name"] == "Strawberry")
    detail = catalog.get_species(db_session, GARDEN, strawberry["id"])
    assert detail["id"] == strawberry["id"]
    assert isinstance(detail["varieties"], list)
    assert isinstance(detail["plantings"], list)
    assert detail["plant_count"] >= 0
    for v in detail["varieties"]:
        assert v["species_id"] == strawberry["id"]


# --- DB writes (rolled back by the fixture) ---------------------------------------

def _log_count(db, entity_type: str, category: str) -> int:
    from sqlalchemy import func, select

    from app.models.history import ActivityLog

    return db.scalar(
        select(func.count()).where(
            ActivityLog.entity_type == entity_type,
            ActivityLog.category == category,
        )
    )


def test_species_create_dedupes_by_name(db_session):
    first = catalog.create_species(
        db_session, GARDEN, name="  Test Dedupe Species XYZ  "
    )
    assert first["created"] is True
    assert first["name"] == "Test Dedupe Species XYZ"  # stripped
    adds_after_first = _log_count(db_session, "species", "add")

    again = catalog.create_species(
        db_session, GARDEN, name="Test Dedupe Species XYZ",
        description="ignored on dedupe",
    )
    assert again["created"] is False
    assert again["id"] == first["id"]
    # dedupe hit writes NO second activity-log row
    assert _log_count(db_session, "species", "add") == adds_after_first


def test_species_create_normalizes_kind_and_seeds_spacing(db_session):
    sp = catalog.create_species(
        db_session, GARDEN, name="Test Kind Coercion XYZ",
        type_="fungus", primary_function="pest",
    )
    assert (sp["type"], sp["primary_function"]) == ("plant", "edible")
    # no registry match + no explicit spacing -> hard defaults
    assert (sp["plants_per_unit"], sp["space_per_unit_sqft"]) == (1, 1.0)
    # payload always carries the icon descriptor (default leaf here)
    assert sp["icon"] == DEFAULT_SPECIES_ICON

    with pytest.raises(AppError) as exc:
        catalog.create_species(db_session, GARDEN, name="   ")
    assert exc.value.code == catalog.NAME_REQUIRED


def test_species_spacing_update_validates(db_session):
    sp = catalog.create_species(db_session, GARDEN, name="Test Spacing XYZ")
    out = catalog.update_species_spacing(
        db_session, GARDEN, sp["id"],
        plants_per_unit="0", space_per_unit_sqft="0.01",
    )
    assert (out["plants_per_unit"], out["space_per_unit_sqft"]) == (1, 0.05)

    out = catalog.update_species_spacing(
        db_session, GARDEN, sp["id"],
        plants_per_unit="4", space_per_unit_sqft="2.5",
    )
    assert (out["plants_per_unit"], out["space_per_unit_sqft"]) == (4, 2.5)

    # blank resets to the v1 hard defaults (1, 1.0)
    out = catalog.update_species_spacing(db_session, GARDEN, sp["id"])
    assert (out["plants_per_unit"], out["space_per_unit_sqft"]) == (1, 1.0)

    with pytest.raises(AppError) as exc:
        catalog.update_species_spacing(db_session, GARDEN, 99999999)
    assert exc.value.code == catalog.SPECIES_NOT_FOUND
    assert exc.value.http_status == 404


def test_species_patch_type_clamps_function(db_session):
    sp = catalog.create_species(
        db_session, GARDEN, name="Test Clamp XYZ",
        type_="plant", primary_function="weed",
    )
    out = catalog.update_species(
        db_session, GARDEN, sp["id"], {"type": "animal"}
    )
    # 'weed' is invalid for animal -> clamped to first valid option
    assert (out["type"], out["primary_function"]) == ("animal", "beneficial")

    with pytest.raises(AppError) as exc:
        catalog.update_species(db_session, GARDEN, sp["id"], {"bogus": 1})
    assert exc.value.code == VALIDATION_FAILED


def test_variety_species_mismatch_guard(db_session):
    with pytest.raises(AppError) as exc:
        catalog.create_variety(
            db_session, GARDEN, name="Orphan Guard XYZ", species_id=99999999
        )
    assert exc.value.code == catalog.SPECIES_NOT_FOUND

    sp = catalog.create_species(db_session, GARDEN, name="Test Variety Host XYZ")
    v = catalog.create_variety(
        db_session, GARDEN, name="Test Variety XYZ", species_id=sp["id"]
    )
    assert v["created"] is True
    assert v["species_name"] == "Test Variety Host XYZ"

    # dedupe on exact (species_id, name)
    again = catalog.create_variety(
        db_session, GARDEN, name="Test Variety XYZ", species_id=sp["id"]
    )
    assert again["created"] is False and again["id"] == v["id"]

    with pytest.raises(AppError) as exc:
        catalog.update_variety(
            db_session, GARDEN, v["id"], {"species_id": 99999999}
        )
    assert exc.value.code == catalog.SPECIES_NOT_FOUND


def test_variety_spacing_override_inherits_when_cleared(db_session):
    sp = catalog.create_species(
        db_session, GARDEN, name="Test Inherit Host XYZ",
        plants_per_unit="4", space_per_unit_sqft="2.0",
    )
    v = catalog.create_variety(
        db_session, GARDEN, name="Test Inherit XYZ", species_id=sp["id"],
        plants_per_unit="9", space_per_unit_sqft="0.5",
    )
    assert (v["plants_per_unit"], v["space_per_unit_sqft"]) == (9, 0.5)
    assert (v["effective_plants_per_unit"], v["effective_space_per_unit_sqft"]) == (9, 0.5)

    # blank -> NULL override -> effective falls back to the species values
    out = catalog.update_variety_spacing(db_session, GARDEN, v["id"])
    assert (out["plants_per_unit"], out["space_per_unit_sqft"]) == (None, None)
    assert (out["effective_plants_per_unit"], out["effective_space_per_unit_sqft"]) == (4, 2.0)


def test_station_areas_replace(db_session):
    from sqlalchemy import select

    from app.models.garden import Area

    area_ids = list(
        db_session.scalars(
            select(Area.id).where(Area.garden_id == GARDEN).order_by(Area.id).limit(2)
        )
    )
    if len(area_ids) < 2:
        pytest.skip("migrated data has fewer than 2 areas")

    st = inventory.create_station(
        db_session, GARDEN, name="Test Station XYZ", area_ids=[area_ids[0]]
    )
    assert [a["id"] for a in st["areas"]] == [area_ids[0]]

    stored = inventory.set_station_areas(
        db_session, GARDEN, st["id"], area_ids
    )
    assert stored == sorted(area_ids)

    # full replace, not additive — and duplicates collapse (v1 INSERT OR IGNORE)
    stored = inventory.set_station_areas(
        db_session, GARDEN, st["id"], [area_ids[1], area_ids[1]]
    )
    assert stored == [area_ids[1]]

    # all-or-nothing: one unknown id rejects the whole set, nothing changes
    with pytest.raises(AppError) as exc:
        inventory.set_station_areas(
            db_session, GARDEN, st["id"], [area_ids[0], 99999999]
        )
    assert exc.value.code == inventory.UNKNOWN_AREA_IDS
    detail = inventory.get_station(db_session, GARDEN, st["id"])
    assert [a["id"] for a in detail["areas"]] == [area_ids[1]]

    # empty list clears the set
    assert inventory.set_station_areas(db_session, GARDEN, st["id"], []) == []


def test_supply_crud_roundtrip(db_session):
    s = inventory.create_supply(
        db_session, GARDEN, name="Test Supply XYZ", category="soil",
        quantity_on_hand="2 bags", needed=False,
    )
    assert s["needed"] is False and s["amount"] == ""

    out = inventory.update_supply(
        db_session, GARDEN, s["id"], {"needed": True, "quantity_needed": "3"}
    )
    assert out["needed"] is True and out["quantity_needed"] == "3"

    inventory.delete_supply(db_session, GARDEN, s["id"])
    with pytest.raises(AppError) as exc:
        inventory.get_supply(db_session, GARDEN, s["id"])
    assert exc.value.code == inventory.SUPPLY_NOT_FOUND


def test_year_create_and_get_or_create(db_session):
    y = years.create_year(db_session, GARDEN, year=2098, notes="test year")
    assert y["year"] == 2098

    with pytest.raises(AppError) as exc:
        years.create_year(db_session, GARDEN, year=2098)
    assert exc.value.code == years.YEAR_EXISTS
    assert exc.value.http_status == 409

    with pytest.raises(AppError) as exc:
        years.create_year(db_session, GARDEN, year=1800)
    assert exc.value.code == years.YEAR_OUT_OF_RANGE

    # get_or_create: returns the existing row, creates only when missing
    same = years.get_or_create_year(db_session, GARDEN, 2098)
    assert same.id == y["id"]
    new = years.get_or_create_year(db_session, GARDEN, 2099)
    assert new.year == 2099 and new.id != y["id"]
    assert years.get_or_create_year(db_session, GARDEN, 2099).id == new.id
