from __future__ import annotations

import hashlib
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any

from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload

from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_task_projection import (
    DEFAULT_APPROVAL_SLA_HOURS,
    ApprovalTaskProjectionService,
)
from app.services.approval_task_protocol import ApprovalTaskEventProtocol
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_status_registry import normalize_expense_claim_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
    BUDGET_MANAGER_APPROVAL_STAGE,
    DIRECT_MANAGER_APPROVAL_STAGE,
    FINANCE_APPROVAL_STAGE,
)

DEFAULT_BACKFILL_BATCH_SIZE = 100
MAX_BACKFILL_BATCH_SIZE = 1000

_PENDING_STATUS_VALUES = frozenset(
    {
        "submitted",
        "review",
        "pending_review",
        "approving",
        "manager_review",
        "budget_review",
        "finance_review",
        "in_approval",
        "under_review",
    }
)
_OPEN_TASK_STATUSES = frozenset({"waiting", "pending"})
_ACTIVE_EMPLOYMENT_STATUSES = frozenset({"", "active", "employed", "在职", "正常"})
_NODE_BY_STAGE = {
    DIRECT_MANAGER_APPROVAL_STAGE: ("direct_manager", "直属领导审批", 1),
    BUDGET_MANAGER_APPROVAL_STAGE: ("budget_manager", "预算管理者审批", 2),
    FINANCE_APPROVAL_STAGE: ("finance", "财务审批", 3),
}


class ApprovalTaskBackfillDisposition(StrEnum):
    ELIGIBLE = "eligible"
    CREATED = "created"
    EXISTING = "existing"
    UNSUPPORTED_STAGE = "unsupported_stage"
    ASSIGNEE_UNRESOLVED = "assignee_unresolved"
    TENANT_CONFLICT = "tenant_conflict"


@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillCursor:
    created_at: datetime
    claim_id: str


@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillItem:
    claim_id: str
    claim_no: str
    disposition: ApprovalTaskBackfillDisposition
    node_key: str = ""
    entered_at: datetime | None = None
    entered_at_source: str = ""
    assignee_key: str = ""
    task_id: str | None = None
    reason: str = ""


@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillPreview:
    tenant_id: str
    inspected: int
    eligible: int
    existing: int
    skipped: int
    has_more: bool
    next_cursor: ApprovalTaskBackfillCursor | None
    items: tuple[ApprovalTaskBackfillItem, ...]


@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillResult:
    tenant_id: str
    run_id: str
    inspected: int
    created: int
    existing: int
    skipped: int
    has_more: bool
    next_cursor: ApprovalTaskBackfillCursor | None
    items: tuple[ApprovalTaskBackfillItem, ...]


@dataclass(frozen=True, slots=True)
class _Assignment:
    kind: str
    key: str
    employee_id: str | None
    name: str


@dataclass(slots=True)
class _BatchContext:
    links_by_claim_id: dict[str, ExpenseCaseLink]
    events_by_claim_id: dict[str, list[BusinessEvent]]
    tasks_by_claim_id: dict[str, list[ApprovalTask]]
    risks_by_claim_id: dict[
        str,
        list[tuple[RiskObservation, RiskDisposition | None]],
    ]


class ApprovalTaskBackfillService:
    """显式、可预览、幂等地为历史待审批 Claim 创建根任务。

    不自行提交，也绝不能从 GET 隐式调用；``run`` 默认 dry-run。
    """

    def __init__(
        self,
        db: Session,
        *,
        tenant_id: str,
        created_before: datetime | None = None,
        sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
    ) -> None:
        self.db = db
        self.tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
        self.created_before = (
            self._aware(created_before, field_name="created_before")
            if created_before is not None
            else None
        )
        self.sla_hours = int(sla_hours)
        if self.sla_hours <= 0:
            raise ValueError("sla_hours must be greater than zero")
        self.claim_policy = ExpenseClaimAccessPolicy(db)
        self.projections = ApprovalTaskProjectionService(db)
        self.events = ApprovalTaskEventProtocol(db)

    def run(
        self,
        *,
        dry_run: bool = True,
        run_id: str | None = None,
        batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
        after: ApprovalTaskBackfillCursor | None = None,
        backfilled_at: datetime | None = None,
    ) -> ApprovalTaskBackfillPreview | ApprovalTaskBackfillResult:
        if dry_run:
            return self.preview(batch_size=batch_size, after=after)
        return self.apply_batch(
            run_id=self._require_text(run_id, field_name="run_id", max_length=64),
            batch_size=batch_size,
            after=after,
            backfilled_at=backfilled_at,
        )

    def preview(
        self,
        *,
        batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
        after: ApprovalTaskBackfillCursor | None = None,
    ) -> ApprovalTaskBackfillPreview:
        """只读预览；不 add、不 flush、不 commit。"""

        with self.db.no_autoflush:
            claims, has_more = self._load_claims(
                batch_size=self._batch_size(batch_size),
                after=after,
                lock_rows=False,
            )
            context = self._load_context(claims)
            items = self._classify(claims, context=context)
        return ApprovalTaskBackfillPreview(
            tenant_id=self.tenant_id,
            inspected=len(items),
            eligible=self._count(items, ApprovalTaskBackfillDisposition.ELIGIBLE),
            existing=self._count(items, ApprovalTaskBackfillDisposition.EXISTING),
            skipped=self._skipped_count(items),
            has_more=has_more,
            next_cursor=self._next_cursor(claims),
            items=items,
        )

    def apply_batch(
        self,
        *,
        run_id: str,
        batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
        after: ApprovalTaskBackfillCursor | None = None,
        backfilled_at: datetime | None = None,
    ) -> ApprovalTaskBackfillResult:
        """锁定并应用一批，不 commit；失败时由调用方回滚整个批次。"""

        normalized_run_id = self._require_text(run_id, field_name="run_id", max_length=64)
        applied_at = self._aware(
            backfilled_at or datetime.now(UTC),
            field_name="backfilled_at",
        )
        claims, has_more = self._load_claims(
            batch_size=self._batch_size(batch_size),
            after=after,
            lock_rows=True,
        )
        context = self._load_context(claims)
        classified = self._classify(claims, context=context)
        claims_by_id = {str(claim.id): claim for claim in claims}
        result_items: list[ApprovalTaskBackfillItem] = []

        for item in classified:
            if item.disposition is not ApprovalTaskBackfillDisposition.ELIGIBLE:
                result_items.append(item)
                continue
            claim = claims_by_id[item.claim_id]
            assignment = self._resolve_assignment(claim, node_key=item.node_key)
            if assignment is None:
                result_items.append(
                    replace(
                        item,
                        disposition=ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
                        reason="当前审批节点无法解析到安全的处理人。",
                    )
                )
                continue
            task = self._create_task(
                claim,
                item=item,
                assignment=assignment,
                context=context,
                run_id=normalized_run_id,
                backfilled_at=applied_at,
            )
            result_items.append(
                replace(
                    item,
                    disposition=ApprovalTaskBackfillDisposition.CREATED,
                    task_id=task.id,
                )
            )

        result = tuple(result_items)
        return ApprovalTaskBackfillResult(
            tenant_id=self.tenant_id,
            run_id=normalized_run_id,
            inspected=len(result),
            created=self._count(result, ApprovalTaskBackfillDisposition.CREATED),
            existing=self._count(result, ApprovalTaskBackfillDisposition.EXISTING),
            skipped=self._skipped_count(result),
            has_more=has_more,
            next_cursor=self._next_cursor(claims),
            items=result,
        )

    def derive_entered_at(
        self,
        claim: ExpenseClaim,
        *,
        events: Sequence[BusinessEvent],
        approval_stage: str,
    ) -> tuple[datetime, str, BusinessEvent | None]:
        """按 BusinessEvent > submitted_at > updated_at > created_at 推导节点时间。"""

        matching_events = [
            event
            for event in events
            if self._event_enters_stage(event, approval_stage=approval_stage)
        ]
        if matching_events:
            event = max(
                matching_events,
                key=lambda value: self._as_utc(value.occurred_at),
            )
            return self._as_utc(event.occurred_at), "workflow_event", event
        if claim.submitted_at is not None:
            return self._as_utc(claim.submitted_at), "submitted_at", None
        if claim.updated_at is not None:
            return self._as_utc(claim.updated_at), "updated_at", None
        if claim.created_at is not None:
            return self._as_utc(claim.created_at), "backfill", None
        raise ValueError(f"Claim {claim.id} 缺少可用于回填的时间。")

    def _load_claims(
        self,
        *,
        batch_size: int,
        after: ApprovalTaskBackfillCursor | None,
        lock_rows: bool,
    ) -> tuple[list[ExpenseClaim], bool]:
        normalized_status = func.lower(func.trim(func.coalesce(ExpenseClaim.status, "")))
        stmt = (
            select(ExpenseClaim)
            .options(
                selectinload(ExpenseClaim.items),
                selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
                selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
                selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
            )
            .where(
                normalized_status.in_(_PENDING_STATUS_VALUES),
                ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(self.tenant_id),
            )
        )
        if self.created_before is not None:
            stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
        if after is not None:
            claim_id = self._require_text(
                after.claim_id,
                field_name="after.claim_id",
                max_length=36,
            )
            stmt = stmt.where(
                or_(
                    ExpenseClaim.created_at > after.created_at,
                    and_(
                        ExpenseClaim.created_at == after.created_at,
                        ExpenseClaim.id > claim_id,
                    ),
                )
            )
        stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
            batch_size + 1
        )
        if lock_rows:
            stmt = stmt.with_for_update()
        rows = list(self.db.scalars(stmt).unique().all())
        return rows[:batch_size], len(rows) > batch_size

    def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
        claim_ids = [str(claim.id) for claim in claims]
        if not claim_ids:
            return _BatchContext({}, {}, {}, {})

        links_by_claim_id = {
            str(link.resource_id): link
            for link in self.db.scalars(
                select(ExpenseCaseLink).where(
                    ExpenseCaseLink.resource_type == "expense_claim",
                    ExpenseCaseLink.resource_id.in_(claim_ids),
                )
            ).all()
        }
        events_by_claim_id: dict[str, list[BusinessEvent]] = {}
        for event in self.db.scalars(
            select(BusinessEvent)
            .where(
                BusinessEvent.tenant_id == self.tenant_id,
                BusinessEvent.aggregate_type == "expense_claim",
                BusinessEvent.aggregate_id.in_(claim_ids),
            )
            .order_by(BusinessEvent.occurred_at.desc(), BusinessEvent.id.desc())
        ).all():
            events_by_claim_id.setdefault(str(event.aggregate_id), []).append(event)

        tasks_by_claim_id: dict[str, list[ApprovalTask]] = {}
        for task in self.db.scalars(
            select(ApprovalTask).where(
                ApprovalTask.tenant_id == self.tenant_id,
                ApprovalTask.claim_id.in_(claim_ids),
                ApprovalTask.task_kind == "root",
            )
        ).all():
            tasks_by_claim_id.setdefault(str(task.claim_id), []).append(task)

        risks_by_claim_id: dict[
            str,
            list[tuple[RiskObservation, RiskDisposition | None]],
        ] = {}
        for observation, disposition in self.db.execute(
            select(RiskObservation, RiskDisposition)
            .outerjoin(
                RiskDisposition,
                (RiskDisposition.tenant_id == RiskObservation.tenant_id)
                & (RiskDisposition.observation_id == RiskObservation.id),
            )
            .where(
                RiskObservation.tenant_id == self.tenant_id,
                RiskObservation.claim_id.in_(claim_ids),
            )
        ).all():
            risks_by_claim_id.setdefault(str(observation.claim_id), []).append(
                (observation, disposition)
            )
        return _BatchContext(
            links_by_claim_id=links_by_claim_id,
            events_by_claim_id=events_by_claim_id,
            tasks_by_claim_id=tasks_by_claim_id,
            risks_by_claim_id=risks_by_claim_id,
        )

    def _classify(
        self,
        claims: Sequence[ExpenseClaim],
        *,
        context: _BatchContext,
    ) -> tuple[ApprovalTaskBackfillItem, ...]:
        items: list[ApprovalTaskBackfillItem] = []
        for claim in claims:
            claim_id = str(claim.id)
            link = context.links_by_claim_id.get(claim_id)
            if link is not None and link.tenant_id != self.tenant_id:
                items.append(
                    self._item(
                        claim,
                        ApprovalTaskBackfillDisposition.TENANT_CONFLICT,
                        reason="费用单据已关联到其他租户。",
                    )
                )
                continue

            state = self._normalized_state(claim)
            node_spec = _NODE_BY_STAGE.get(state.approval_stage)
            if state.status != "submitted" or node_spec is None:
                items.append(
                    self._item(
                        claim,
                        ApprovalTaskBackfillDisposition.UNSUPPORTED_STAGE,
                        reason="当前状态或审批阶段不属于可回填的待审批节点。",
                    )
                )
                continue

            node_key, _label, _sequence = node_spec
            entered_at, entered_source, _source_event = self.derive_entered_at(
                claim,
                events=context.events_by_claim_id.get(claim_id, ()),
                approval_stage=state.approval_stage,
            )
            node_entry_key = self._node_entry_key(
                claim_id=claim_id,
                node_key=node_key,
                entered_at=entered_at,
            )
            previous_tasks = context.tasks_by_claim_id.get(claim_id, ())
            existing = next(
                (
                    task
                    for task in previous_tasks
                    if task.status in _OPEN_TASK_STATUSES or task.node_entry_key == node_entry_key
                ),
                None,
            )
            if existing is not None:
                items.append(
                    self._item(
                        claim,
                        ApprovalTaskBackfillDisposition.EXISTING,
                        node_key=node_key,
                        entered_at=entered_at,
                        entered_at_source=entered_source,
                        assignee_key=existing.assignee_key,
                        task_id=existing.id,
                        reason="当前 Claim 已有开放根任务或相同节点进入事实。",
                    )
                )
                continue

            assignment = self._resolve_assignment(claim, node_key=node_key)
            if assignment is None:
                items.append(
                    self._item(
                        claim,
                        ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
                        node_key=node_key,
                        entered_at=entered_at,
                        entered_at_source=entered_source,
                        reason="当前审批节点无法解析到安全的处理人。",
                    )
                )
                continue
            items.append(
                self._item(
                    claim,
                    ApprovalTaskBackfillDisposition.ELIGIBLE,
                    node_key=node_key,
                    entered_at=entered_at,
                    entered_at_source=entered_source,
                    assignee_key=assignment.key,
                )
            )
        return tuple(items)

    def _resolve_assignment(
        self,
        claim: ExpenseClaim,
        *,
        node_key: str,
    ) -> _Assignment | None:
        employee: Employee | None
        if node_key == "direct_manager":
            claim_employee = claim.employee
            if claim_employee is None:
                claim_employee = self.claim_policy.resolve_claim_employee_for_backfill(claim)
            employee = claim_employee.manager if claim_employee is not None else None
            if employee is None:
                manager_name = self.claim_policy.resolve_claim_manager_name(claim)
                employee = self.claim_policy.resolve_employee_by_identity_candidates(
                    [manager_name],
                    tenant_id=self.claim_policy.resolve_structured_claim_tenant_id(claim),
                )
        elif node_key == "budget_manager":
            employee = self.claim_policy.resolve_department_budget_manager(claim)
        elif node_key == "finance":
            employee = self.claim_policy.resolve_finance_approver(claim)
            if employee is None:
                return _Assignment("role", "finance", None, "财务审批角色")
        else:
            return None
        if employee is None or employee.id == str(claim.employee_id or "").strip():
            return None
        employment_status = str(employee.employment_status or "").strip().lower()
        if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
            return (
                _Assignment("role", "finance", None, "财务审批角色")
                if node_key == "finance"
                else None
            )
        return _Assignment(
            kind="employee",
            key=str(employee.id),
            employee_id=str(employee.id),
            name=str(employee.name or employee.employee_no or employee.id).strip(),
        )

    def _create_task(
        self,
        claim: ExpenseClaim,
        *,
        item: ApprovalTaskBackfillItem,
        assignment: _Assignment,
        context: _BatchContext,
        run_id: str,
        backfilled_at: datetime,
    ) -> ApprovalTask:
        if item.entered_at is None:
            raise RuntimeError("eligible backfill item is missing entered_at")
        state = self._normalized_state(claim)
        node_key, node_label, node_sequence = _NODE_BY_STAGE[state.approval_stage]
        link = context.links_by_claim_id.get(str(claim.id))
        node_entry_key = self._node_entry_key(
            claim_id=str(claim.id), node_key=node_key, entered_at=item.entered_at
        )
        task = ApprovalTask(
            id=str(uuid.uuid4()),
            tenant_id=self.tenant_id,
            claim_id=str(claim.id),
            expense_case_id=link.expense_case_id if link is not None else None,
            node_instance_id=node_entry_key,
            node_entry_key=node_entry_key,
            parent_task_id=None,
            task_kind="root",
            node_key=node_key,
            node_label=node_label,
            node_sequence=node_sequence,
            sequence_order=0,
            coordination_mode="single",
            owner_kind=assignment.kind,
            owner_key=assignment.key,
            owner_employee_id=assignment.employee_id,
            owner_name=assignment.name,
            assignee_kind=assignment.kind,
            assignee_key=assignment.key,
            assignee_employee_id=assignment.employee_id,
            assignee_name=assignment.name,
            status="pending",
            decision=None,
            version=1,
            claim_status_snapshot=state.status,
            claim_stage_snapshot=state.approval_stage,
            entered_at=item.entered_at,
            entered_at_source=item.entered_at_source,
            activated_at=item.entered_at,
            sla_hours_snapshot=self.sla_hours,
            completed_at=None,
            cancelled_at=None,
            escalation_level=0,
        )
        projection = self.projections.build(
            claim,
            tenant_id=self.tenant_id,
            entered_at=item.entered_at,
            now=backfilled_at,
            sla_hours=self.sla_hours,
            observation_rows=context.risks_by_claim_id.get(str(claim.id), ()),
        )
        self.projections.apply(task, projection)
        self.db.add(task)
        self.db.flush()

        source_event = self._source_event(
            context.events_by_claim_id.get(str(claim.id), ()),
            approval_stage=state.approval_stage,
        )
        payload = {
            "backfill_run_id": run_id,
            "history_reconstructed": False,
            "current_node_reconstructed": True,
            "claim_id": str(claim.id),
            "claim_no": str(claim.claim_no or ""),
            "approval_stage": state.approval_stage,
            "entered_at": self._isoformat(item.entered_at),
            "entered_at_source": item.entered_at_source,
        }
        event, _response = self.events.append_event(
            task=task,
            event_type="node_entered_backfilled",
            actor_id="approval-task-backfill",
            actor_name="审批任务历史回填",
            actor_type="system",
            request_id=self._request_id(run_id=run_id, task=task),
            expected_task_version=0,
            payload=payload,
            before_json={},
            business_event_id=source_event.id if source_event is not None else None,
            correlation_id=run_id,
            occurred_at=backfilled_at,
        )
        self.db.flush()
        return task

    @staticmethod
    def _item(
        claim: ExpenseClaim,
        disposition: ApprovalTaskBackfillDisposition,
        **values: Any,
    ) -> ApprovalTaskBackfillItem:
        return ApprovalTaskBackfillItem(
            claim_id=str(claim.id),
            claim_no=str(claim.claim_no or ""),
            disposition=disposition,
            **values,
        )

    @staticmethod
    def _event_enters_stage(event: BusinessEvent, *, approval_stage: str) -> bool:
        payload = event.payload_json if isinstance(event.payload_json, dict) else {}
        next_stage = str(
            payload.get("next_approval_stage") or payload.get("approval_stage") or ""
        ).strip()
        next_status = str(payload.get("next_status") or "submitted").strip().lower()
        return next_stage == approval_stage and next_status in _PENDING_STATUS_VALUES

    def _source_event(
        self,
        events: Sequence[BusinessEvent],
        *,
        approval_stage: str,
    ) -> BusinessEvent | None:
        matches = [
            event
            for event in events
            if self._event_enters_stage(event, approval_stage=approval_stage)
        ]
        return max(matches, key=lambda value: self._as_utc(value.occurred_at)) if matches else None

    @staticmethod
    def _normalized_state(claim: ExpenseClaim) -> Any:
        raw_status = str(claim.status or "").strip().lower()
        status = "submitted" if raw_status in {"in_approval", "under_review"} else claim.status
        return normalize_expense_claim_state(
            status,
            claim.approval_stage,
            claim_no=claim.claim_no,
            expense_type=claim.expense_type,
        )

    @staticmethod
    def _node_digest(*, claim_id: str, node_key: str, entered_at: datetime) -> str:
        raw = f"{claim_id}:{node_key}:{ApprovalTaskBackfillService._isoformat(entered_at)}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]

    @classmethod
    def _node_entry_key(cls, *, claim_id: str, node_key: str, entered_at: datetime) -> str:
        digest = cls._node_digest(
            claim_id=claim_id,
            node_key=node_key,
            entered_at=entered_at,
        )
        return f"claim:{claim_id}:{node_key}:{digest}"

    @staticmethod
    def _request_id(*, run_id: str, task: ApprovalTask) -> str:
        digest = hashlib.sha256(f"{task.tenant_id}:{task.node_entry_key}".encode()).hexdigest()[:24]
        return f"backfill:{run_id}:{digest}"[:120]

    @staticmethod
    def _count(
        items: Sequence[ApprovalTaskBackfillItem],
        disposition: ApprovalTaskBackfillDisposition,
    ) -> int:
        return sum(1 for item in items if item.disposition is disposition)

    @staticmethod
    def _skipped_count(items: Sequence[ApprovalTaskBackfillItem]) -> int:
        return sum(
            1
            for item in items
            if item.disposition
            not in {
                ApprovalTaskBackfillDisposition.ELIGIBLE,
                ApprovalTaskBackfillDisposition.CREATED,
                ApprovalTaskBackfillDisposition.EXISTING,
            }
        )

    @staticmethod
    def _next_cursor(
        claims: Sequence[ExpenseClaim],
    ) -> ApprovalTaskBackfillCursor | None:
        if not claims:
            return None
        claim = claims[-1]
        return ApprovalTaskBackfillCursor(
            created_at=claim.created_at,
            claim_id=str(claim.id),
        )

    @staticmethod
    def _batch_size(value: int) -> int:
        normalized = int(value)
        if normalized < 1 or normalized > MAX_BACKFILL_BATCH_SIZE:
            raise ValueError(f"batch_size must be between 1 and {MAX_BACKFILL_BATCH_SIZE}")
        return normalized

    @staticmethod
    def _require_text(value: str | None, *, field_name: str, max_length: int) -> str:
        normalized = str(value or "").strip()
        if not normalized:
            raise ValueError(f"{field_name} must not be empty")
        if len(normalized) > max_length:
            raise ValueError(f"{field_name} must be at most {max_length} characters")
        return normalized

    @staticmethod
    def _aware(value: datetime, *, field_name: str) -> datetime:
        if value.tzinfo is None or value.utcoffset() is None:
            raise ValueError(f"{field_name} must include a timezone")
        return value.astimezone(UTC)

    @staticmethod
    def _as_utc(value: datetime) -> datetime:
        return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)

    @staticmethod
    def _isoformat(value: datetime) -> str:
        return ApprovalTaskBackfillService._as_utc(value).isoformat().replace("+00:00", "Z")
