"""PATCH semantics: field-level edits, the entity-chain rules
(T-ENT-29 — full trio / picked project / explicit unlink; partial
errors), the re-stamp matrix (T-LOCK-002/003, T-ENT-27/28), lock rules
(T-ENT-40 / T-INV-014), and audit rows with before-images."""

from decimal import Decimal

import pytest
from sqlalchemy import select

from app.models import AuditLog, Party, Project
from app.services.entries_write import parse_duration_seconds, update_entry
from app.services.errors import ServiceError


def err(fn, *args, **kwargs) -> ServiceError:
    with pytest.raises(ServiceError) as exc_info:
        fn(*args, **kwargs)
    return exc_info.value


# ------------------------------------------------------------ cell edits


def test_description_cell_edit_touches_only_description(g, rian):
    e = g.entries["adi"]
    before_cost = Decimal(str(e.cost_amount))
    update_entry(g.session, rian, e.id, {"description": "  new text  "})
    assert e.description == "new text"
    assert Decimal(str(e.cost_amount)) == before_cost  # no re-stamp


def test_billable_toggle(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"billable": False})
    assert e.billable is False


def test_start_required(g, rian):
    e = g.entries["adi"]
    error = err(update_entry, g.session, rian, e.id, {"start_at": ""})
    assert error.summary == "Start date/time is required."


def test_t_ent_39_duration_parser():
    assert parse_duration_seconds("1:30:00") == 5400
    assert parse_duration_seconds("1:30") == 5400
    assert parse_duration_seconds("1.5") == 5400
    assert parse_duration_seconds("-1.5") is None
    assert parse_duration_seconds("bogus") is None
    assert parse_duration_seconds("") is None


# ------------------------------------------------------- re-stamp matrix


def test_t_lock_002_duration_change_recomputes_at_current_rates(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"duration": "2:00:00"})
    assert e.duration_seconds == 7200
    # adi: cost term 14/h; billout = worker-specific override 20/h
    assert Decimal(str(e.cost_amount)) == Decimal("28.00")
    assert Decimal(str(e.billout_amount)) == Decimal("40.00")


def test_t_ent_28_worker_change_restamps_both_stamps(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"worker_party_id": str(g.parties.gary.id)})
    assert e.worker_party_id == g.parties.gary.id
    assert e.via_engagement_id == g.engagements.gary.id
    # gary on Site Rebuild: cost 0 (real zero — Gary), billout default 25
    # with the project-wide -20% -> 20.00 (worker-specific override is
    # adi's, so the pct rule applies)
    assert Decimal(str(e.cost_amount)) == Decimal("0.00")
    assert Decimal(str(e.billout_amount)) == Decimal("20.00")


def test_worker_cleared_nulls_stamps(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"worker_party_id": None})
    assert e.worker_party_id is None and e.via_engagement_id is None
    assert e.cost_amount is None and e.cost_currency is None
    assert e.billout_amount is None and e.billout_currency is None


def test_project_change_restamps_billout_override_scope(g, rian):
    """Moving adi's row off Site Rebuild drops the worker-specific
    override -> billout falls back to the term default 35/h."""
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"project_id": str(g.projects.admin.id)})
    assert e.project_id == g.projects.admin.id
    assert Decimal(str(e.billout_amount)) == Decimal("35.00")
    assert Decimal(str(e.cost_amount)) == Decimal("14.00")


def test_t_mny_001_no_term_stamps_null_not_zero(g, rian):
    """rian re-attributed to a worker with no terms? Closest case:
    clearing the worker. NULL = never priced — never 0.00."""
    e = g.entries["gary"]
    update_entry(g.session, rian, e.id, {"worker_party_id": None})
    assert e.cost_amount is None
    assert e.billout_amount is None


def test_source_identity_never_written(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"worker_party_id": str(g.parties.gary.id)})
    assert e.source_user_email == "info@adipramono.com"  # frozen audit (B48)
    assert e.source_user_name == "Adi Pramono"


# ------------------------------------------------------- entity chain


def test_full_trio_resolve_or_creates_chain(g, rian):
    e = g.entries["adi"]
    update_entry(
        g.session, rian, e.id,
        {"operator": "PlusROI", "client": "Brentwood", "project": "New Thing"},
    )
    project = g.session.scalar(select(Project).where(Project.name == "New Thing"))
    assert project is not None
    assert project.client_party_id == g.parties.brentwood.id
    assert e.project_id == project.id


def test_trio_matches_existing_case_insensitively(g, rian):
    e = g.entries["gary"]
    update_entry(
        g.session, rian, e.id,
        {"operator": "plusroi", "client": "BRENTWOOD", "project": "site rebuild"},
    )
    assert e.project_id == g.projects.site.id
    # no duplicate entities created
    assert g.session.scalar(
        select(Party.id).where(Party.name == "plusroi")
    ) is None


def test_t_ent_29_partial_trio_errors_not_silent_null(g, rian):
    e = g.entries["adi"]
    before = e.project_id
    error = err(
        update_entry, g.session, rian, e.id, {"operator": "PlusROI", "client": "", "project": ""}
    )
    assert error.code == "PARTIAL_ENTITY_CHAIN"
    assert e.project_id == before  # nothing changed


def test_explicit_unlink(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"project_id": None})
    assert e.project_id is None
    # unlinked -> money re-stamped for no-project scope (term default 35)
    assert Decimal(str(e.billout_amount)) == Decimal("35.00")


def test_project_id_and_trio_together_rejected(g, rian):
    e = g.entries["adi"]
    error = err(
        update_entry, g.session, rian, e.id,
        {"project_id": str(g.projects.admin.id), "operator": "X", "client": "Y", "project": "Z"},
    )
    assert error.code == "AMBIGUOUS_PROJECT_TARGET"


def test_unknown_project_id_404(g, rian):
    e = g.entries["adi"]
    error = err(
        update_entry, g.session, rian, e.id,
        {"project_id": "00000000-0000-0000-0000-00000000dead"},
    )
    assert error.summary == "Selected project not found."


def test_unknown_worker_404(g, rian):
    e = g.entries["adi"]
    error = err(
        update_entry, g.session, rian, e.id,
        {"worker_party_id": str(g.parties.brentwood.id)},  # org party, not a worker
    )
    assert error.summary == "Selected team member not found (or not visible to you)."


# ------------------------------------------------------------- locks


def test_t_ent_40_locked_row_manager_409_owner_ok(g, rian, adi):
    e = g.entries["locked"]
    error = err(update_entry, g.session, adi, e.id, {"description": "nope"})
    assert error.status == 409
    assert error.summary == (
        "This entry is attached to an invoice and locked. Only an owner can edit it."
    )
    assert e.description == "locked row"
    update_entry(g.session, rian, e.id, {"description": "owner surgery"})
    assert e.description == "owner surgery"


def test_worker_role_cannot_edit(g, gary):
    e = g.entries["gary"]
    error = err(update_entry, g.session, gary, e.id, {"description": "hi"})
    # gary's row is in rian's batch -> invisible to worker gary (scope)
    assert error.status == 404


def test_manager_cannot_touch_other_batches(g, adi):
    e = g.entries["gary"]  # rian's batch
    error = err(update_entry, g.session, adi, e.id, {"description": "hi"})
    assert error.status == 404
    assert error.summary == "Entry not found."


# ------------------------------------------------------------- audit


def test_update_writes_audit_with_before_image(g, rian):
    e = g.entries["adi"]
    update_entry(g.session, rian, e.id, {"description": "audited"})
    audit = g.session.scalars(
        select(AuditLog).where(AuditLog.action == "entries.update")
    ).all()[-1]
    assert audit.entity_id == e.id
    assert audit.actor_user_id == g.users["rian"].id
    assert audit.impersonated_by is None
    assert audit.before["rows"][0]["description"] == "adi row"
    assert audit.after["rows"][0]["description"] == "audited"
