"""The bulk-mutation engine (judgment #11 — architectural, not polish).

The three safety mechanics, exactly as bound by 04-architecture and
entries.md §rebuild deltas:

1. **Scope = display.** The mutation scope derives from the SAME query
   builder the display uses — `scope(actor, "entries:read", ...)` +
   `services/reporting/filters.apply_filters` — one code path, so a
   dropped filter dimension is structurally impossible (the 3,274-rows
   incident class; T-ENT-16). Explicit-id selections ALSO pass through
   scope() (guardEntryIds parity under view-as).

2. **Expected-count precondition.** EVERY bulk call carries
   `expected_count` — the number the user just confirmed. The server
   recomputes the scope FIRST and aborts with the exact legacy
   "Safety stop:" message on mismatch, before any write (T-ENT-17).

3. **Undo via before-images.** The audit row carries a COMPLETE
   snapshot of every affected row; services/audit.undo_entries_action
   replays it (skipping since-locked rows, reporting exact counts).

Every operation runs in ONE transaction and returns verified counts
("matched N, changed K") — counts-matching-reality is the top trust
rule for this user.

Lock rules (entries.md B64 rebuilt):
  - bulk edit: non-owners skip invoice-locked rows in BOTH modes;
    owners include them (T-ENT-25);
  - bulk recalculate: locked rows are skipped for EVERYONE and
    reported separately (T-ADJ-012/017 — the rebuild target, not the
    legacy bug);
  - bulk delete: locked rows are skipped for everyone (detach first —
    single-row owner delete remains available for surgery).
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import TimeEntry
from app.services.audit import snapshot_entry, write_audit
from app.services.authz.scope import scope
from app.services.entries_write import _resolve_chain, _resolve_worker_target, _validate_project
from app.services.errors import ServiceError
from app.services.money.restamp import restamp_entry
from app.services.reporting.filters import EntryFilters, EntryJoins, apply_filters


@dataclass(frozen=True)
class BulkSelection:
    """Either an explicit id list OR the full filter param set — never
    both. `expected_count` is required either way (T-ENT-44)."""

    expected_count: int
    ids: list[uuid.UUID] | None = None
    filters: EntryFilters | None = None

    @property
    def all_matching(self) -> bool:
        return self.ids is None


def _require_manage(actor, message: str) -> None:
    if not actor.allowed("can_manage_imports"):
        raise ServiceError(403, "NOT_ALLOWED", message)


def collect_scope(session: Session, actor, selection: BulkSelection) -> list[TimeEntry]:
    """Materialize the mutation scope THROUGH the display query path.
    Raises the safety stop on any expected-count mismatch."""
    joins = EntryJoins()
    stmt = scope(actor, "entries:read", joins.select_from(TimeEntry.id))
    if selection.all_matching:
        stmt = apply_filters(stmt, joins, selection.filters or EntryFilters())
    else:
        stmt = apply_filters(stmt, joins, EntryFilters())  # status/all — ids bind the set
        stmt = stmt.where(TimeEntry.id.in_(selection.ids))
    ids = [row[0] for row in session.execute(stmt.order_by(TimeEntry.id)).all()]

    if len(ids) != selection.expected_count:
        noun = "filter" if selection.all_matching else "selection"
        raise ServiceError(
            409,
            "SAFETY_STOP",
            f"Safety stop: the {noun} currently matches {len(ids)} entries, but you"
            f" confirmed {selection.expected_count}. Nothing was changed."
            " Refresh /entries to see the current state, then retry.",
        )
    if not ids:
        return []
    return list(
        session.scalars(
            select(TimeEntry).where(TimeEntry.id.in_(ids)).order_by(TimeEntry.id)
        ).all()
    )


def _split_locked(rows: list[TimeEntry]) -> tuple[list[TimeEntry], list[TimeEntry]]:
    unlocked = [r for r in rows if r.invoice_line_id is None]
    locked = [r for r in rows if r.invoice_line_id is not None]
    return unlocked, locked


# ------------------------------------------------------------- bulk edit


def bulk_edit_fields(
    session: Session,
    actor,
    selection: BulkSelection,
    *,
    worker_party_id: uuid.UUID | None = None,
    project_id: uuid.UUID | None = None,
    new_operator: str | None = None,
    new_client: str | None = None,
    new_project: str | None = None,
) -> dict:
    """Entity-aware bulk reassignment (B37/B38 rebuilt): re-point the
    project FK and/or the worker attribution; the quick-create trio
    resolve-or-creates a fresh chain and overrides any picked project.
    Worker changes re-stamp money from current terms at the (possibly
    new) project — project applies FIRST (T-ENT-23)."""
    _require_manage(actor, "You do not have permission to bulk edit.")

    trio = [v for v in (new_operator, new_client, new_project) if (v or "").strip()]
    target_project: uuid.UUID | None = None
    project_target_set = False
    if trio:
        if len(trio) != 3:
            raise ServiceError(
                422,
                "PARTIAL_ENTITY_CHAIN",
                "To create a new project here, fill in all three:"
                " operator + client + project.",
            )
        target_project = _resolve_chain(session, actor, new_operator, new_client, new_project)
        project_target_set = True
    elif project_id is not None:
        target_project = _validate_project(session, actor, project_id)
        project_target_set = True

    worker_target: tuple[uuid.UUID, uuid.UUID] | None = None
    if worker_party_id is not None:
        worker_target = _resolve_worker_target(session, actor, worker_party_id)

    if not project_target_set and worker_target is None:
        raise ServiceError(
            422, "NO_TARGET", "Pick a project or a team member to reassign."
        )

    rows = collect_scope(session, actor, selection)
    matched = len(rows)
    if actor.allowed("can_transfer"):
        eligible, skipped = rows, []
    else:
        eligible, skipped = _split_locked(rows)
    if matched and not eligible:
        raise ServiceError(
            409,
            "ALL_LOCKED",
            "No entries left to reassign after applying the lock guard.",
        )

    before = [snapshot_entry(r) for r in eligible]
    changed = 0
    rate_cache: dict = {}
    for row in eligible:
        row_changed = False
        if project_target_set and row.project_id != target_project:
            row.project_id = target_project  # project FIRST (T-ENT-23)
            row_changed = True
        if worker_target is not None:
            wid, eid = worker_target
            if row.worker_party_id != wid or row.via_engagement_id != eid:
                row.worker_party_id = wid
                row.via_engagement_id = eid
                row_changed = True
            # attribution then explicit re-stamp (T-ENT-14s two-step)
            if restamp_entry(session, row, rate_cache=rate_cache):
                row_changed = True
        if row_changed:
            changed += 1

    audit = write_audit(
        session,
        actor,
        action="entries.bulk_edit",
        entity_type="time_entry",
        entity_id=None,
        before={"rows": before},
        after={
            "targets": {
                "project_id": str(target_project) if target_project else None,
                "project_target_set": project_target_set,
                "worker_party_id": str(worker_target[0]) if worker_target else None,
            },
            "matched": matched,
            "changed": changed,
            "skipped_locked": len(skipped),
        },
    )
    session.commit()
    return {
        "matched": matched,
        "changed": changed,
        "skipped_locked": len(skipped),
        "undo_id": str(audit.id),
    }


# ------------------------------------------------------- bulk recalculate


def bulk_recalculate(session: Session, actor, selection: BulkSelection) -> dict:
    """Re-stamp cost + billout from CURRENT terms/overrides (B36
    rebuilt): locked rows skipped for everyone and reported separately;
    rows with no worker resolution get NULL stamps (T-ADJ-014); the
    changed count is rows whose stamp values changed (T-ADJ-015)."""
    _require_manage(actor, "You do not have permission to recalculate entries.")

    rows = collect_scope(session, actor, selection)
    matched = len(rows)
    eligible, skipped = _split_locked(rows)

    before = [snapshot_entry(r) for r in eligible]
    rate_cache: dict = {}
    changed = sum(1 for row in eligible if restamp_entry(session, row, rate_cache=rate_cache))

    audit = write_audit(
        session,
        actor,
        action="entries.bulk_recalculate",
        entity_type="time_entry",
        entity_id=None,
        before={"rows": before},
        after={
            "matched": matched,
            "changed": changed,
            "skipped_locked": len(skipped),
        },
    )
    session.commit()
    return {
        "matched": matched,
        "changed": changed,
        "skipped_locked": len(skipped),
        "undo_id": str(audit.id),
    }


# ------------------------------------------------------------ bulk delete


def bulk_delete(session: Session, actor, selection: BulkSelection) -> dict:
    """Bulk hard-delete with full before-images (undo re-inserts).
    Invoice-locked rows are always skipped — detach them first."""
    _require_manage(actor, "You do not have permission to delete entries.")

    rows = collect_scope(session, actor, selection)
    matched = len(rows)
    eligible, skipped = _split_locked(rows)
    if matched and not eligible:
        raise ServiceError(
            409,
            "ALL_LOCKED",
            "No entries left to delete after applying the lock guard.",
        )

    before = [snapshot_entry(r) for r in eligible]
    for row in eligible:
        session.delete(row)

    audit = write_audit(
        session,
        actor,
        action="entries.bulk_delete",
        entity_type="time_entry",
        entity_id=None,
        before={"rows": before},
        after={
            "matched": matched,
            "deleted": len(eligible),
            "skipped_locked": len(skipped),
        },
    )
    session.commit()
    return {
        "matched": matched,
        "changed": len(eligible),
        "skipped_locked": len(skipped),
        "undo_id": str(audit.id),
    }
