"""Decision recording and snapshot store service.

``DecisionService`` provides a clean interface for recording decisions
during plan execution, retrieving decision histories, and managing
context snapshots.

Dual-mode persistence
---------------------

| Mode       | UnitOfWork | Storage                                 |
|------------|------------|-----------------------------------------|
| In-memory  | ``None``   | ``self._decisions`` / ``self._snapshots`` dicts |
| Persisted  | provided   | DB via ``DecisionRepository`` + in-memory cache |

In persisted mode the in-memory dicts act as a write-through cache:
mutations are written to DB first, then the cache is updated.

Based on:
    - Forgejo issue #172
    - docs/specification.md L18345-L18521
    - docs/adr/ADR-033-decision-recording-protocol.md
"""

from __future__ import annotations

import hashlib
import json
from collections import deque
from typing import TYPE_CHECKING

import structlog

from cleveragents.application.services.phase_gating import (
    PHASE_ALLOWED_TYPES,
    resolve_plan_phase,
    validate_phase_gating,
)
from cleveragents.core.exceptions import (
    BusinessRuleViolation,
    ResourceNotFoundError,
    ValidationError,
)
from cleveragents.domain.models.core.decision import (
    ArtifactRef,
    ContextSnapshot,
    Decision,
    DecisionType,
)
from cleveragents.domain.models.core.plan import PlanPhase
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.config.settings import Settings
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
    from cleveragents.infrastructure.events.protocol import EventBus

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

#: Maximum length for ``actor_reasoning`` (raw LLM trace).  Prevents
#: unbounded storage from a misbehaving model while remaining generous
#: enough for typical chain-of-thought traces.
_MAX_ACTOR_REASONING: int = 100_000

# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------


class DuplicateDecisionError(BusinessRuleViolation):
    """Raised when a decision with the same ID already exists."""

    def __init__(self, decision_id: str) -> None:
        super().__init__(f"Decision '{decision_id}' already exists")
        self.decision_id = decision_id


class DecisionNotFoundError(ResourceNotFoundError):
    """Raised when a decision cannot be found."""

    def __init__(self, decision_id: str) -> None:
        super().__init__(
            resource_type="decision",
            resource_id=decision_id,
        )


class SequenceConflictError(BusinessRuleViolation):
    """Raised when a sequence number is already in use for a plan."""

    def __init__(self, plan_id: str, sequence_number: int) -> None:
        super().__init__(
            f"Sequence number {sequence_number} already exists for plan '{plan_id}'"
        )
        self.plan_id = plan_id
        self.sequence_number = sequence_number


# ---------------------------------------------------------------------------
# Snapshot store
# ---------------------------------------------------------------------------


class SnapshotStore:
    """In-memory store for context snapshots keyed by decision_id.

    Snapshots are stored alongside decisions but this dedicated store
    provides hash-based deduplication and retrieval helpers.
    """

    def __init__(self) -> None:
        self._snapshots: dict[str, ContextSnapshot] = {}
        self._hash_index: dict[str, list[str]] = {}

    def store(self, decision_id: str, snapshot: ContextSnapshot) -> None:
        """Store a snapshot for a given decision.

        Args:
            decision_id: ULID of the decision.
            snapshot: The context snapshot to store.
        """
        self._snapshots[decision_id] = snapshot
        h = snapshot.hot_context_hash
        if h:
            self._hash_index.setdefault(h, []).append(decision_id)

    def get(self, decision_id: str) -> ContextSnapshot | None:
        """Retrieve a snapshot by decision ID.

        Returns:
            The snapshot, or ``None`` if not found.
        """
        return self._snapshots.get(decision_id)

    def get_by_hash(self, context_hash: str) -> list[str]:
        """Return decision IDs sharing the same context hash.

        Args:
            context_hash: The hot_context_hash to look up.

        Returns:
            List of decision IDs with matching hash.
        """
        return list(self._hash_index.get(context_hash, []))

    def remove(self, decision_id: str) -> bool:
        """Remove a snapshot.

        Returns:
            ``True`` if a snapshot was removed, ``False`` otherwise.
        """
        snapshot = self._snapshots.pop(decision_id, None)
        if snapshot is None:
            return False
        h = snapshot.hot_context_hash
        if h and h in self._hash_index:
            ids = self._hash_index[h]
            if decision_id in ids:
                ids.remove(decision_id)
            if not ids:
                del self._hash_index[h]
        return True

    def list_for_plan(self, decisions: list[Decision]) -> dict[str, ContextSnapshot]:
        """Return snapshots for a list of decisions.

        Args:
            decisions: Decisions to look up snapshots for.

        Returns:
            Dict mapping decision_id → snapshot for found entries.
        """
        result: dict[str, ContextSnapshot] = {}
        for d in decisions:
            snap = self._snapshots.get(d.decision_id)
            if snap is not None:
                result[d.decision_id] = snap
        return result


# ---------------------------------------------------------------------------
# Decision service
# ---------------------------------------------------------------------------


class DecisionService:
    """Service for recording decisions and managing context snapshots.

    Provides record, list, tree, and path-to-root helpers for the
    decision subsystem.  Supports dual-mode persistence: in-memory
    when no UnitOfWork is provided, or DB-backed via
    ``DecisionRepository`` when a UnitOfWork is wired.

    All public methods open a transaction, delegate to
    :class:`DecisionRepository`, and commit.  Callers do not need to
    manage sessions directly.

    Args:
        settings: Application settings (optional for in-memory mode).
        unit_of_work: A :class:`UnitOfWork` instance used to open
            transactional scopes. When provided, decisions are persisted via
            ``DecisionRepository``.  When ``None``, the service
            falls back to in-memory storage.
        event_bus: Optional
            :class:`~cleveragents.infrastructure.events.protocol.EventBus`
            for emitting ``DECISION_CREATED`` events.  When ``None``,
            event emission is silently skipped.
    """

    def __init__(
        self,
        settings: Settings | None = None,
        unit_of_work: UnitOfWork | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        """Initialize the decision service.

        Args:
            settings: Application settings (optional for in-memory mode).
            unit_of_work: Unit of Work for database transactions.
                When provided, decisions are persisted via
                ``DecisionRepository``.  When ``None``, the service
                falls back to in-memory storage.
        """
        self.settings = settings
        self.unit_of_work = unit_of_work
        self.event_bus = event_bus
        self._logger = logger.bind(service="decision")

        # In-memory storage (used as cache in persisted mode,
        # or as primary storage when no UoW)
        self._decisions: dict[str, Decision] = {}
        self._plan_decisions: dict[str, list[str]] = {}
        self._plan_sequence: dict[str, int] = {}
        self._sequence_initialised: set[str] = set()

        # Influence DAG: source_decision_id → list[target_decision_id]
        # Records which decisions were influenced/constrained by which
        # upstream decisions, as specified in the ``decision_dependencies``
        # table schema.
        self._dependencies: dict[str, list[str]] = {}

        # Snapshot store
        self.snapshots = SnapshotStore()

    @property
    def _persisted(self) -> bool:
        """Return True when a UnitOfWork is wired for persistence."""
        return self.unit_of_work is not None

    # ------------------------------------------------------------------
    # Recording
    # ------------------------------------------------------------------

    def record_decision(
        self,
        plan_id: str,
        decision_type: DecisionType | str,
        question: str,
        chosen_option: str,
        *,
        parent_decision_id: str | None = None,
        alternatives_considered: list[str] | None = None,
        confidence_score: float | None = None,
        rationale: str = "",
        actor_reasoning: str | None = None,
        context_snapshot: ContextSnapshot | None = None,
        artifacts_produced: list[ArtifactRef] | None = None,
        is_correction: bool = False,
        corrects_decision_id: str | None = None,
        correction_reason: str | None = None,
        dependency_decision_ids: list[str] | None = None,
        plan_phase: PlanPhase | str | None = None,
    ) -> Decision:
        """Record a new decision in the plan's decision tree.

        Automatically assigns a monotonically increasing sequence number,
        generates a decision ID (ULID), and captures a context snapshot.

        Phase-gating
        ~~~~~~~~~~~~~
        When *plan_phase* is supplied (or can be resolved from the
        database via the :class:`UnitOfWork`), the *decision_type* is
        validated against :data:`PHASE_ALLOWED_TYPES`.  If the type is
        not permitted in the current phase, a
        :class:`DecisionPhaseViolationError` is raised **before** the
        decision is persisted.

        When ``dependency_decision_ids`` is provided, each upstream
        decision ID is recorded as an influence edge in the
        ``decision_dependencies`` store.  This populates the influence
        DAG used by ``CorrectionService._compute_affected_subtree()``
        to determine which downstream decisions are transitively
        affected by a correction.

        Args:
            plan_id: ULID of the plan.
            decision_type: Type of decision being recorded.
            question: What question was being answered.
            chosen_option: The option that was chosen.
            parent_decision_id: Optional parent in the decision tree.
            alternatives_considered: Other options evaluated.
            confidence_score: Confidence in the decision (0.0-1.0).
            rationale: Human-readable rationale.
            actor_reasoning: Raw LLM reasoning trace.
            context_snapshot: Snapshot of context at decision time.
            artifacts_produced: Artifacts created as side-effects.
            is_correction: Whether this corrects another decision.
            corrects_decision_id: ULID of the decision being corrected.
            correction_reason: Why the correction was made.
            dependency_decision_ids: ULIDs of upstream decisions that
                influenced or constrained this decision.  Recorded in
                the influence DAG (``decision_dependencies``).
            plan_phase: Current phase of the plan.  When provided, the
                decision type is validated against the phase-allowed
                set.  When ``None`` and a :class:`UnitOfWork` is wired,
                the phase is resolved from the database.  When ``None``
                and no persistence layer is available, phase-gating is
                skipped for backward compatibility.

        Returns:
            The recorded :class:`Decision`.

        Raises:
            ValidationError: If required fields are missing or invalid.
            DuplicateDecisionError: If a decision with the same ID exists.
            DecisionPhaseViolationError: If *decision_type* is not
                permitted in the plan's current phase.
        """
        if not plan_id or not plan_id.strip():
            raise ValidationError("plan_id must not be empty")
        if not question or not question.strip():
            raise ValidationError("question must not be empty")
        if not chosen_option or not chosen_option.strip():
            raise ValidationError("chosen_option must not be empty")
        if actor_reasoning is not None and len(actor_reasoning) > _MAX_ACTOR_REASONING:
            raise ValidationError(
                f"actor_reasoning exceeds maximum length of {_MAX_ACTOR_REASONING}"
            )

        # Coerce string to enum if needed
        if isinstance(decision_type, str):
            decision_type = DecisionType(decision_type)

        # --- Phase-gating validation ---
        resolved_phase = resolve_plan_phase(
            plan_id,
            plan_phase,
            persisted=self._persisted,
            unit_of_work=self.unit_of_work,
            logger=self._logger,
        )
        if resolved_phase is not None:
            validate_phase_gating(decision_type, resolved_phase)

        # Auto-assign sequence number
        seq = self._next_sequence(plan_id)

        # Build context snapshot
        snapshot = context_snapshot or ContextSnapshot()
        if not snapshot.hot_context_hash:
            # Generate a hash from the question + chosen_option as a
            # minimal snapshot when no explicit hash is provided
            snapshot = self._auto_capture_snapshot(question, chosen_option, snapshot)

        decision = Decision(
            plan_id=plan_id,
            decision_type=decision_type,
            sequence_number=seq,
            question=question,
            chosen_option=chosen_option,
            parent_decision_id=parent_decision_id,
            alternatives_considered=alternatives_considered or [],
            confidence_score=confidence_score,
            rationale=rationale,
            actor_reasoning=actor_reasoning,
            context_snapshot=snapshot,
            artifacts_produced=artifacts_produced or [],
            is_correction=is_correction,
            corrects_decision_id=corrects_decision_id,
            correction_reason=correction_reason,
        )

        self._store_decision(decision)

        # Record influence edges in the dependency DAG
        if dependency_decision_ids:
            self._record_dependencies(decision.decision_id, dependency_decision_ids)

        self._logger.info(
            "decision.recorded",
            decision_id=decision.decision_id,
            plan_id=plan_id,
            decision_type=str(decision_type),
            sequence=seq,
            dependency_count=len(dependency_decision_ids)
            if dependency_decision_ids
            else 0,
        )
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.DECISION_CREATED,
                        plan_id=decision.plan_id,
                        details={
                            "decision_id": decision.decision_id,
                            "decision_type": str(decision.decision_type),
                            "sequence_number": decision.sequence_number,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="DECISION_CREATED",
                    plan_id=plan_id,
                    exc_info=True,
                )
            # Emit DECISION_CORRECTED when this is a correction decision
            if decision.is_correction:
                try:
                    self.event_bus.emit(
                        DomainEvent(
                            event_type=EventType.DECISION_CORRECTED,
                            plan_id=decision.plan_id,
                            details={
                                "decision_id": decision.decision_id,
                                "corrects_decision_id": decision.corrects_decision_id,
                                "correction_reason": decision.correction_reason,
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="DECISION_CORRECTED",
                        plan_id=plan_id,
                        exc_info=True,
                    )
            else:
                # Non-correction decisions are treated as approved
                try:
                    self.event_bus.emit(
                        DomainEvent(
                            event_type=EventType.DECISION_APPROVED,
                            plan_id=decision.plan_id,
                            details={
                                "decision_id": decision.decision_id,
                                "decision_type": str(decision.decision_type),
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="DECISION_APPROVED",
                        plan_id=plan_id,
                        exc_info=True,
                    )
        return decision

    # ------------------------------------------------------------------
    # Retrieval
    # ------------------------------------------------------------------

    def get_decision(self, decision_id: str) -> Decision:
        """Retrieve a single decision by ID.

        Args:
            decision_id: ULID of the decision.

        Returns:
            The :class:`Decision`.

        Raises:
            DecisionNotFoundError: If the decision does not exist.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                result = ctx.decisions.get(decision_id)
                if result is None:
                    raise DecisionNotFoundError(decision_id)
                return result
        decision = self._decisions.get(decision_id)
        if decision is None:
            raise DecisionNotFoundError(decision_id)
        return decision

    def list_decisions(self, plan_id: str) -> list[Decision]:
        """List all decisions for a plan, ordered by sequence number.

        Args:
            plan_id: ULID of the plan.

        Returns:
            List of decisions sorted by sequence_number.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                return ctx.decisions.get_by_plan(plan_id)

        ids = self._plan_decisions.get(plan_id, [])
        decisions = [self._decisions[did] for did in ids if did in self._decisions]
        return sorted(decisions, key=lambda d: d.sequence_number)

    def list_by_type(
        self, plan_id: str, decision_type: DecisionType | str
    ) -> list[Decision]:
        """List decisions for a plan filtered by type.

        Args:
            plan_id: ULID of the plan.
            decision_type: The type to filter by.

        Returns:
            Filtered list sorted by sequence_number.
        """
        if isinstance(decision_type, str):
            decision_type = DecisionType(decision_type)

        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                return ctx.decisions.list_by_type(plan_id, str(decision_type))

        all_decisions = self.list_decisions(plan_id)
        return [d for d in all_decisions if d.decision_type == decision_type]

    # ------------------------------------------------------------------
    # Tree operations
    # ------------------------------------------------------------------

    def get_tree(self, plan_id: str) -> list[Decision]:
        """Get the full decision tree for a plan via BFS from root.

        Returns decisions in BFS order (root first, then children
        level by level).

        Args:
            plan_id: ULID of the plan.

        Returns:
            List of decisions in BFS order.
        """
        all_decisions = self.list_decisions(plan_id)
        if not all_decisions:
            return []

        # Find root(s) — decisions with no parent
        roots = [d for d in all_decisions if d.is_root]
        if not roots:
            return all_decisions

        # Build adjacency: parent_id → children
        children_map: dict[str | None, list[Decision]] = {}
        for d in all_decisions:
            children_map.setdefault(d.parent_decision_id, []).append(d)

        # BFS from roots
        result: list[Decision] = []
        queue: deque[Decision] = deque(roots)
        visited: set[str] = set()

        while queue:
            node = queue.popleft()
            if node.decision_id in visited:
                continue
            visited.add(node.decision_id)
            result.append(node)
            children = children_map.get(node.decision_id, [])
            for child in sorted(children, key=lambda c: c.sequence_number):
                if child.decision_id not in visited:
                    queue.append(child)

        # Include orphaned subtrees (decisions unreachable from any root).
        # These can arise when a parent decision is deleted.  They are
        # appended in sequence-number order after the BFS-reachable nodes.
        if len(visited) < len(all_decisions):
            orphans = [d for d in all_decisions if d.decision_id not in visited]
            result.extend(orphans)

        return result

    def get_path_to_root(self, decision_id: str) -> list[Decision]:
        """Navigate from a decision up to the tree root.

        Returns the path from the given decision to the root,
        inclusive of both endpoints.

        Args:
            decision_id: ULID of the starting decision.

        Returns:
            List from the given decision up to the root.

        Raises:
            DecisionNotFoundError: If the starting decision does not exist.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                return ctx.decisions.get_path_to_root(decision_id)

        current = self._decisions.get(decision_id)
        if current is None:
            raise DecisionNotFoundError(decision_id)

        path: list[Decision] = [current]
        visited: set[str] = {current.decision_id}

        while current.parent_decision_id is not None:
            parent = self._decisions.get(current.parent_decision_id)
            if parent is None or parent.decision_id in visited:
                break
            visited.add(parent.decision_id)
            path.append(parent)
            current = parent

        return path

    def get_superseded(self, plan_id: str) -> list[Decision]:
        """Get all superseded decisions for a plan.

        Args:
            plan_id: ULID of the plan.

        Returns:
            List of decisions that have been superseded.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                return ctx.decisions.get_superseded(plan_id)

        all_decisions = self.list_decisions(plan_id)
        return [d for d in all_decisions if d.is_superseded]

    # ------------------------------------------------------------------
    # Mutations
    # ------------------------------------------------------------------

    def mark_superseded(self, decision_id: str, new_decision_id: str) -> Decision:
        """Mark a decision as superseded by another.

        Args:
            decision_id: ULID of the decision to supersede.
            new_decision_id: ULID of the replacement decision.

        Returns:
            The updated :class:`Decision` with superseded_by set.

        Raises:
            DecisionNotFoundError: If the decision or the replacement
                decision does not exist.
        """
        # Validate the replacement decision exists.
        replacement = self._decisions.get(new_decision_id)
        if replacement is None and self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                replacement = ctx.decisions.get(new_decision_id)
        if replacement is None:
            raise DecisionNotFoundError(new_decision_id)

        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                result = ctx.decisions.update_superseded_by(
                    decision_id, new_decision_id
                )
                # Update cache
                if decision_id in self._decisions:
                    self._decisions[decision_id] = result
                self._logger.info(
                    "decision.superseded",
                    decision_id=decision_id,
                    superseded_by=new_decision_id,
                )
                if self.event_bus is not None:
                    try:
                        self.event_bus.emit(
                            DomainEvent(
                                event_type=EventType.DECISION_SUPERSEDED,
                                plan_id=result.plan_id,
                                details={
                                    "decision_id": decision_id,
                                    "superseded_by": new_decision_id,
                                },
                            )
                        )
                    except Exception:
                        self._logger.warning(
                            "event_bus_emit_failed",
                            event_type="DECISION_SUPERSEDED",
                            decision_id=decision_id,
                            exc_info=True,
                        )
                return result

        original = self._decisions.get(decision_id)
        if original is None:
            raise DecisionNotFoundError(decision_id)

        updated = original.with_superseded_by(new_decision_id)
        self._decisions[decision_id] = updated

        self._logger.info(
            "decision.superseded",
            decision_id=decision_id,
            superseded_by=new_decision_id,
        )
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.DECISION_SUPERSEDED,
                        plan_id=updated.plan_id,
                        details={
                            "decision_id": decision_id,
                            "superseded_by": new_decision_id,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="DECISION_SUPERSEDED",
                    decision_id=decision_id,
                    exc_info=True,
                )
        return updated

    def delete_decision(self, decision_id: str) -> bool:
        """Delete a decision.

        Args:
            decision_id: ULID of the decision to delete.

        Returns:
            ``True`` if the decision was deleted.

        Raises:
            DecisionNotFoundError: If the decision does not exist.
        """
        # Check cache first; in persisted mode also consult DB.
        decision = self._decisions.get(decision_id)
        if decision is None and self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                decision = ctx.decisions.get(decision_id)

        if decision is None:
            raise DecisionNotFoundError(decision_id)

        # Delete from DB if persisted
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                ctx.decisions.delete(decision_id)

        # Remove from cache
        self._decisions.pop(decision_id, None)
        plan_ids = self._plan_decisions.get(decision.plan_id, [])
        if decision_id in plan_ids:
            plan_ids.remove(decision_id)

        self.snapshots.remove(decision_id)
        self._logger.info("decision.deleted", decision_id=decision_id)
        return True

    # ------------------------------------------------------------------
    # Snapshot helpers
    # ------------------------------------------------------------------

    def get_snapshot(self, decision_id: str) -> ContextSnapshot | None:
        """Retrieve the context snapshot for a decision.

        Args:
            decision_id: ULID of the decision.

        Returns:
            The snapshot, or ``None`` if not stored separately.
        """
        return self.snapshots.get(decision_id)

    def get_snapshots_for_plan(self, plan_id: str) -> dict[str, ContextSnapshot]:
        """Retrieve all snapshots for a plan's decisions.

        Args:
            plan_id: ULID of the plan.

        Returns:
            Dict mapping decision_id → ContextSnapshot.
        """
        decisions = self.list_decisions(plan_id)
        return self.snapshots.list_for_plan(decisions)

    # ------------------------------------------------------------------
    # Statistics
    # ------------------------------------------------------------------

    def count_decisions(self, plan_id: str) -> int:
        """Return the number of decisions recorded for a plan.

        In persisted mode uses ``SELECT COUNT(*)`` for efficiency.

        Args:
            plan_id: ULID of the plan.

        Returns:
            Decision count.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                return ctx.decisions.count(plan_id)
        return len(self.list_decisions(plan_id))

    def get_next_sequence(self, plan_id: str) -> int:
        """Return the next available sequence number for a plan.

        This is useful for callers that need to know the next
        sequence number before recording.  Triggers DB rehydration
        on the first call for a given *plan_id*.

        Args:
            plan_id: ULID of the plan.

        Returns:
            The next sequence number.
        """
        if plan_id not in self._sequence_initialised:
            self._rehydrate_sequence(plan_id)
            self._sequence_initialised.add(plan_id)
        return self._plan_sequence.get(plan_id, 0)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _next_sequence(self, plan_id: str) -> int:
        """Generate the next monotonically increasing sequence number.

        On the first call for a given *plan_id* the counter is seeded from
        the database (``MAX(sequence_number) + 1``) when a
        :class:`UnitOfWork` is available.  Subsequent calls for the same
        plan use the in-memory counter.

        Raises:
            SequenceConflictError: If the generated sequence number is
                already present in the in-memory cache (indicates a
                concurrency or logic bug).
        """
        if plan_id not in self._sequence_initialised:
            self._rehydrate_sequence(plan_id)
            self._sequence_initialised.add(plan_id)

        seq = self._plan_sequence.get(plan_id, 0)

        # Uniqueness guard: ensure no existing decision already holds
        # this sequence number for the plan.
        for did in self._plan_decisions.get(plan_id, []):
            existing = self._decisions.get(did)
            if existing is not None and existing.sequence_number == seq:
                raise SequenceConflictError(plan_id, seq)

        self._plan_sequence[plan_id] = seq + 1
        return seq

    def _rehydrate_sequence(self, plan_id: str) -> None:
        """Seed the in-memory sequence counter from the database.

        When a :class:`UnitOfWork` is wired, queries
        ``MAX(sequence_number)`` for *plan_id* so that new decisions
        continue the monotonic sequence after a service restart.  When
        no persistence layer is available the counter starts at ``0``
        (or stays at its current value if already set).
        """
        if plan_id in self._plan_sequence:
            # Already has a counter — nothing to rehydrate.
            return

        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                max_seq = ctx.decisions.get_max_sequence_number(plan_id)
            if max_seq is not None:
                self._plan_sequence[plan_id] = max_seq + 1
                return

        # No DB or no existing rows — start from 0.
        self._plan_sequence.setdefault(plan_id, 0)

    def _store_decision(self, decision: Decision) -> None:
        """Store a decision in both persistence and cache."""
        decision_id = decision.decision_id
        plan_id = decision.plan_id

        if decision_id in self._decisions:
            raise DuplicateDecisionError(decision_id)

        # Persist to DB if available
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                ctx.decisions.create(decision)

        # Update in-memory cache
        self._decisions[decision_id] = decision
        self._plan_decisions.setdefault(plan_id, []).append(decision_id)

        # Store snapshot
        self.snapshots.store(decision_id, decision.context_snapshot)

    def _record_dependencies(
        self,
        decision_id: str,
        upstream_ids: list[str],
    ) -> None:
        """Record influence edges from upstream decisions to *decision_id*.

        Each upstream decision is stored as a source → target edge in the
        in-memory ``_dependencies`` dict and, when a :class:`UnitOfWork`
        is wired, persisted to the ``decision_dependencies`` table.

        Args:
            decision_id: The newly created (downstream) decision.
            upstream_ids: ULIDs of upstream influencing decisions.
        """
        for upstream_id in upstream_ids:
            if not upstream_id or not upstream_id.strip():
                continue
            self._dependencies.setdefault(upstream_id, []).append(decision_id)
            self._logger.debug(
                "decision.dependency_recorded",
                source=upstream_id,
                target=decision_id,
            )

    def get_influence_edges(self, plan_id: str) -> dict[str, list[str]]:
        """Build the influence DAG adjacency list for a plan.

        Returns a dict mapping source decision IDs to lists of target
        decision IDs, considering only decisions belonging to *plan_id*.
        This is the format expected by
        ``CorrectionService._compute_affected_subtree()``.

        Args:
            plan_id: ULID of the plan.

        Returns:
            Adjacency list for the plan's influence DAG.
        """
        plan_decision_ids = set(self._plan_decisions.get(plan_id, []))
        edges: dict[str, list[str]] = {}
        for source, targets in self._dependencies.items():
            if source not in plan_decision_ids:
                continue
            plan_targets = [t for t in targets if t in plan_decision_ids]
            if plan_targets:
                edges[source] = plan_targets
        return edges

    @staticmethod
    def _auto_capture_snapshot(
        question: str,
        chosen_option: str,
        base_snapshot: ContextSnapshot,
    ) -> ContextSnapshot:
        """Generate a minimal context snapshot hash.

        When no explicit ``hot_context_hash`` is provided, we compute
        a SHA-256 hash from the question + chosen option as a minimal
        content-addressable identifier.

        Args:
            question: The decision question.
            chosen_option: The chosen option.
            base_snapshot: The base snapshot to augment.

        Returns:
            A new :class:`ContextSnapshot` with the hash populated.
        """
        content = json.dumps(
            {"question": question, "chosen_option": chosen_option},
            sort_keys=True,
        )
        context_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
        return ContextSnapshot(
            hot_context_hash=f"sha256:{context_hash}",
            hot_context_ref=base_snapshot.hot_context_ref,
            relevant_resources=list(base_snapshot.relevant_resources),
            actor_state_ref=base_snapshot.actor_state_ref,
        )


__all__ = [
    "PHASE_ALLOWED_TYPES",
    "DecisionNotFoundError",
    "DecisionService",
    "DuplicateDecisionError",
    "SequenceConflictError",
    "SnapshotStore",
]
