"""报销附件源文件写入的商业预占与文件事务补偿。"""

from __future__ import annotations

import hashlib
import logging
import shutil
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

from sqlalchemy.orm import Session, sessionmaker

from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaimItem
from app.services.commercial_direct_operation import (
    CommercialDirectOperationBridge,
    DirectOperationIdentity,
    DirectOperationResult,
)
from app.services.commercial_transaction_callbacks import (
    bind_commercial_transaction_outcome,
)
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.tenant_registry import required_tenant_id

logger = logging.getLogger(__name__)


class ExpenseClaimAttachmentCommercialAccessDenied(RuntimeError):
    pass


@dataclass(frozen=True, slots=True)
class ExpenseClaimAttachmentCommercialAttempt:
    identity: DirectOperationIdentity
    size_bytes: int


class ExpenseClaimAttachmentCommercialObserver:
    def __init__(self, bridge: CommercialDirectOperationBridge) -> None:
        self.bridge = bridge

    def permit(
        self,
        *,
        current_user: CurrentUserContext,
        claim_id: str,
        item_id: str,
        content: bytes,
        request_id: str,
        source_receipt_id: str,
        started_at: datetime,
    ) -> ExpenseClaimAttachmentCommercialAttempt:
        if not content:
            raise ValueError("附件商业计量需要非空源文件。")
        tenant_id = required_tenant_id(current_user.tenant_id)
        content_hash = hashlib.sha256(content).hexdigest()
        request_token = _request_token(
            current_user=current_user,
            claim_id=claim_id,
            item_id=item_id,
            content_hash=content_hash,
            request_id=request_id,
            source_receipt_id=source_receipt_id,
        )
        identity = DirectOperationIdentity(
            tenant_id=tenant_id,
            operation_key=f"attachment-upload:{request_token}",
            run_key=f"attachment-claim:{_digest(claim_id)}",
            tool_type="storage",
            tool_name="attachment.upload",
            provider="local-filesystem",
            started_at=_utc(started_at),
        )
        permit = self.bridge.permit(
            identity,
            requested_quantity=len(content),
            required_quantity_basis="bytes",
        )
        if not permit.allowed:
            raise ExpenseClaimAttachmentCommercialAccessDenied(permit.reason)
        return ExpenseClaimAttachmentCommercialAttempt(
            identity=identity,
            size_bytes=len(content),
        )

    def complete(
        self,
        attempt: ExpenseClaimAttachmentCommercialAttempt,
    ) -> DirectOperationResult:
        return self.bridge.complete(
            attempt.identity,
            outcome="succeeded",
            authoritative_quantities={"bytes": attempt.size_bytes},
            completed_at=datetime.now(UTC),
            usage_source="persisted_attachment_source_bytes",
            usage_availability="available",
        )

    def release(
        self,
        attempt: ExpenseClaimAttachmentCommercialAttempt,
    ) -> DirectOperationResult:
        return self.bridge.complete(
            attempt.identity,
            outcome="not_sent",
            authoritative_quantities={},
            completed_at=datetime.now(UTC),
            usage_source="business_transaction_rolled_back",
            usage_availability="unavailable",
        )


@dataclass(slots=True)
class ExpenseClaimAttachmentFileTransaction:
    db: Session
    storage: ExpenseClaimAttachmentStorage
    item: ExpenseClaimItem | None
    attachment_dir: Path
    observer: ExpenseClaimAttachmentCommercialObserver | None = None
    attempt: ExpenseClaimAttachmentCommercialAttempt | None = None
    staged_paths: list[tuple[Path, Path]] = field(default_factory=list)

    def stage_replacement(self) -> None:
        self._stage_existing_item_files()
        self._bind(
            on_commit=self._commit_replacement,
            on_rollback=self._rollback_replacement,
            operation_name="expense_claim_attachment_upload",
        )

    def stage_deletion(self) -> None:
        self._stage_existing_item_files()
        self._bind(
            on_commit=self._discard_staged_paths,
            on_rollback=self._restore_staged_paths,
            operation_name="expense_claim_attachment_delete",
        )

    def stage_claim_deletion(self, claim_id: str) -> None:
        claim_root = (self.storage.root() / str(claim_id or "").strip()).resolve()
        claim_root.relative_to(self.storage.root())
        self._stage_path(claim_root)
        self._bind(
            on_commit=self._discard_staged_paths,
            on_rollback=self._restore_staged_paths,
            operation_name="expense_claim_files_delete",
        )

    def _bind(self, *, on_commit, on_rollback, operation_name: str) -> None:
        bind_commercial_transaction_outcome(
            self.db,
            on_commit=on_commit,
            on_rollback=on_rollback,
            operation_name=operation_name,
        )

    def _stage_existing_item_files(self) -> None:
        if self.item is None:
            raise ValueError("附件文件事务缺少费用明细。")
        file_path = self.storage.resolve_item_path(self.item)
        if file_path is None:
            return
        if file_path.parent == self.storage.root():
            self._stage_path(file_path)
            self._stage_path(self.storage.meta_path(file_path))
            return
        self._stage_path(file_path.parent)

    def _stage_path(self, original: Path) -> None:
        if not original.exists():
            return
        backup = original.with_name(f".{original.name}.txn-{uuid.uuid4().hex}")
        original.replace(backup)
        self.staged_paths.append((original, backup))

    def _commit_replacement(self) -> None:
        try:
            if self.observer is not None and self.attempt is not None:
                self.observer.complete(self.attempt)
        finally:
            self._discard_staged_paths()

    def _rollback_replacement(self) -> None:
        try:
            self._delete_path(self.attachment_dir)
            self._restore_staged_paths()
        finally:
            if self.observer is not None and self.attempt is not None:
                self.observer.release(self.attempt)

    def _restore_staged_paths(self) -> None:
        for original, backup in reversed(self.staged_paths):
            if not backup.exists():
                continue
            self._delete_path(original)
            backup.replace(original)
        self.staged_paths.clear()

    def _discard_staged_paths(self) -> None:
        for _original, backup in self.staged_paths:
            self._delete_path(backup)
        self.staged_paths.clear()

    @staticmethod
    def _delete_path(path: Path) -> None:
        if not path.exists():
            return
        if path.is_dir():
            shutil.rmtree(path)
        else:
            path.unlink()


def stage_attachment_replacement(
    db: Session,
    *,
    storage: ExpenseClaimAttachmentStorage,
    item: ExpenseClaimItem,
    current_user: CurrentUserContext,
    claim_id: str,
    content: bytes,
    request_id: str = "",
    source_receipt_id: str = "",
) -> ExpenseClaimAttachmentFileTransaction:
    observer = _build_observer(db)
    attempt = observer.permit(
        current_user=current_user,
        claim_id=claim_id,
        item_id=item.id,
        content=content,
        request_id=request_id,
        source_receipt_id=source_receipt_id,
        started_at=datetime.now(UTC),
    )
    transaction = ExpenseClaimAttachmentFileTransaction(
        db=db,
        storage=storage,
        item=item,
        attachment_dir=storage.build_item_dir(claim_id, item.id),
        observer=observer,
        attempt=attempt,
    )
    try:
        transaction.stage_replacement()
    except Exception:
        observer.release(attempt)
        raise
    return transaction


def stage_attachment_deletion(
    db: Session,
    *,
    storage: ExpenseClaimAttachmentStorage,
    item: ExpenseClaimItem,
) -> ExpenseClaimAttachmentFileTransaction:
    transaction = ExpenseClaimAttachmentFileTransaction(
        db=db,
        storage=storage,
        item=item,
        attachment_dir=storage.build_item_dir(item.claim_id, item.id),
    )
    transaction.stage_deletion()
    return transaction


def stage_claim_attachment_deletion(
    db: Session,
    *,
    storage: ExpenseClaimAttachmentStorage,
    claim_id: str,
) -> ExpenseClaimAttachmentFileTransaction:
    claim_root = (storage.root() / str(claim_id or "").strip()).resolve()
    transaction = ExpenseClaimAttachmentFileTransaction(
        db=db,
        storage=storage,
        item=None,
        attachment_dir=claim_root,
    )
    transaction.stage_claim_deletion(claim_id)
    return transaction


def _build_observer(db: Session) -> ExpenseClaimAttachmentCommercialObserver:
    factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
    return ExpenseClaimAttachmentCommercialObserver(
        CommercialDirectOperationBridge(factory, lookup_session=db)
    )


def _request_token(
    *,
    current_user: CurrentUserContext,
    claim_id: str,
    item_id: str,
    content_hash: str,
    request_id: str,
    source_receipt_id: str,
) -> str:
    explicit = str(request_id or "").strip()
    receipt = str(source_receipt_id or "").strip()
    source = explicit or (f"receipt:{receipt}" if receipt else "content-bound")
    return _digest(
        "\x1f".join(
            (
                required_tenant_id(current_user.tenant_id),
                str(current_user.auth_session_id or "").strip(),
                str(claim_id or "").strip(),
                str(item_id or "").strip(),
                content_hash,
                source,
            )
        )
    )


def _digest(value: str) -> str:
    return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()


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