from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any
from uuid import uuid4

from sqlalchemy import (
    BigInteger,
    DateTime,
    ForeignKey,
    Index,
    Integer,
    JSON,
    String,
    Text,
    UniqueConstraint,
    select,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, Session, mapped_column

from govoplan_core.core.runtime_coordination import LeaseClaim, assert_lease_fence
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import contains_plain_secret


class RecoveryMode(StrEnum):
    ATOMIC = "atomic"
    COMPENSATION = "compensation"
    SNAPSHOT_RESTORE = "snapshot_restore"
    FORWARD_RECOVERY = "forward_recovery"
    IRREVERSIBLE = "irreversible"


class RecoveryStatus(StrEnum):
    PLANNED = "planned"
    PREPARED = "prepared"
    RUNNING = "running"
    SUCCEEDED = "succeeded"
    FAILED = "failed"
    OUTCOME_UNKNOWN = "outcome_unknown"
    RECOVERY_REQUIRED = "recovery_required"
    RECOVERING = "recovering"
    RECOVERED = "recovered"
    MANUAL_INTERVENTION = "manual_intervention"


TERMINAL_RECOVERY_STATUSES = frozenset(
    {
        RecoveryStatus.SUCCEEDED.value,
        RecoveryStatus.FAILED.value,
        RecoveryStatus.RECOVERED.value,
        RecoveryStatus.MANUAL_INTERVENTION.value,
    }
)


_TRANSITIONS: dict[str, frozenset[str]] = {
    RecoveryStatus.PLANNED.value: frozenset(
        {RecoveryStatus.PREPARED.value, RecoveryStatus.FAILED.value}
    ),
    RecoveryStatus.PREPARED.value: frozenset(
        {RecoveryStatus.RUNNING.value, RecoveryStatus.FAILED.value}
    ),
    RecoveryStatus.RUNNING.value: frozenset(
        {
            RecoveryStatus.SUCCEEDED.value,
            RecoveryStatus.FAILED.value,
            RecoveryStatus.OUTCOME_UNKNOWN.value,
            RecoveryStatus.RECOVERY_REQUIRED.value,
        }
    ),
    RecoveryStatus.OUTCOME_UNKNOWN.value: frozenset(
        {
            RecoveryStatus.SUCCEEDED.value,
            RecoveryStatus.RECOVERY_REQUIRED.value,
            RecoveryStatus.MANUAL_INTERVENTION.value,
        }
    ),
    RecoveryStatus.RECOVERY_REQUIRED.value: frozenset(
        {
            RecoveryStatus.RECOVERING.value,
            RecoveryStatus.MANUAL_INTERVENTION.value,
        }
    ),
    RecoveryStatus.RECOVERING.value: frozenset(
        {
            RecoveryStatus.RECOVERED.value,
            RecoveryStatus.MANUAL_INTERVENTION.value,
        }
    ),
}


class RecoveryGuaranteeError(ValueError):
    pass


class RecoveryIdempotencyConflict(RecoveryGuaranteeError):
    pass


class RecoveryOperation(Base, TimestampMixin):
    __tablename__ = "core_recovery_operations"
    __table_args__ = (
        UniqueConstraint(
            "installation_id",
            "module_id",
            "idempotency_key",
            name="uq_core_recovery_operation_idempotency",
        ),
        Index(
            "ix_core_recovery_operations_status_updated",
            "installation_id",
            "status",
            "updated_at",
        ),
        Index(
            "ix_core_recovery_operations_resource",
            "module_id",
            "resource_type",
            "resource_id",
        ),
    )

    id: Mapped[str] = mapped_column(
        String(36),
        primary_key=True,
        default=lambda: str(uuid4()),
    )
    installation_id: Mapped[str] = mapped_column(
        String(100), nullable=False, index=True
    )
    module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
    operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
    resource_type: Mapped[str | None] = mapped_column(String(100), index=True)
    resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
    mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
    status: Mapped[str] = mapped_column(
        String(40),
        default=RecoveryStatus.PLANNED.value,
        nullable=False,
        index=True,
    )
    idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
    request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
    plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
    backup_reference: Mapped[str | None] = mapped_column(String(1000))
    approval_reference: Mapped[str | None] = mapped_column(String(1000))
    lease_resource_key: Mapped[str | None] = mapped_column(String(255))
    holder_node_id: Mapped[str | None] = mapped_column(String(200))
    holder_incarnation: Mapped[str | None] = mapped_column(String(36))
    fencing_token: Mapped[int | None] = mapped_column(
        BigInteger().with_variant(Integer, "sqlite")
    )
    checkpoint_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
    evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
    failure_summary: Mapped[str | None] = mapped_column(Text)
    started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    recovery_started_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True)
    )
    recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
    metadata_: Mapped[dict[str, Any]] = mapped_column(
        "metadata", JSON, default=dict, nullable=False
    )


class RecoveryCheckpoint(Base):
    __tablename__ = "core_recovery_checkpoints"
    __table_args__ = (
        UniqueConstraint(
            "operation_id",
            "sequence",
            name="uq_core_recovery_checkpoint_sequence",
        ),
        Index(
            "ix_core_recovery_checkpoints_operation_created",
            "operation_id",
            "created_at",
        ),
    )

    id: Mapped[str] = mapped_column(
        String(36),
        primary_key=True,
        default=lambda: str(uuid4()),
    )
    operation_id: Mapped[str] = mapped_column(
        ForeignKey("core_recovery_operations.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    sequence: Mapped[int] = mapped_column(Integer, nullable=False)
    status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
    kind: Mapped[str] = mapped_column(String(80), nullable=False)
    summary: Mapped[str] = mapped_column(Text, nullable=False)
    evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
    previous_sha256: Mapped[str | None] = mapped_column(String(64))
    checkpoint_sha256: Mapped[str] = mapped_column(
        String(64), nullable=False, index=True
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=utcnow, nullable=False
    )


@dataclass(frozen=True, slots=True)
class RecoveryPlan:
    mode: RecoveryMode
    preconditions: tuple[str, ...] = ()
    compensation_steps: tuple[str, ...] = ()
    forward_recovery_steps: tuple[str, ...] = ()
    verification_steps: tuple[str, ...] = ()
    backup_reference: str | None = None
    approval_reference: str | None = None

    def validate(self) -> None:
        if not self.verification_steps:
            raise RecoveryGuaranteeError(
                "Recovery plans require at least one verification step"
            )
        if self.mode == RecoveryMode.COMPENSATION and not self.compensation_steps:
            raise RecoveryGuaranteeError(
                "Compensation recovery requires explicit compensation steps"
            )
        if self.mode == RecoveryMode.SNAPSHOT_RESTORE and not self.backup_reference:
            raise RecoveryGuaranteeError(
                "Snapshot restore requires a verified backup reference"
            )
        if (
            self.mode == RecoveryMode.FORWARD_RECOVERY
            and not self.forward_recovery_steps
        ):
            raise RecoveryGuaranteeError(
                "Forward recovery requires explicit forward-recovery steps"
            )
        if self.mode == RecoveryMode.IRREVERSIBLE and not self.approval_reference:
            raise RecoveryGuaranteeError(
                "Irreversible operations require an approval reference"
            )

    def as_dict(self) -> dict[str, Any]:
        return {
            "mode": self.mode.value,
            "preconditions": list(self.preconditions),
            "compensation_steps": list(self.compensation_steps),
            "forward_recovery_steps": list(self.forward_recovery_steps),
            "verification_steps": list(self.verification_steps),
            "backup_reference": self.backup_reference,
            "approval_reference": self.approval_reference,
        }


def plan_recovery_operation(
    session: Session,
    *,
    installation_id: str,
    module_id: str,
    operation_type: str,
    idempotency_key: str,
    request: dict[str, Any],
    recovery_plan: RecoveryPlan,
    resource_type: str | None = None,
    resource_id: str | None = None,
    lease_claim: LeaseClaim | None = None,
    metadata: dict[str, Any] | None = None,
) -> RecoveryOperation:
    recovery_plan.validate()
    request_sha256 = _canonical_sha256(request)
    existing = session.execute(
        select(RecoveryOperation).where(
            RecoveryOperation.installation_id == installation_id,
            RecoveryOperation.module_id == module_id,
            RecoveryOperation.idempotency_key == idempotency_key,
        )
    ).scalar_one_or_none()
    if existing is not None:
        if existing.request_sha256 != request_sha256:
            raise RecoveryIdempotencyConflict(
                "Recovery operation idempotency key was reused for another request"
            )
        return existing
    if contains_plain_secret(metadata or {}):
        raise RecoveryGuaranteeError(
            "Recovery metadata must contain secret references, not plaintext secrets"
        )
    if lease_claim is not None:
        if lease_claim.installation_id != installation_id:
            raise RecoveryGuaranteeError(
                "Recovery operation and lease belong to different installations"
            )
        assert_lease_fence(session, lease_claim)
    operation = RecoveryOperation(
        installation_id=installation_id,
        module_id=module_id,
        operation_type=operation_type,
        resource_type=resource_type,
        resource_id=resource_id,
        mode=recovery_plan.mode.value,
        status=RecoveryStatus.PLANNED.value,
        idempotency_key=idempotency_key,
        request_sha256=request_sha256,
        plan=recovery_plan.as_dict(),
        backup_reference=recovery_plan.backup_reference,
        approval_reference=recovery_plan.approval_reference,
        lease_resource_key=lease_claim.resource_key if lease_claim else None,
        holder_node_id=lease_claim.holder_node_id if lease_claim else None,
        holder_incarnation=lease_claim.holder_incarnation if lease_claim else None,
        fencing_token=lease_claim.fencing_token if lease_claim else None,
        metadata_=dict(metadata or {}),
    )
    try:
        with session.begin_nested():
            session.add(operation)
            session.flush()
    except IntegrityError:
        existing = session.execute(
            select(RecoveryOperation).where(
                RecoveryOperation.installation_id == installation_id,
                RecoveryOperation.module_id == module_id,
                RecoveryOperation.idempotency_key == idempotency_key,
            )
        ).scalar_one_or_none()
        if existing is None:
            raise
        if existing.request_sha256 != request_sha256:
            raise RecoveryIdempotencyConflict(
                "Recovery operation idempotency key was reused for another request"
            )
        return existing
    record_recovery_checkpoint(
        session,
        operation,
        kind="plan",
        summary="Recovery contract recorded before side effects",
        evidence={"request_sha256": request_sha256, "plan": recovery_plan.as_dict()},
        lease_claim=lease_claim,
    )
    return operation


def prepare_recovery_operation(
    session: Session,
    operation: RecoveryOperation,
    *,
    evidence: dict[str, Any],
    lease_claim: LeaseClaim | None = None,
) -> RecoveryOperation:
    _verify_operation_fence(session, operation, lease_claim)
    if not evidence:
        raise RecoveryGuaranteeError(
            "Recovery preparation requires durable precondition evidence"
        )
    return transition_recovery_operation(
        session,
        operation,
        status=RecoveryStatus.PREPARED,
        kind="prepared",
        summary="Preconditions and recovery material verified",
        evidence=evidence,
        lease_claim=lease_claim,
    )


def start_recovery_operation(
    session: Session,
    operation: RecoveryOperation,
    *,
    evidence: dict[str, Any] | None = None,
    lease_claim: LeaseClaim | None = None,
    now: datetime | None = None,
) -> RecoveryOperation:
    _verify_operation_fence(session, operation, lease_claim)
    operation.started_at = _as_utc(now or utcnow())
    return transition_recovery_operation(
        session,
        operation,
        status=RecoveryStatus.RUNNING,
        kind="started",
        summary="Guarded operation started",
        evidence=evidence or {},
        lease_claim=lease_claim,
    )


def transition_recovery_operation(
    session: Session,
    operation: RecoveryOperation,
    *,
    status: RecoveryStatus,
    kind: str,
    summary: str,
    evidence: dict[str, Any] | None = None,
    failure_summary: str | None = None,
    lease_claim: LeaseClaim | None = None,
    now: datetime | None = None,
) -> RecoveryOperation:
    locked = session.execute(
        select(RecoveryOperation)
        .where(RecoveryOperation.id == operation.id)
        .with_for_update()
    ).scalar_one()
    _verify_operation_fence(session, locked, lease_claim)
    allowed = _TRANSITIONS.get(locked.status, frozenset())
    if status.value not in allowed:
        raise RecoveryGuaranteeError(
            f"Recovery transition {locked.status!r} -> {status.value!r} is not allowed"
        )
    _validate_mode_transition(locked, status)
    _validate_transition_evidence(
        status=status,
        evidence=evidence or {},
        failure_summary=failure_summary,
    )
    observed_at = _as_utc(now or utcnow())
    locked.status = status.value
    locked.revision = int(locked.revision or 0) + 1
    if failure_summary is not None:
        locked.failure_summary = failure_summary
    if status == RecoveryStatus.SUCCEEDED:
        locked.completed_at = observed_at
    elif status == RecoveryStatus.RECOVERING:
        locked.recovery_started_at = observed_at
    elif status == RecoveryStatus.RECOVERED:
        locked.recovered_at = observed_at
        locked.completed_at = observed_at
    elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
        locked.completed_at = observed_at
    session.add(locked)
    record_recovery_checkpoint(
        session,
        locked,
        kind=kind,
        summary=summary,
        evidence=evidence or {},
        lease_claim=lease_claim,
        now=observed_at,
    )
    session.flush()
    return locked


def record_recovery_checkpoint(
    session: Session,
    operation: RecoveryOperation,
    *,
    kind: str,
    summary: str,
    evidence: dict[str, Any],
    lease_claim: LeaseClaim | None = None,
    now: datetime | None = None,
) -> RecoveryCheckpoint:
    if contains_plain_secret(evidence):
        raise RecoveryGuaranteeError(
            "Recovery evidence must contain secret references, not plaintext secrets"
        )
    locked = session.execute(
        select(RecoveryOperation)
        .where(RecoveryOperation.id == operation.id)
        .with_for_update()
    ).scalar_one()
    _verify_operation_fence(session, locked, lease_claim)
    observed_at = _as_utc(now or utcnow())
    sequence = int(locked.checkpoint_count or 0) + 1
    payload = {
        "operation_id": locked.id,
        "sequence": sequence,
        "status": locked.status,
        "kind": kind,
        "summary": summary,
        "evidence": evidence,
        "previous_sha256": locked.evidence_head_sha256,
        "created_at": observed_at.isoformat(),
    }
    checkpoint_hash = _canonical_sha256(payload)
    checkpoint = RecoveryCheckpoint(
        operation_id=locked.id,
        sequence=sequence,
        status=locked.status,
        kind=kind,
        summary=summary,
        evidence=dict(evidence),
        previous_sha256=locked.evidence_head_sha256,
        checkpoint_sha256=checkpoint_hash,
        created_at=observed_at,
    )
    locked.checkpoint_count = sequence
    locked.evidence_head_sha256 = checkpoint_hash
    session.add(locked)
    session.add(checkpoint)
    session.flush()
    return checkpoint


def verify_recovery_evidence_chain(
    session: Session,
    operation_id: str,
) -> bool:
    operation = session.get(RecoveryOperation, operation_id)
    if operation is None:
        raise RecoveryGuaranteeError("Recovery operation was not found")
    checkpoints = (
        session.execute(
            select(RecoveryCheckpoint)
            .where(RecoveryCheckpoint.operation_id == operation_id)
            .order_by(RecoveryCheckpoint.sequence)
        )
        .scalars()
        .all()
    )
    previous: str | None = None
    for expected_sequence, checkpoint in enumerate(checkpoints, start=1):
        if (
            checkpoint.sequence != expected_sequence
            or checkpoint.previous_sha256 != previous
        ):
            return False
        payload = {
            "operation_id": checkpoint.operation_id,
            "sequence": checkpoint.sequence,
            "status": checkpoint.status,
            "kind": checkpoint.kind,
            "summary": checkpoint.summary,
            "evidence": checkpoint.evidence,
            "previous_sha256": checkpoint.previous_sha256,
            "created_at": _as_utc(checkpoint.created_at).isoformat(),
        }
        if _canonical_sha256(payload) != checkpoint.checkpoint_sha256:
            return False
        previous = checkpoint.checkpoint_sha256
    return (
        len(checkpoints) == int(operation.checkpoint_count or 0)
        and previous == operation.evidence_head_sha256
    )


def operation_recovery_action(mode: RecoveryMode) -> RecoveryStatus:
    if mode == RecoveryMode.ATOMIC:
        return RecoveryStatus.FAILED
    if mode in {
        RecoveryMode.COMPENSATION,
        RecoveryMode.SNAPSHOT_RESTORE,
        RecoveryMode.FORWARD_RECOVERY,
    }:
        return RecoveryStatus.RECOVERY_REQUIRED
    return RecoveryStatus.MANUAL_INTERVENTION


def _validate_mode_transition(
    operation: RecoveryOperation,
    status: RecoveryStatus,
) -> None:
    mode = RecoveryMode(operation.mode)
    if (
        operation.status == RecoveryStatus.RUNNING.value
        and status == RecoveryStatus.FAILED
        and mode != RecoveryMode.ATOMIC
    ):
        raise RecoveryGuaranteeError(
            "A non-atomic operation cannot be marked failed after it starts; "
            "record recovery_required, outcome_unknown, or manual_intervention"
        )
    if mode == RecoveryMode.ATOMIC and status in {
        RecoveryStatus.RECOVERY_REQUIRED,
        RecoveryStatus.RECOVERING,
        RecoveryStatus.RECOVERED,
    }:
        raise RecoveryGuaranteeError(
            "Atomic operations must roll back in their transaction instead of entering recovery"
        )
    if mode == RecoveryMode.IRREVERSIBLE and status in {
        RecoveryStatus.RECOVERY_REQUIRED,
        RecoveryStatus.RECOVERING,
        RecoveryStatus.RECOVERED,
    }:
        raise RecoveryGuaranteeError(
            "Irreversible operations cannot claim automated recovery"
        )


def _validate_transition_evidence(
    *,
    status: RecoveryStatus,
    evidence: dict[str, Any],
    failure_summary: str | None,
) -> None:
    if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
        checks = evidence.get("checks")
        if (
            evidence.get("verified") is not True
            or not isinstance(
                checks,
                (dict, list),
            )
            or not checks
        ):
            raise RecoveryGuaranteeError(
                "Successful recovery transitions require verified evidence and check results"
            )
    if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
        raise RecoveryGuaranteeError(
            "Manual intervention requires an operator-facing failure summary"
        )


def _verify_operation_fence(
    session: Session,
    operation: RecoveryOperation,
    lease_claim: LeaseClaim | None,
) -> None:
    if operation.lease_resource_key is None:
        return
    if lease_claim is None:
        raise RecoveryGuaranteeError(
            "This recovery operation requires its distributed lease fence"
        )
    if (
        lease_claim.resource_key != operation.lease_resource_key
        or lease_claim.holder_node_id != operation.holder_node_id
        or lease_claim.holder_incarnation != operation.holder_incarnation
        or lease_claim.fencing_token != operation.fencing_token
    ):
        raise RecoveryGuaranteeError(
            "Recovery operation lease does not match its recorded fence"
        )
    assert_lease_fence(session, lease_claim)


def _canonical_sha256(value: dict[str, Any]) -> str:
    encoded = json.dumps(
        value,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
        default=str,
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def _as_utc(value: datetime) -> datetime:
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


__all__ = [
    "RecoveryCheckpoint",
    "RecoveryGuaranteeError",
    "RecoveryIdempotencyConflict",
    "RecoveryMode",
    "RecoveryOperation",
    "RecoveryPlan",
    "RecoveryStatus",
    "TERMINAL_RECOVERY_STATUSES",
    "operation_recovery_action",
    "plan_recovery_operation",
    "prepare_recovery_operation",
    "record_recovery_checkpoint",
    "start_recovery_operation",
    "transition_recovery_operation",
    "verify_recovery_evidence_chain",
]
