"""Behavior tests for the inline-edit, archive, and export domains + the
agent-token auth path.

Pure-unit tests (whitelist/coercion/agent-token) run anywhere. DB-backed
tests use the ``db_session`` rollback fixture and skip on the host when
DATABASE_URL is unset (they run in-container per conftest.py).
"""
from __future__ import annotations

import json
from datetime import datetime, timezone
from types import SimpleNamespace

import pytest
from sqlalchemy import select

from app.errors import AppError
from app.models.garden import Area, WateringStation, Year
from app.models.history import ActivityLog, OverviewVersion
from app.models.catalog import Species, Variety
from app.models.identity import Garden
from app.models.planting import Plant, Planting
from app.services import agent_tokens
from app.services.archive import (
    ARCHIVABLE_ENTITIES,
    archive_entity,
    unarchive_entity,
)
from app.services.editable import (
    EDITABLE_FIELDS,
    coerce_value,
    edit_field,
)
from app.services.export import V1_EXPORT_TABLES, build_media_manifest, export_garden

NOW = datetime.now(timezone.utc)


# ---------------------------------------------------------------------------
# whitelist + coercion (no DB — edit_field validates before touching the db)
# ---------------------------------------------------------------------------

def test_unknown_entity_type_rejected():
    with pytest.raises(AppError) as e:
        edit_field(None, 1, "gnome", 1, "name", "x")
    assert e.value.code == "edit_entity_unknown"
    assert e.value.http_status == 404


def test_bad_field_rejected():
    with pytest.raises(AppError) as e:
        edit_field(None, 1, "area", 1, "is_archived", True)  # not whitelisted
    assert e.value.code == "edit_field_not_allowed"
    assert e.value.http_status == 400


def test_bad_select_option_rejected():
    with pytest.raises(AppError) as e:
        edit_field(None, 1, "area", 1, "sketch_rotation", 30)  # not a 45° step
    assert e.value.code == "edit_value_invalid"


def test_required_text_rejected_when_blank():
    with pytest.raises(AppError) as e:
        edit_field(None, 1, "area", 1, "name", "   ")
    assert e.value.code == "edit_value_invalid"


def test_int_bounds():
    spec = EDITABLE_FIELDS["year"]["year"]
    assert coerce_value("year", spec, "2026") == 2026
    for bad in ("1500", 2101, "abc"):
        with pytest.raises(AppError) as e:
            coerce_value("year", spec, bad)
        assert e.value.code == "edit_value_invalid"


def test_float_blank_coerces_to_zero_and_min_enforced():
    spec = EDITABLE_FIELDS["area"]["length_ft"]
    assert coerce_value("length_ft", spec, "") == 0.0  # historical default
    assert coerce_value("length_ft", spec, None) == 0.0
    assert coerce_value("length_ft", spec, "12.5") == 12.5
    with pytest.raises(AppError):
        coerce_value("length_ft", spec, -1)


def test_select_loose_string_compare_returns_typed_option():
    spec = EDITABLE_FIELDS["area"]["sketch_rotation"]
    assert coerce_value("sketch_rotation", spec, "45") == 45  # int, not "45"
    assert coerce_value("sketch_rotation", spec, 90) == 90


def test_fk_coercion_nullable_forms():
    spec = EDITABLE_FIELDS["planting"]["year_id"]
    for empty in (None, "", 0, "0"):
        assert coerce_value("year_id", spec, empty) is None
    assert coerce_value("year_id", spec, "7") == 7
    with pytest.raises(AppError):
        coerce_value("year_id", spec, "abc")


def test_planting_status_kept_as_single_value_select():
    spec = EDITABLE_FIELDS["planting"]["status"]
    assert spec["options"] == ["planted"]
    assert coerce_value("status", spec, "planted") == "planted"
    with pytest.raises(AppError):
        coerce_value("status", spec, "harvested")  # retired v1 value


def test_archivable_entity_map_matches_v1():
    assert set(ARCHIVABLE_ENTITIES) == {
        "area", "planting", "plant", "species", "variety",
        "station", "supply", "year", "comment",
    }


# ---------------------------------------------------------------------------
# agent token (unit)
# ---------------------------------------------------------------------------

def _req(auth_user=None, authorization: str | None = None):
    headers = {}
    if authorization is not None:
        headers["Authorization"] = authorization
    state = SimpleNamespace()
    if auth_user is not None:
        state.auth_user = auth_user
    return SimpleNamespace(state=state, headers=headers)


def test_agent_token_valid(monkeypatch):
    monkeypatch.setenv("AGENT_API_TOKEN", "sekrit-tok")
    assert agent_tokens.is_agent_token_request("/api/export", "Bearer sekrit-tok")
    assert agent_tokens.is_agent_token_request(
        "/api/export/media-manifest", "bearer sekrit-tok"  # scheme case-insensitive
    )
    # dependency passes silently
    agent_tokens.require_grant_or_agent_token(
        _req(authorization="Bearer sekrit-tok")
    )


def test_agent_token_invalid(monkeypatch):
    monkeypatch.setenv("AGENT_API_TOKEN", "sekrit-tok")
    assert not agent_tokens.is_agent_token_request("/api/export", "Bearer wrong")
    # non-allowlisted path never accepts a token, even a valid one
    assert not agent_tokens.is_agent_token_request("/api/edit/area/1", "Bearer sekrit-tok")
    # wrong scheme
    assert not agent_tokens.is_agent_token_request("/api/export", "Basic sekrit-tok")
    with pytest.raises(AppError) as e:
        agent_tokens.require_grant_or_agent_token(_req(authorization="Bearer wrong"))
    assert e.value.code == "auth_agent_token_invalid"
    assert e.value.http_status == 401


def test_agent_token_missing_or_unconfigured(monkeypatch):
    monkeypatch.setenv("AGENT_API_TOKEN", "sekrit-tok")
    assert not agent_tokens.is_agent_token_request("/api/export", "")
    with pytest.raises(AppError):
        agent_tokens.require_grant_or_agent_token(_req())
    # unset env = hard close, even with a matching-empty presentation
    monkeypatch.delenv("AGENT_API_TOKEN", raising=False)
    assert not agent_tokens.is_agent_token_request("/api/export", "Bearer ")
    assert not agent_tokens.is_agent_token_request("/api/export", "Bearer anything")
    with pytest.raises(AppError):
        agent_tokens.require_grant_or_agent_token(_req(authorization="Bearer anything"))


def test_grant_authed_request_passes_without_token(monkeypatch):
    monkeypatch.delenv("AGENT_API_TOKEN", raising=False)
    agent_tokens.require_grant_or_agent_token(_req(auth_user=object()))


def test_agent_paths_are_exact_allowlist():
    assert agent_tokens.AGENT_TOKEN_PATHS == (
        "/api/export", "/api/export/media-manifest",
    )


# ---------------------------------------------------------------------------
# media manifest (host-runnable — filesystem only)
# ---------------------------------------------------------------------------

def test_media_manifest_shape(tmp_path):
    (tmp_path / "notes" / "2026-07").mkdir(parents=True)
    (tmp_path / "notes" / "2026-07" / "a.webm").write_bytes(b"audio-bytes")
    (tmp_path / "artifacts").mkdir()
    (tmp_path / "artifacts" / "plan.md").write_text("# plan")
    (tmp_path / "garden.db.backup").write_text("not media")  # excluded

    m = build_media_manifest(str(tmp_path))
    assert set(m) == {"meta", "files"}
    assert set(m["files"]) == {"notes/2026-07/a.webm", "artifacts/plan.md"}
    entry = m["files"]["notes/2026-07/a.webm"]
    assert entry["size"] == len(b"audio-bytes")
    import hashlib
    assert entry["sha256"] == hashlib.sha256(b"audio-bytes").hexdigest()
    assert m["meta"]["file_count"] == 2


# ---------------------------------------------------------------------------
# DB-backed behavior (db_session rollback fixture; skips on host)
# ---------------------------------------------------------------------------

@pytest.fixture()
def garden_id(db_session) -> int:
    g = Garden(name="test-garden", created_at=NOW)
    db_session.add(g)
    db_session.flush()
    return g.id


def _add(db, obj):
    db.add(obj)
    db.flush()
    return obj


def test_edit_updates_field_and_logs(db_session, garden_id):
    area = _add(db_session, Area(garden_id=garden_id, name="Front Bed", created_at=NOW))
    out = edit_field(db_session, garden_id, "area", area.id, "name", "  Back Bed ")
    assert out == {"ok": True, "value": "Back Bed", "side_effects": {}}
    assert area.name == "Back Bed"
    log = db_session.execute(
        select(ActivityLog).where(
            ActivityLog.entity_type == "area", ActivityLog.entity_id == area.id,
            ActivityLog.category == "edit",
        )
    ).scalars().all()
    assert len(log) == 1
    assert log[0].field == "name"
    assert log[0].old_value == "Front Bed"
    assert log[0].new_value == "Back Bed"
    assert log[0].garden_id == garden_id


def test_snapshot_created_for_snapshot_fields(db_session, garden_id):
    area = _add(db_session, Area(
        garden_id=garden_id, name="Bed", notes="old overview", created_at=NOW,
    ))
    edit_field(db_session, garden_id, "area", area.id, "notes", "new overview")
    snaps = db_session.execute(
        select(OverviewVersion).where(
            OverviewVersion.entity_type == "area",
            OverviewVersion.entity_id == area.id,
        )
    ).scalars().all()
    assert len(snaps) == 1
    assert snaps[0].content == "old overview"  # the PREVIOUS value
    assert snaps[0].author == "user"
    assert area.notes == "new overview"


def test_no_snapshot_when_content_unchanged(db_session, garden_id):
    area = _add(db_session, Area(
        garden_id=garden_id, name="Bed", notes="same", created_at=NOW,
    ))
    edit_field(db_session, garden_id, "area", area.id, "notes", "same")
    n = db_session.execute(
        select(OverviewVersion).where(
            OverviewVersion.entity_type == "area",
            OverviewVersion.entity_id == area.id,
        )
    ).scalars().all()
    assert n == []


def test_no_snapshot_for_plain_fields(db_session, garden_id):
    station = _add(db_session, WateringStation(
        garden_id=garden_id, name="Zone 1", created_at=NOW,
    ))
    edit_field(db_session, garden_id, "station", station.id, "notes", "drip line")
    n = db_session.execute(
        select(OverviewVersion).where(
            OverviewVersion.entity_type == "station",
        )
    ).scalars().all()
    assert n == []  # station notes has no snapshot=True in v1


def test_edit_fk_validated_and_garden_scoped(db_session, garden_id):
    planting = _add(db_session, Planting(
        garden_id=garden_id, name="Spring", created_at=NOW,
    ))
    year = _add(db_session, Year(garden_id=garden_id, year=2031, created_at=NOW))
    out = edit_field(db_session, garden_id, "planting", planting.id, "year_id", str(year.id))
    assert out["value"] == year.id
    # missing fk id
    with pytest.raises(AppError) as e:
        edit_field(db_session, garden_id, "planting", planting.id, "year_id", 99999999)
    assert e.value.code == "edit_value_invalid"
    # fk row in ANOTHER garden is invisible
    g2 = _add(db_session, Garden(name="other", created_at=NOW))
    y2 = _add(db_session, Year(garden_id=g2.id, year=2032, created_at=NOW))
    with pytest.raises(AppError):
        edit_field(db_session, garden_id, "planting", planting.id, "year_id", y2.id)
    # clearing a nullable fk
    assert edit_field(
        db_session, garden_id, "planting", planting.id, "year_id", ""
    )["value"] is None


def test_edit_row_in_other_garden_is_not_found(db_session, garden_id):
    g2 = _add(db_session, Garden(name="other", created_at=NOW))
    area2 = _add(db_session, Area(garden_id=g2.id, name="Theirs", created_at=NOW))
    with pytest.raises(AppError) as e:
        edit_field(db_session, garden_id, "area", area2.id, "name", "Mine now")
    assert e.value.code == "resource_not_found"


def test_species_type_change_clamps_function(db_session, garden_id):
    sp = _add(db_session, Species(
        garden_id=garden_id, name="Ladybug-host", created_at=NOW,
        type="plant", primary_function="edible",
    ))
    out = edit_field(db_session, garden_id, "species", sp.id, "type", "animal")
    assert out["side_effects"] == {"primary_function": "beneficial"}
    assert sp.type == "animal"
    assert sp.primary_function == "beneficial"
    # side-effect gets its own log row, labeled (auto)
    logs = db_session.execute(
        select(ActivityLog).where(
            ActivityLog.entity_type == "species",
            ActivityLog.entity_id == sp.id,
        ).order_by(ActivityLog.id)
    ).scalars().all()
    assert [l.field for l in logs] == ["type", "primary_function"]
    assert "(auto)" in logs[1].summary


def test_species_invalid_function_for_type_rejected(db_session, garden_id):
    sp = _add(db_session, Species(
        garden_id=garden_id, name="Tomato-x", created_at=NOW,
        type="plant", primary_function="edible",
    ))
    with pytest.raises(AppError) as e:
        edit_field(db_session, garden_id, "species", sp.id, "primary_function", "pest")
    assert e.value.code == "edit_value_invalid"
    assert sp.primary_function == "edible"  # unchanged


def test_archive_cascades_down_planting_to_plants(db_session, garden_id):
    planting = _add(db_session, Planting(
        garden_id=garden_id, name="Spring", created_at=NOW,
    ))
    p1 = _add(db_session, Plant(
        garden_id=garden_id, planting_id=planting.id, status="planted",
        created_at=NOW, is_archived=False,
    ))
    p2 = _add(db_session, Plant(
        garden_id=garden_id, planting_id=planting.id, status="planted",
        created_at=NOW, is_archived=False,
    ))
    archive_entity(db_session, garden_id, "planting", planting.id)
    db_session.refresh(p1); db_session.refresh(p2)
    assert planting.is_archived is True
    assert p1.is_archived is True and p2.is_archived is True
    cascade_logs = db_session.execute(
        select(ActivityLog).where(
            ActivityLog.category == "archive",
            ActivityLog.entity_type == "plant",
            ActivityLog.entity_id.in_([p1.id, p2.id]),
            ActivityLog.garden_id == garden_id,
        )
    ).scalars().all()
    assert len(cascade_logs) == 2
    assert all("(cascade from planting)" in l.summary for l in cascade_logs)


def test_archive_cascades_up_last_plant_archives_planting(db_session, garden_id):
    planting = _add(db_session, Planting(
        garden_id=garden_id, name="Fall", created_at=NOW, is_archived=False,
    ))
    p1 = _add(db_session, Plant(
        garden_id=garden_id, planting_id=planting.id, status="planted",
        created_at=NOW, is_archived=False,
    ))
    p2 = _add(db_session, Plant(
        garden_id=garden_id, planting_id=planting.id, status="planted",
        created_at=NOW, is_archived=False,
    ))
    archive_entity(db_session, garden_id, "plant", p1.id)
    assert planting.is_archived is False  # one plant still active
    archive_entity(db_session, garden_id, "plant", p2.id)
    assert planting.is_archived is True  # auto up-cascade
    auto = db_session.execute(
        select(ActivityLog).where(
            ActivityLog.category == "archive",
            ActivityLog.entity_type == "planting",
            ActivityLog.entity_id == planting.id,
        )
    ).scalars().all()
    assert len(auto) == 1
    assert "(auto: all plants archived)" in auto[0].summary


def test_archive_cascades_down_area_descendants(db_session, garden_id):
    root = _add(db_session, Area(garden_id=garden_id, name="Yard", created_at=NOW))
    child = _add(db_session, Area(
        garden_id=garden_id, name="Bed", parent_id=root.id, created_at=NOW,
    ))
    grandchild = _add(db_session, Area(
        garden_id=garden_id, name="Row", parent_id=child.id, created_at=NOW,
    ))
    archive_entity(db_session, garden_id, "area", root.id)
    for a in (root, child, grandchild):
        db_session.refresh(a)
        assert a.is_archived is True


def test_archive_cascades_down_species_to_varieties(db_session, garden_id):
    sp = _add(db_session, Species(garden_id=garden_id, name="Bean-x", created_at=NOW))
    v = _add(db_session, Variety(species_id=sp.id, name="Blue Lake", created_at=NOW))
    archive_entity(db_session, garden_id, "species", sp.id)
    db_session.refresh(v)
    assert v.is_archived is True


def test_unarchive_does_not_cascade(db_session, garden_id):
    planting = _add(db_session, Planting(
        garden_id=garden_id, name="Winter", created_at=NOW,
    ))
    plant = _add(db_session, Plant(
        garden_id=garden_id, planting_id=planting.id, status="planted",
        created_at=NOW, is_archived=False,
    ))
    archive_entity(db_session, garden_id, "planting", planting.id)
    unarchive_entity(db_session, garden_id, "planting", planting.id)
    db_session.refresh(plant)
    assert planting.is_archived is False
    assert plant.is_archived is True  # stays archived — explicit revive only


def test_archive_unknown_type_404(db_session, garden_id):
    with pytest.raises(AppError) as e:
        archive_entity(db_session, garden_id, "gnome", 1)
    assert e.value.code == "archive_entity_unknown"
    assert e.value.http_status == 404


def test_export_shape_matches_harness_expectations(db_session, garden_id):
    # a little data so sortedness is meaningful
    a2 = _add(db_session, Area(garden_id=garden_id, name="B", created_at=NOW))
    a1 = _add(db_session, Area(garden_id=garden_id, name="A", created_at=NOW))
    sp = _add(db_session, Species(garden_id=garden_id, name="Kale-x", created_at=NOW))
    _add(db_session, Variety(species_id=sp.id, name="Lacinato", created_at=NOW))

    out = export_garden(db_session, garden_id)
    assert set(out) == {"meta", "tables"}
    # all 17 v1-named tables, exactly
    assert set(out["tables"]) == set(V1_EXPORT_TABLES)
    assert len(out["tables"]) == 17
    # rows sorted by id (area_stations by composite key)
    for name, rows in out["tables"].items():
        if name == "area_stations":
            keys = [(r["area_id"], r["station_id"]) for r in rows]
        else:
            keys = [r["id"] for r in rows]
        assert keys == sorted(keys), f"{name} not sorted"
    # meta counts match
    assert out["meta"]["garden_id"] == garden_id
    for name, rows in out["tables"].items():
        assert out["meta"]["table_counts"][name] == len(rows)
    # our fixture rows are present and JSON-serializable end to end
    ids = [r["id"] for r in out["tables"]["areas"]]
    assert a1.id in ids and a2.id in ids
    json.dumps(out)  # datetimes/dates already stringified


def test_export_is_garden_scoped(db_session, garden_id):
    mine = _add(db_session, Area(garden_id=garden_id, name="Mine", created_at=NOW))
    g2 = _add(db_session, Garden(name="other", created_at=NOW))
    theirs = _add(db_session, Area(garden_id=g2.id, name="Theirs", created_at=NOW))
    out = export_garden(db_session, garden_id)
    ids = {r["id"] for r in out["tables"]["areas"]}
    assert mine.id in ids
    assert theirs.id not in ids
