"""Plan apply service: diff review, artifacts, and apply integration.

Provides the service layer for D0b.apply features:

- **Diff output**: Generate unified diff from ``SpecChangeSet`` entries grouped
  by resource, with per-file operation annotations (create/modify/delete).
- **Artifacts output**: ChangeSet ID, sandbox refs, apply summary, file list.
- **Apply summary persistence**: Store files-changed count, validations-run
  count, and apply timestamps into Plan metadata.
- **Merge failure handling**: Rollback sandbox state and transition plan to
  ``ERRORED`` with conflict details.
- **Empty ChangeSet guard**: Block apply when the ChangeSet has no entries
  unless ``allow_empty`` is explicitly set.
- **Validation-gated apply**: Block apply when required validations have not
  passed, setting plan to ``constrained`` state with actionable error.
- **Changeset statistics enrichment**: Passes SEC7 audit statistics
  (files changed, resources modified, apply duration) to
  ``PlanLifecycleService.complete_apply`` so the ``PLAN_APPLIED`` domain
  event carries rich context for audit logging (issue #716).

All public methods accept ``plan_id`` and delegate plan lookups to
``PlanLifecycleService``.
"""

from __future__ import annotations

from datetime import UTC, datetime
from enum import StrEnum
from typing import TYPE_CHECKING, Any

import structlog
from pydantic import BaseModel, ConfigDict, Field

from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.change import (
    SpecChangeSet,
)
from cleveragents.infrastructure.sandbox.checkpoint import CheckpointManager

if TYPE_CHECKING:
    from cleveragents.application.services.plan_lifecycle_service import (
        PlanLifecycleService,
    )
    from cleveragents.domain.models.core.plan import Plan

logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# Apply outcome model
# ---------------------------------------------------------------------------


class ApplyOutcome(StrEnum):
    """Outcome of a validation-gated apply attempt.

    - ``applied``: All required validations passed; changes committed.
    - ``constrained``: Required validations failed; apply blocked.
    - ``already_applied``: Plan was already in a terminal applied state.
    - ``blocked_empty``: ChangeSet was empty and ``allow_empty`` not set.
    """

    APPLIED = "applied"
    CONSTRAINED = "constrained"
    ALREADY_APPLIED = "already_applied"
    BLOCKED_EMPTY = "blocked_empty"


class ApplyResult(BaseModel):
    """Result of a validation-gated apply attempt.

    Encapsulates the outcome, any error message, the plan state after
    the attempt, and summary counts.
    """

    outcome: ApplyOutcome = Field(..., description="Outcome of the apply attempt")
    plan_id: str = Field(..., description="Plan ULID")
    message: str = Field(default="", description="Human-readable result message")
    files_changed: int = Field(
        default=0, description="Number of files changed during apply"
    )
    validations_total: int = Field(default=0, description="Total validations evaluated")
    validations_passed: int = Field(
        default=0, description="Required validations that passed"
    )
    validations_failed: int = Field(
        default=0, description="Required validations that failed"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# Colour codes for diff operations (used by rich format)
_OP_COLORS: dict[str, str] = {
    "create": "green",
    "modify": "yellow",
    "delete": "red",
    "rename": "cyan",
}


# ---------------------------------------------------------------------------
# Diff rendering helpers
# ---------------------------------------------------------------------------


def _operation_label(op: str) -> str:
    """Return a human-readable label for a change operation."""
    labels: dict[str, str] = {
        "create": "new file",
        "modify": "modified",
        "delete": "deleted",
        "rename": "renamed",
    }
    return labels.get(op, op)


def _render_diff_plain(changeset: SpecChangeSet) -> str:
    """Render a plain-text unified diff from a ``SpecChangeSet``.

    Groups entries by path, showing operation type and hashes.
    """
    if not changeset.entries:
        return "No changes in changeset."

    lines: list[str] = []
    lines.append(f"ChangeSet: {changeset.changeset_id}")
    lines.append(f"Plan: {changeset.plan_id}")
    lines.append(f"Total changes: {len(changeset.entries)}")
    lines.append("")

    # Group entries by path for cleaner display
    by_path: dict[str, list[Any]] = {}
    for entry in changeset.entries:
        by_path.setdefault(entry.path, []).append(entry)

    for path, entries in sorted(by_path.items()):
        for entry in entries:
            op_label = _operation_label(entry.operation)
            lines.append(f"--- a/{path}")
            lines.append(f"+++ b/{path}")
            lines.append(f"@@ {op_label} @@")
            if entry.before_hash:
                lines.append(f"- hash: {entry.before_hash[:12]}...")
            if entry.after_hash:
                lines.append(f"+ hash: {entry.after_hash[:12]}...")
            lines.append("")

    return "\n".join(lines)


def _render_diff_rich(changeset: SpecChangeSet) -> str:
    """Render a Rich-markup diff from a ``SpecChangeSet``."""
    if not changeset.entries:
        return "[yellow]No changes in changeset.[/yellow]"

    lines: list[str] = []
    lines.append(f"[bold]ChangeSet:[/bold] {changeset.changeset_id}")
    lines.append(f"[bold]Plan:[/bold] {changeset.plan_id}")
    lines.append(f"[bold]Total changes:[/bold] {len(changeset.entries)}")
    lines.append("")

    by_path: dict[str, list[Any]] = {}
    for entry in changeset.entries:
        by_path.setdefault(entry.path, []).append(entry)

    for path, entries in sorted(by_path.items()):
        for entry in entries:
            color = _OP_COLORS.get(entry.operation, "white")
            op_label = _operation_label(entry.operation)
            lines.append(f"[{color}]{op_label}[/{color}] {path}")
            if entry.before_hash:
                lines.append(f"  [red]- {entry.before_hash[:12]}...[/red]")
            if entry.after_hash:
                lines.append(f"  [green]+ {entry.after_hash[:12]}...[/green]")
            lines.append("")

    return "\n".join(lines)


def _render_diff_json(changeset: SpecChangeSet) -> dict[str, Any]:
    """Return a JSON-serialisable dict summarising the changeset diff."""
    return {
        "changeset_id": changeset.changeset_id,
        "plan_id": changeset.plan_id,
        "total_changes": len(changeset.entries),
        "summary": changeset.summary(),
        "entries": [
            {
                "path": e.path,
                "operation": e.operation,
                "before_hash": e.before_hash,
                "after_hash": e.after_hash,
                "resource_id": e.resource_id,
                "tool_name": e.tool_name,
                "timestamp": e.timestamp.isoformat(),
            }
            for e in changeset.entries
        ],
    }


# ---------------------------------------------------------------------------
# Artifacts rendering helpers
# ---------------------------------------------------------------------------


def _build_artifacts_dict(
    plan: Plan,
    changeset: SpecChangeSet | None,
) -> dict[str, Any]:
    """Build the artifacts output dictionary."""
    result: dict[str, Any] = {
        "plan_id": plan.identity.plan_id,
        "phase": plan.phase.value,
        "processing_state": plan.processing_state.value,
        "changeset_id": plan.changeset_id,
        "sandbox_refs": plan.sandbox_refs,
    }

    if changeset is not None:
        result["changeset_summary"] = changeset.summary()
        result["files_changed"] = [
            {"path": e.path, "operation": e.operation} for e in changeset.entries
        ]
    else:
        result["changeset_summary"] = None
        result["files_changed"] = []

    if plan.validation_summary:
        result["validation_summary"] = plan.validation_summary

    # Apply summary from metadata if present
    if plan.error_details and "apply_files_changed" in plan.error_details:
        result["apply_summary"] = {
            "files_changed": plan.error_details.get("apply_files_changed", "0"),
            "validations_run": plan.error_details.get("apply_validations_run", "0"),
        }

    return result


# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------


class PlanApplyService:
    """Service for diff review, artifacts display, and apply integration.

    Bridges ``PlanLifecycleService`` with diff rendering, artifacts output,
    apply summary persistence, merge-failure handling, and the empty
    ChangeSet guard.
    """

    def __init__(
        self,
        lifecycle_service: PlanLifecycleService,
        changeset_store: Any | None = None,
        checkpoint_manager: CheckpointManager | None = None,
    ) -> None:
        """Initialise the plan apply service.

        Args:
            lifecycle_service: The ``PlanLifecycleService`` for plan lookups
                and phase transitions.
            changeset_store: Optional ``ChangeSetStore`` for retrieving
                persisted changesets.  When ``None``, diff and artifact
                commands build output from plan metadata only.
            checkpoint_manager: Optional checkpoint manager for
                sandbox state snapshots during apply.  When ``None``,
                checkpoint hooks are silently skipped.
        """
        if lifecycle_service is None:
            raise ValidationError("lifecycle_service must not be None")
        self._lifecycle = lifecycle_service
        self._changeset_store = changeset_store
        self._checkpoint_manager = checkpoint_manager
        self._logger = logger.bind(service="plan_apply")

    # -- Diff output ---------------------------------------------------------

    def diff(
        self,
        plan_id: str,
        fmt: str = "rich",
    ) -> str:
        """Generate diff output for a plan's ChangeSet.

        Args:
            plan_id: The plan ULID.
            fmt: Output format (``rich``, ``plain``, ``json``, ``yaml``).

        Returns:
            Rendered diff string.

        Raises:
            PlanError: If the plan has no changeset.
        """
        import json as json_mod

        import yaml as yaml_mod

        plan = self._lifecycle.get_plan(plan_id)
        changeset = self._resolve_changeset(plan)

        if changeset is None:
            raise PlanError(
                f"Plan {plan_id} has no ChangeSet. "
                "Execute phase must complete before viewing diff."
            )

        if fmt == "json":
            data = _render_diff_json(changeset)
            return json_mod.dumps(data, indent=2, default=str)
        if fmt == "yaml":
            data = _render_diff_json(changeset)
            return yaml_mod.dump(
                data, default_flow_style=False, sort_keys=False
            ).rstrip("\n")
        if fmt == "plain":
            return _render_diff_plain(changeset)
        return _render_diff_rich(changeset)

    # -- Artifacts output ----------------------------------------------------

    def artifacts(
        self,
        plan_id: str,
        fmt: str = "rich",
    ) -> str:
        """Generate artifacts summary for a plan.

        Args:
            plan_id: The plan ULID.
            fmt: Output format (``rich``, ``plain``, ``json``, ``yaml``).

        Returns:
            Rendered artifacts string.
        """
        from cleveragents.cli.formatting import format_output

        plan = self._lifecycle.get_plan(plan_id)
        changeset = self._resolve_changeset(plan)
        data = _build_artifacts_dict(plan, changeset)

        if fmt in ("json", "yaml", "plain", "table"):
            return format_output(data, fmt)
        # Rich fallback: use json
        return format_output(data, "json")

    # -- Persist apply summary -----------------------------------------------

    def persist_apply_summary(
        self,
        plan_id: str,
        files_changed: int,
        validations_run: int = 0,
    ) -> Plan:
        """Store apply summary metadata into the plan.

        Args:
            plan_id: The plan ULID.
            files_changed: Number of files changed during apply.
            validations_run: Number of validations executed.

        Returns:
            The updated Plan.
        """
        plan = self._lifecycle.get_plan(plan_id)

        # Store summary in error_details (used as general metadata store)
        details = dict(plan.error_details) if plan.error_details else {}
        details["apply_files_changed"] = str(files_changed)
        details["apply_validations_run"] = str(validations_run)
        details["apply_completed_at"] = datetime.now(tz=UTC).isoformat()
        plan.error_details = details
        plan.timestamps.updated_at = datetime.now(tz=UTC)

        self._lifecycle._commit_plan(plan)

        self._logger.info(
            "Apply summary persisted",
            plan_id=plan_id,
            files_changed=files_changed,
            validations_run=validations_run,
        )

        return plan

    # -- Merge failure handling ----------------------------------------------

    def handle_merge_failure(
        self,
        plan_id: str,
        conflict_details: str,
    ) -> Plan:
        """Handle a sandbox merge failure.

        Sets the plan to ERRORED state with conflict details and
        logs the failure.  Sandbox rollback is signalled by the
        error state — the sandbox manager checks plan state on next
        access.

        Args:
            plan_id: The plan ULID.
            conflict_details: Description of the merge conflicts.

        Returns:
            The updated Plan in ERRORED state.
        """
        plan = self._lifecycle.get_plan(plan_id)

        # Store conflict details
        details = dict(plan.error_details) if plan.error_details else {}
        details["merge_conflict"] = conflict_details
        details["sandbox_rollback"] = "pending"
        plan.error_details = details

        self._lifecycle._commit_plan(plan)

        # Transition to errored
        error_msg = f"Merge failed: {conflict_details}"
        plan = self._lifecycle.fail_apply(plan_id, error_msg)

        self._logger.error(
            "Merge failure handled",
            plan_id=plan_id,
            conflict_details=conflict_details,
        )

        return plan

    # -- Empty ChangeSet guard -----------------------------------------------

    def guard_empty_changeset(
        self,
        plan_id: str,
        allow_empty: bool = False,
    ) -> bool:
        """Check whether apply should be blocked due to empty ChangeSet.

        Args:
            plan_id: The plan ULID.
            allow_empty: If ``True``, allow apply even when ChangeSet
                has no entries.

        Returns:
            ``True`` if apply should proceed, ``False`` if blocked.

        Raises:
            PlanError: If apply is blocked and ``allow_empty`` is False.
        """
        plan = self._lifecycle.get_plan(plan_id)
        changeset = self._resolve_changeset(plan)

        if changeset is not None and len(changeset.entries) > 0:
            return True

        if allow_empty:
            self._logger.info(
                "Empty ChangeSet allowed via --allow-empty",
                plan_id=plan_id,
            )
            return True

        raise PlanError(
            f"Plan {plan_id} has an empty ChangeSet. "
            "No changes to apply. Use --allow-empty to override."
        )

    @property
    def checkpoint_manager(self) -> CheckpointManager | None:
        """Return the checkpoint manager, if configured."""
        return self._checkpoint_manager

    # -- Validation-gated apply ---------------------------------------------

    def apply_with_validation_gate(
        self,
        plan_id: str,
        validation_summary: dict[str, Any] | None = None,
        *,
        allow_empty: bool = False,
    ) -> ApplyResult:
        """Execute a validation-gated apply for a plan.

        Checks that all required validations have passed before
        proceeding with apply.  If validations failed, the plan is
        set to ``constrained`` state with an actionable error message.

        Args:
            plan_id: The plan ULID.
            validation_summary: Validation summary dict from the
                Execute phase.  If ``None``, the plan's stored
                ``validation_summary`` is used.
            allow_empty: If ``True``, allow apply on empty ChangeSet.

        Returns:
            An ``ApplyResult`` describing the outcome.
        """
        from cleveragents.domain.models.core.plan import (
            ProcessingState,
        )

        plan = self._lifecycle.get_plan(plan_id)

        # Guard: already in terminal state
        if plan.is_terminal:
            if plan.processing_state == ProcessingState.APPLIED:
                self._logger.info(
                    "Plan already applied",
                    plan_id=plan_id,
                )
                return ApplyResult(
                    outcome=ApplyOutcome.ALREADY_APPLIED,
                    plan_id=plan_id,
                    message=(
                        f"Plan {plan_id} has already been applied. "
                        "Re-apply is not permitted."
                    ),
                )
            # Other terminal states (errored, cancelled, constrained)
            # are also blocked
            self._logger.info(
                "Plan in terminal state, cannot apply",
                plan_id=plan_id,
                state=plan.processing_state.value,
            )
            return ApplyResult(
                outcome=ApplyOutcome.CONSTRAINED,
                plan_id=plan_id,
                message=(
                    f"Plan {plan_id} is in terminal state "
                    f"'{plan.processing_state.value}' and cannot be applied."
                ),
            )

        # Guard: empty changeset
        changeset = self._resolve_changeset(plan)
        if changeset is None or (len(changeset.entries) == 0 and not allow_empty):
            self._logger.info(
                "Apply blocked: empty changeset",
                plan_id=plan_id,
            )
            return ApplyResult(
                outcome=ApplyOutcome.BLOCKED_EMPTY,
                plan_id=plan_id,
                message=(
                    f"Plan {plan_id} has an empty ChangeSet. "
                    "No changes to apply. Use --allow-empty to override."
                ),
            )

        # Resolve validation summary
        vs = validation_summary or plan.validation_summary
        req_passed, req_failed, total = self._extract_validation_counts(vs)

        # Gate: required validations must all pass
        if req_failed > 0:
            reason = (
                f"Apply refused: {req_failed} required Execute-phase "
                f"validation(s) did not pass "
                f"({req_passed} passed, {req_failed} failed). "
                "Fix the issues and re-run validations before applying. "
                "Use `agents plan prompt <PLAN_ID>` to resume or "
                "`agents plan correct <PLAN_ID>` to provide fixes."
            )
            self._logger.warning(
                "Apply blocked by validation gate",
                plan_id=plan_id,
                required_passed=req_passed,
                required_failed=req_failed,
            )

            # Transition to constrained if in Apply phase
            try:
                self._lifecycle.constrain_apply(plan_id, reason)
            except (PlanError, ValidationError):
                # Plan may not be in Apply phase yet; store reason
                # in validation_summary for CLI display
                self._logger.debug(
                    "Could not transition to constrained "
                    "(plan may not be in Apply phase)",
                    plan_id=plan_id,
                )

            return ApplyResult(
                outcome=ApplyOutcome.CONSTRAINED,
                plan_id=plan_id,
                message=reason,
                validations_total=total,
                validations_passed=req_passed,
                validations_failed=req_failed,
            )

        # All required validations passed — proceed with apply
        files_changed = len(changeset.entries)

        # Compute changeset statistics for SEC7 audit enrichment (issue #716)
        changeset_summary = changeset.summary()
        resources_modified = changeset_summary.get("resources_involved", 0)

        self._logger.info(
            "Validation gate passed, applying",
            plan_id=plan_id,
            files_changed=files_changed,
            resources_modified=resources_modified,
            required_passed=req_passed,
        )

        # Checkpoint: pre_apply snapshot
        self._try_checkpoint(plan_id, "pre_apply")

        # Record apply start time for duration measurement
        apply_start = datetime.now(tz=UTC)

        # Persist apply summary
        self.persist_apply_summary(
            plan_id=plan_id,
            files_changed=files_changed,
            validations_run=total,
        )

        # Compute apply duration
        apply_duration_seconds = (datetime.now(tz=UTC) - apply_start).total_seconds()

        # Transition to applied via lifecycle service, passing changeset
        # statistics so the PLAN_APPLIED event carries rich SEC7 audit context.
        try:
            self._lifecycle.complete_apply(
                plan_id,
                files_changed=files_changed,
                lines_added=changeset_summary.get("creates", 0),
                lines_removed=changeset_summary.get("deletes", 0),
                resources_modified=resources_modified,
                apply_duration_seconds=apply_duration_seconds,
            )
        except (PlanError, ValidationError):
            # If lifecycle transition fails, attempt rollback
            self._try_rollback(plan_id)
            self._logger.debug(
                "Could not transition to applied (plan may not be in Apply phase)",
                plan_id=plan_id,
            )

        return ApplyResult(
            outcome=ApplyOutcome.APPLIED,
            plan_id=plan_id,
            message=(
                f"Plan {plan_id} applied successfully. "
                f"{files_changed} file(s) changed, "
                f"{total} validation(s) evaluated."
            ),
            files_changed=files_changed,
            validations_total=total,
            validations_passed=req_passed,
            validations_failed=0,
        )

    @staticmethod
    def _extract_validation_counts(
        vs: dict[str, Any] | None,
    ) -> tuple[int, int, int]:
        """Extract required passed/failed counts from a validation summary.

        Returns:
            Tuple of (required_passed, required_failed, total).
        """
        if vs is None:
            # No validation summary — treat as all passed (0 validations)
            return 0, 0, 0

        req_passed = int(vs.get("required_passed", 0))
        req_failed = int(vs.get("required_failed", 0))
        total = int(vs.get("total", req_passed + req_failed))

        return req_passed, req_failed, total

    # -- Checkpoint helpers --------------------------------------------------

    def _try_checkpoint(
        self,
        plan_id: str,
        phase: str,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        """Create a checkpoint if a checkpoint manager is configured.

        Non-fatal: logs and swallows any errors.
        """
        if self._checkpoint_manager is None:
            return
        try:
            # Build a lightweight proxy for checkpoint creation
            sandbox = _ApplyPhaseProxy(plan_id)
            self._checkpoint_manager.create_checkpoint(
                sandbox=sandbox,
                plan_id=plan_id,
                phase=phase,
                metadata=metadata or {},
            )
        except Exception:
            self._logger.debug(
                "Checkpoint creation failed (non-fatal)",
                plan_id=plan_id,
                phase=phase,
                exc_info=True,
            )

    def _try_rollback(self, plan_id: str) -> None:
        """Attempt rollback to the last checkpoint for a plan.

        Non-fatal: logs and swallows any errors.
        """
        if self._checkpoint_manager is None:
            return
        try:
            sandbox_id = f"apply-{plan_id[:16]}"
            checkpoints = self._checkpoint_manager.list_checkpoints(sandbox_id)
            if checkpoints:
                self._checkpoint_manager.rollback_to(checkpoints[-1])
        except Exception:
            self._logger.debug(
                "Checkpoint rollback failed (non-fatal)",
                plan_id=plan_id,
                exc_info=True,
            )

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

    def _resolve_changeset(self, plan: Plan) -> SpecChangeSet | None:
        """Resolve the ChangeSet for a plan.

        Checks the changeset store first (if available), then builds
        an empty SpecChangeSet stub if the plan has a changeset_id but
        no store is configured.
        """
        if plan.changeset_id is None:
            return None

        # Try store lookup
        if self._changeset_store is not None:
            stored = self._changeset_store.get(plan.changeset_id)
            if stored is not None:
                return stored

        # Fallback: empty changeset with the known ID
        return SpecChangeSet(
            changeset_id=plan.changeset_id,
            plan_id=plan.identity.plan_id,
        )

    # -- ChangeSet cleanup --------------------------------------------------

    def cleanup_changeset(self, plan_id: str) -> int:
        """Delete persisted changeset entries and invocations for a plan.

        Called on plan cancel and apply failure to free storage used by
        artifacts that are no longer needed.

        Args:
            plan_id: The plan ULID.

        Returns:
            Number of artifact records deleted.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        deleted = 0
        store = self._changeset_store
        if store is not None and hasattr(store, "delete_for_plan"):
            deleted += store.delete_for_plan(plan_id)

        self._logger.info(
            "Changeset artifacts cleaned up",
            plan_id=plan_id,
            records_deleted=deleted,
        )
        return deleted


# ---------------------------------------------------------------------------
# Apply phase proxy for checkpoint hooks
# ---------------------------------------------------------------------------


class _ApplyPhaseProxy:
    """Lightweight proxy providing sandbox-like interface for apply checkpoints.

    Used by ``PlanApplyService._try_checkpoint`` when no real sandbox
    object is available.
    """

    def __init__(self, plan_id: str) -> None:
        self._sandbox_id = f"apply-{plan_id[:16]}"

    @property
    def sandbox_id(self) -> str:
        """Return a synthetic sandbox ID."""
        return self._sandbox_id

    @property
    def context(self) -> None:
        """Return ``None`` (no physical sandbox path)."""
        return None
