"""Single-entry write paths (entries.md §edit/§create/§re-resolve/§delete,
rebuild edition): PATCH-style field-level edit, manual entry creation
into the per-(tenant,user) "Manual entries" batch, re-resolve from the
row's source email, and delete — every one audited with a full
before-image (04-architecture "Audit") and lock-guarded.

Rebuild deltas applied here (ADR #006):
  - Entity identity is the project FK. A patch either picks an existing
    project (`project_id`), names the full operator+client+project trio
    (resolve-or-create — THE one services/entities implementation), or
    explicitly unlinks (`project_id: null`). The legacy "blank one text
    field and the link silently nulls" wart (B50) is replaced by the
    explicit unlink (T-ENT-29).
  - Money re-stamps go through services/money/restamp.py whenever the
    worker, project, or duration changes (T-LOCK-002/003); identity
    fields (source_user_email/name) are frozen audit history on import
    rows and are never patchable (B48).
  - Re-resolve clears ALL money stamps when clearing the conversion —
    the legacy stale-billout wart (B54) does not port (T-ENT-33).
  - Worker resolution is tenant-wide via active worker engagements —
    the legacy per-team scoping retired with teams (02 §2).

Lock rule (B62/B63 carried): a row attached to an invoice line needs
can_transfer; unlocked rows need can_manage_imports. Non-owners get a
409 with the exact legacy message.
"""

from __future__ import annotations

import re
import uuid
from datetime import datetime, timedelta

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models import Engagement, ImportBatch, Party, Project, TimeEntry
from app.services.audit import snapshot_entry, write_audit
from app.services.authz.capabilities import edit_entry_capability
from app.services.authz.scope import scope
from app.services.entities import (
    load_entity_graph,
    resolve_or_create_client,
    resolve_or_create_operator,
    resolve_or_create_project,
)
from app.services.errors import ServiceError
from app.services.money import resolve_engagement_for_party, restamp_entry
from app.services.money.stamping import WORKER_ENGAGEMENT_TYPES

MANUAL_BATCH_NAME = "Manual entries"
MANUAL_BATCH_NOTES = "Auto-created bucket for manually-added entries."

_DURATION_HMS = re.compile(r"^(\d+):([0-5]?\d)(?::([0-5]?\d))?$")
_DURATION_DECIMAL = re.compile(r"^\d+(\.\d+)?$")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$")


# ------------------------------------------------------------- parsers


def parse_duration_seconds(raw: str | None) -> int | None:
    """`H:MM:SS` | `H:MM` | decimal hours -> seconds (B51 parity; the
    server-side twin of the client parser — T-ENT-39). Invalid,
    negative, or empty -> None."""
    if raw is None:
        return None
    text = raw.strip()
    if not text:
        return None
    m = _DURATION_HMS.match(text)
    if m:
        hours, minutes = int(m.group(1)), int(m.group(2))
        seconds = int(m.group(3) or 0)
        return hours * 3600 + minutes * 60 + seconds
    if _DURATION_DECIMAL.match(text):
        return round(float(text) * 3600)
    return None


def parse_naive_timestamp(raw: str | None) -> datetime | None:
    """`YYYY-MM-DD[ T]HH:MM[:SS]` (or bare date) -> naive datetime."""
    if raw is None:
        return None
    text = raw.strip().replace("T", " ")
    if not text:
        return None
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
        try:
            return datetime.strptime(text, fmt)
        except ValueError:
            continue
    return None


# ------------------------------------------------------------- guards


def get_scoped_entry(session: Session, actor, entry_id: uuid.UUID) -> TimeEntry:
    """Fetch through scope() — the row-level twin of the list queries,
    so view-as containment holds on writes too (guardEntry parity)."""
    entry = session.scalars(
        scope(actor, "entries:read", select(TimeEntry).where(TimeEntry.id == entry_id))
    ).first()
    if entry is None:
        raise ServiceError(404, "ENTRY_NOT_FOUND", "Entry not found.")
    return entry


def require_can_edit(actor, entry: TimeEntry, verb: str) -> None:
    """The lock-aware capability rule (B63, edit_entry_capability)."""
    locked = entry.invoice_line_id is not None
    capability = edit_entry_capability(locked)
    if actor.allowed(capability):
        return
    if locked:
        raise ServiceError(
            409,
            "ENTRY_LOCKED",
            f"This entry is attached to an invoice and locked. Only an owner can {verb} it.",
        )
    raise ServiceError(403, "NOT_ALLOWED", f"You do not have permission to {verb} entries.")


def _resolve_worker_target(
    session: Session, actor, worker_party_id: uuid.UUID
) -> tuple[uuid.UUID, uuid.UUID]:
    engagement_id = resolve_engagement_for_party(session, actor.tenant_id, worker_party_id)
    if engagement_id is None:
        raise ServiceError(
            404,
            "WORKER_NOT_FOUND",
            "Selected team member not found (or not visible to you).",
        )
    return worker_party_id, engagement_id


def _resolve_chain(
    session: Session, actor, operator: str | None, client: str | None, project: str | None
) -> uuid.UUID | None:
    """The all-three-or-nothing chain rule (B50 -> T-ENT-29): full trio
    resolve-or-creates; all blank = explicit unlink; partial errors."""
    op = (operator or "").strip()
    cl = (client or "").strip()
    pr = (project or "").strip()
    if not (op or cl or pr):
        return None
    if not (op and cl and pr):
        raise ServiceError(
            422,
            "PARTIAL_ENTITY_CHAIN",
            "To set the project, fill in all three: operator + client + project"
            " — or pick an existing project.",
        )
    graph = load_entity_graph(session, actor.tenant_id)
    op_id = resolve_or_create_operator(session, graph, op)
    cl_id = resolve_or_create_client(session, graph, op_id, cl)
    return resolve_or_create_project(session, graph, op_id, cl_id, pr)


def _validate_project(session: Session, actor, project_id: uuid.UUID) -> uuid.UUID:
    found = session.scalar(
        select(Project.id).where(Project.id == project_id, Project.tenant_id == actor.tenant_id)
    )
    if found is None:
        raise ServiceError(404, "PROJECT_NOT_FOUND", "Selected project not found.")
    return found


# ------------------------------------------------------------- update


def update_entry(session: Session, actor, entry_id: uuid.UUID, patch: dict) -> TimeEntry:
    """Field-level PATCH. `patch` contains ONLY the provided keys (the
    router builds it from the request's set fields), so a cell edit
    touches exactly one thing. Money re-stamps when worker / project /
    duration changed (T-LOCK-002/003); one transaction; audited."""
    entry = get_scoped_entry(session, actor, entry_id)
    require_can_edit(actor, entry, "edit")
    before = snapshot_entry(entry)
    money_dirty = False

    # --- entity chain -------------------------------------------------
    trio_present = any(k in patch for k in ("operator", "client", "project"))
    if trio_present and "project_id" in patch:
        raise ServiceError(
            422,
            "AMBIGUOUS_PROJECT_TARGET",
            "Send either project_id or the operator/client/project names, not both.",
        )
    if trio_present:
        new_project_id = _resolve_chain(
            session, actor, patch.get("operator"), patch.get("client"), patch.get("project")
        )
        if new_project_id != entry.project_id:
            entry.project_id = new_project_id
            money_dirty = True
    elif "project_id" in patch:
        raw = patch["project_id"]
        new_project_id = _validate_project(session, actor, uuid.UUID(str(raw))) if raw else None
        if new_project_id != entry.project_id:
            entry.project_id = new_project_id
            money_dirty = True

    # --- worker (the canonical billed-for identity — B47/B48) ----------
    if "worker_party_id" in patch:
        raw = patch["worker_party_id"]
        if raw:
            worker_id, engagement_id = _resolve_worker_target(
                session, actor, uuid.UUID(str(raw))
            )
        else:
            worker_id, engagement_id = None, None
        if worker_id != entry.worker_party_id:
            entry.worker_party_id = worker_id
            entry.via_engagement_id = engagement_id
            money_dirty = True

    # --- times / duration ----------------------------------------------
    if "start_at" in patch:
        start = parse_naive_timestamp(patch["start_at"])
        if start is None:
            raise ServiceError(422, "START_REQUIRED", "Start date/time is required.")
        entry.start_at = start
    if "end_at" in patch:
        entry.end_at = parse_naive_timestamp(patch["end_at"])
    if "duration" in patch or "duration_seconds" in patch:
        if "duration_seconds" in patch:
            raw_seconds = patch["duration_seconds"]
            seconds = int(raw_seconds) if raw_seconds is not None else None
        else:
            raw_text = (patch.get("duration") or "").strip()
            seconds = parse_duration_seconds(raw_text)
            if raw_text and seconds is None:
                raise ServiceError(
                    422, "BAD_DURATION", "Duration must be HH:MM:SS or decimal hours."
                )
        if seconds is not None and seconds < 0:
            raise ServiceError(422, "BAD_DURATION", "Duration can't be negative.")
        if seconds != entry.duration_seconds:
            entry.duration_seconds = seconds
            money_dirty = True

    # --- cosmetic fields -------------------------------------------------
    if "description" in patch:
        entry.description = (patch["description"] or "").strip() or None
    if "billable" in patch:
        entry.billable = patch["billable"]

    if money_dirty:
        restamp_entry(session, entry)

    write_audit(
        session,
        actor,
        action="entries.update",
        entity_type="time_entry",
        entity_id=entry.id,
        before={"rows": [before]},
        after={"rows": [snapshot_entry(entry)]},
    )
    session.commit()
    return entry


# ------------------------------------------------------------- create


def get_or_create_manual_batch(session: Session, tenant_id: uuid.UUID, user_id: uuid.UUID):
    """ONE batch per (tenant, user) — B60 verbatim (found by importer +
    source='manual' + the exact name, case-insensitive)."""
    batch = session.scalars(
        select(ImportBatch)
        .where(
            ImportBatch.tenant_id == tenant_id,
            ImportBatch.imported_by == user_id,
            ImportBatch.source == "manual",
            func.lower(ImportBatch.name) == MANUAL_BATCH_NAME.lower(),
        )
        .order_by(ImportBatch.imported_at)
    ).first()
    if batch is not None:
        return batch
    batch = ImportBatch(
        tenant_id=tenant_id,
        source="manual",
        filename=None,
        name=MANUAL_BATCH_NAME,
        notes=MANUAL_BATCH_NOTES,
        row_count=0,
        imported_by=user_id,  # EFFECTIVE user (ADR #033)
    )
    session.add(batch)
    session.flush()
    return batch


def create_entry(session: Session, actor, payload: dict) -> TimeEntry:
    """Manual entry (B57–B61): validation messages verbatim, identity
    stamped FROM the worker, billable always true, lands in the
    per-(tenant,user) Manual entries batch."""
    if not actor.allowed("can_manage_imports"):
        raise ServiceError(403, "NOT_ALLOWED", "You do not have permission to add entries.")
    if actor.tenant_id is None or actor.user_id is None:
        raise ServiceError(
            400,
            "NO_ACCOUNT",
            "You don't have an account in this organization yet."
            " An owner needs to set up your access first.",
        )

    date_text = (payload.get("date") or "").strip()
    if not _DATE_RE.match(date_text):
        raise ServiceError(422, "BAD_DATE", "Date must be YYYY-MM-DD.")

    time_text = (payload.get("start_time") or "").strip() or "00:00:00"
    m = _TIME_RE.match(time_text)
    if not m:
        raise ServiceError(422, "BAD_TIME", "Start time must be HH:MM[:SS].")
    hh, mm, ss = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
    if hh > 23 or mm > 59 or ss > 59:
        raise ServiceError(422, "BAD_TIME", "Start time must be HH:MM[:SS].")
    start_time = f"{hh:02d}:{mm:02d}:{ss:02d}"

    seconds = parse_duration_seconds(payload.get("duration"))
    if "duration_seconds" in payload and payload["duration_seconds"] is not None:
        seconds = int(payload["duration_seconds"])
    if seconds is None or seconds < 0:
        raise ServiceError(
            422, "BAD_DURATION", "Duration is required (HH:MM:SS or decimal hours)."
        )

    description = (payload.get("description") or "").strip()
    if not description:
        raise ServiceError(422, "DESCRIPTION_REQUIRED", "Description is required.")

    raw_worker = payload.get("worker_party_id")
    if not raw_worker:
        raise ServiceError(
            422,
            "WORKER_REQUIRED",
            "Pick a team member — entries must be billable to someone.",
        )
    worker_id, engagement_id = _resolve_worker_target(session, actor, uuid.UUID(str(raw_worker)))
    party = session.get(Party, worker_id)

    if payload.get("project_id"):
        project_id = _validate_project(session, actor, uuid.UUID(str(payload["project_id"])))
    else:
        project_id = _resolve_chain(
            session, actor, payload.get("operator"), payload.get("client"), payload.get("project")
        )

    start_at = datetime.strptime(f"{date_text} {start_time}", "%Y-%m-%d %H:%M:%S")
    end_at = start_at + timedelta(seconds=seconds)  # midnight rollover included (B59)

    batch = get_or_create_manual_batch(session, actor.tenant_id, actor.user_id)
    entry = TimeEntry(
        tenant_id=actor.tenant_id,
        project_id=project_id,
        worker_party_id=worker_id,
        via_engagement_id=engagement_id,
        source="manual",
        source_timezone=None,
        import_id=batch.id,
        source_user_name=party.name if party else None,
        source_user_email=(party.email or None) if party else None,
        raw_operator=None,
        raw_client=None,
        raw_project=None,
        description=description,
        billable=True,  # always (B59)
        start_at=start_at,
        end_at=end_at,
        duration_seconds=seconds,
        invoice_line_id=None,
    )
    session.add(entry)
    session.flush()
    restamp_entry(session, entry)

    write_audit(
        session,
        actor,
        action="entries.create",
        entity_type="time_entry",
        entity_id=entry.id,
        after={"rows": [snapshot_entry(entry)]},
    )
    session.commit()
    return entry


# ------------------------------------------------------------- re-resolve


def reresolve_entry(session: Session, actor, entry_id: uuid.UUID) -> tuple[TimeEntry, str]:
    """Re-run the worker conversion from the row's CURRENT source email
    (B53–B55, rebuild: tenant-wide engagement roster; clearing clears
    ALL stamps — the B54 stale-billout wart does not port)."""
    entry = get_scoped_entry(session, actor, entry_id)
    require_can_edit(actor, entry, "re-resolve")
    before = snapshot_entry(entry)

    email = (entry.source_user_email or "").strip()
    if not email:
        entry.worker_party_id = None
        entry.via_engagement_id = None
        entry.cost_amount = None
        entry.cost_currency = None
        entry.billout_amount = None
        entry.billout_currency = None
        info = "Cleared conversion — no source email."
    else:
        from app.services.money.stamping import resolve_worker

        worker = resolve_worker(session, actor.tenant_id, email)
        if worker is None:
            raise ServiceError(
                404,
                "WORKER_NOT_FOUND",
                f"No team member with email {email}. Add them on the Team page first.",
            )
        entry.worker_party_id, entry.via_engagement_id = worker
        restamp_entry(session, entry)
        info = "Re-resolved."

    write_audit(
        session,
        actor,
        action="entries.reresolve",
        entity_type="time_entry",
        entity_id=entry.id,
        before={"rows": [before]},
        after={"rows": [snapshot_entry(entry)]},
    )
    session.commit()
    return entry, info


# ------------------------------------------------------------- delete


def delete_entry(session: Session, actor, entry_id: uuid.UUID) -> uuid.UUID:
    """Hard delete with a full before-image; returns the audit id (the
    undo token for the toast-with-undo pattern — 04 mutation policy)."""
    entry = get_scoped_entry(session, actor, entry_id)
    require_can_edit(actor, entry, "delete")
    before = snapshot_entry(entry)
    audit = write_audit(
        session,
        actor,
        action="entries.delete",
        entity_type="time_entry",
        entity_id=entry.id,
        before={"rows": [before]},
    )
    session.delete(entry)
    session.commit()
    return audit.id


# ------------------------------------------------------------- options


def edit_options(session: Session, actor) -> dict:
    """The pickers' data: active workers + the entity catalog (operators,
    client edges, projects) — tenant-wide metadata, same visibility as
    the legacy datalists."""
    if actor.tenant_id is None:
        return {"workers": [], "operators": [], "clients": [], "projects": []}

    worker_rows = session.execute(
        select(Party.id, Party.name, Party.email)
        .join(Engagement, Engagement.party_b_id == Party.id)
        .where(
            Engagement.tenant_id == actor.tenant_id,
            Engagement.type.in_(WORKER_ENGAGEMENT_TYPES),
            Engagement.ended_on.is_(None),
            Party.kind == "person",
        )
        .distinct()
        .order_by(Party.name)
    ).all()
    workers = [
        {"party_id": str(pid), "name": name, "email": email}
        for pid, name, email in worker_rows
    ]
    default_worker = next(
        (
            w["party_id"]
            for w in workers
            if (w["email"] or "").strip().lower() == (actor.email or "").strip().lower()
        ),
        None,
    )

    graph = load_entity_graph(session, actor.tenant_id)
    operators = sorted(
        ({"id": str(pid), "name": name} for pid, name in graph.operators.items()),
        key=lambda o: o["name"].lower(),
    )
    clients = sorted(
        (
            {
                "operator_id": str(op_id),
                "operator": graph.operators.get(op_id, ""),
                "client_id": str(cl_id),
                "name": graph.client_names.get(cl_id, ""),
                "label": f"{graph.operators.get(op_id, '')} : {graph.client_names.get(cl_id, '')}",
            }
            for op_id, by_name in graph.clients_of.items()
            for cl_id in by_name.values()
        ),
        key=lambda c: c["label"].lower(),
    )
    projects = sorted(
        (
            {
                "id": str(project_id),
                "name": name,
                "operator_id": str(op_id),
                "client_id": str(cl_id),
                "client": graph.client_names.get(cl_id, ""),
                "operator": graph.operators.get(op_id, ""),
                "label": f"{graph.client_names.get(cl_id, '')} : {name}",
            }
            for project_id, (name, op_id, cl_id) in graph.project_chain.items()
        ),
        key=lambda p: p["label"].lower(),
    )
    return {
        "workers": workers,
        "default_worker_party_id": default_worker,
        "operators": operators,
        "clients": clients,
        "projects": projects,
    }
