"""Plan service for managing AI-driven code generation plans.

.. deprecated::
    ``PlanService`` is the legacy plan management service and will be
    removed in a future release.  Use
    :class:`~cleveragents.application.services.plan_lifecycle_service.PlanLifecycleService`
    for all new plan workflows (Action -> Strategize -> Execute -> Apply).

This service handles creating, building, and applying plans that
contain AI-generated code changes. Uses repository pattern and Unit of Work
for persistence (ADR-007).
"""

from __future__ import annotations

import ast
import asyncio
import os
import re
import uuid
import warnings
from collections.abc import AsyncIterator, Callable, Sequence
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

import structlog

from cleveragents.application.services.actor_service import ActorService

if TYPE_CHECKING:
    from langchain_core.language_models import BaseLanguageModel

    from cleveragents.application.services.memory_service import (
        ConversationBufferMemoryAdapter,
        MemoryService,
    )
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import NotFoundError, PlanError, ValidationError
from cleveragents.domain.models.core import (
    Actor,
    Change,
    OperationType,
    Plan,
    PlanBuild,
    PlanResult,
    PlanStatus,
    Project,
)
from cleveragents.domain.providers.ai_provider import (
    ActorInvocationContext,
    AIProviderInterface,
    ProviderResponse,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.providers.registry import ProviderRegistry, get_provider_registry


class PlanService:
    """Service for managing code generation plans.

    .. deprecated::
        ``PlanService`` is deprecated.  Use ``PlanLifecycleService`` instead
        for the v3 plan lifecycle (Action -> Strategize -> Execute -> Apply).

    Plans contain instructions and context for AI to generate code changes.
    """

    def __init__(
        self,
        settings: Settings,
        unit_of_work: UnitOfWork,
        ai_provider: AIProviderInterface | None = None,
        llm: BaseLanguageModel | None = None,
        provider_registry: ProviderRegistry | None = None,
        actor_service: ActorService | None = None,
    ):
        """Initialize the plan service.

        Args:
            settings: Application settings
            unit_of_work: Unit of Work for database transactions
            ai_provider: AI provider for generating code changes (optional)
            llm: Language model for memory service (optional)
            provider_registry: Provider registry for runtime overrides (optional)
            actor_service: Actor management service for resolving actor selection
        """
        warnings.warn(
            "PlanService is deprecated. Use PlanLifecycleService for the v3 "
            "plan lifecycle (Action -> Strategize -> Execute -> Apply).",
            DeprecationWarning,
            stacklevel=2,
        )
        self.settings = settings
        self.unit_of_work = unit_of_work
        self.ai_provider = ai_provider
        self._provider_registry: ProviderRegistry | None = provider_registry
        self.actor_service = actor_service or ActorService(settings, unit_of_work)
        self._memory_services: dict[str, MemoryService] = {}
        self._llm = llm
        self._logger = structlog.get_logger(__name__).bind(service="plan")

    def _use_mock_provider(self) -> bool:
        """Return True when the runtime is configured to force the mock provider."""

        return os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
            "1",
            "true",
            "yes",
        )

    def _get_provider_registry(self) -> ProviderRegistry:
        """Return the provider registry instance (creating it lazily if needed)."""

        if self._provider_registry is None:
            self._provider_registry = get_provider_registry(self.settings)
        return self._provider_registry

    def _provider_selection_metadata(
        self,
        *,
        actor: Actor,
        actor_source: str,
        resolved_provider: str,
        resolved_model: str,
    ) -> dict[str, Any]:
        """Build a metadata payload describing actor/provider selection."""

        defaults = self.settings.resolve_provider_defaults()
        return {
            "actor": actor.name,
            "actor_source": actor_source,
            "actor_is_default": actor.is_default,
            "actor_unsafe": actor.unsafe,
            "resolved_provider": resolved_provider,
            "resolved_model": resolved_model,
            "default_provider": defaults.provider or "",
            "default_provider_source": defaults.provider_source,
            "default_model": defaults.model or "",
            "default_model_source": defaults.model_source,
            "configured_providers": self.settings.configured_provider_names(),
        }

    def _provider_error_details(
        self,
        *,
        provider: str | None,
        model: str | None,
        actor: str | None,
    ) -> dict[str, Any]:
        """Describe provider diagnostics for user-facing PlanError details."""

        diagnostics = self.settings.provider_configuration_diagnostics()
        diagnostics.setdefault(
            "configured_providers", self.settings.configured_provider_names()
        )
        diagnostics.setdefault(
            "expected_env_vars", self.settings.provider_expected_env_vars()
        )
        diagnostics["requested_provider"] = provider or ""
        diagnostics["requested_model"] = model or ""
        diagnostics["requested_actor"] = actor or ""
        diagnostics.setdefault(
            "help",
            "Select an actor with --actor or set a default actor via "
            "'agents actor set-default <name>'.",
        )
        diagnostics.setdefault(
            "provider_selection",
            {
                "actor": actor or "",
                "provider": provider or "",
                "model": model or "",
            },
        )
        diagnostics.setdefault("actor", actor or "")
        return diagnostics

    def _build_actor_context(
        self,
        actor: Actor,
        provider: str | None = None,
        model: str | None = None,
    ) -> ActorInvocationContext:
        """Create the invocation context passed to providers and graphs."""

        config_blob: dict[str, Any] = dict(actor.config_blob or {})
        options_raw = config_blob.get("options")
        options_payload: dict[str, Any] = (
            cast(dict[str, Any], options_raw) if isinstance(options_raw, dict) else {}
        )
        initial_context_raw = config_blob.get("initial_context") or config_blob.get(
            "context_variables"
        )
        initial_context: dict[str, Any] = (
            cast(dict[str, Any], initial_context_raw)
            if isinstance(initial_context_raw, dict)
            else {}
        )
        graph_descriptor_raw = actor.graph_descriptor or config_blob.get(
            "graph_descriptor"
        )
        graph_descriptor: dict[str, Any] | None = (
            cast(dict[str, Any], graph_descriptor_raw)
            if isinstance(graph_descriptor_raw, dict)
            else None
        )
        provider_value = provider or actor.provider or "mock-provider"
        model_value = model or actor.model or provider_value

        return ActorInvocationContext(
            name=actor.name,
            provider=provider_value,
            model=model_value,
            options=options_payload,
            graph_descriptor=graph_descriptor,
            unsafe=actor.unsafe,
            config_hash=actor.config_hash,
            config_blob=config_blob,
            initial_context=initial_context,
        )

    def _resolve_actor(self, actor_name: str | None) -> tuple[Actor, str]:
        """Resolve the actor either from the flag or the default entry."""

        if not self.actor_service:
            raise PlanError(
                message="Actor support is not configured",
                details={"hint": "Ensure ActorService is wired in the container."},
            )

        testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
            "true",
            "yes",
            "1",
        )

        fallback_error: Exception | None = None
        actor: Actor | None
        actor_source = "unknown"

        try:
            if actor_name:
                actor = self.actor_service.get_actor(actor_name)
                actor_source = "explicit"
            else:
                actor = self.actor_service.get_default_actor()
                actor_source = "default"
        except (ValidationError, NotFoundError) as exc:
            fallback_error = exc
            actor = None

        if not actor and isinstance(fallback_error, ValidationError):
            details = self._provider_error_details(
                provider="",
                model="",
                actor=actor_name or "",
            )
            raise PlanError(
                message=str(fallback_error), details=details
            ) from fallback_error

        if not actor and testing_mode:
            try:
                actor = self.actor_service.ensure_default_mock_actor(force=True)
                actor_source = "default-mock"
            except Exception as ensure_exc:
                fallback_error = fallback_error or ensure_exc
                actor = None

        if not actor:
            details = self._provider_error_details(
                provider="",
                model="",
                actor=actor_name or "",
            )
            message = (
                "No actor configured. Pass --actor <name> or set a default actor "
                "with 'agents actor set-default <name>'."
            )
            raise PlanError(message=message, details=details) from fallback_error

        return actor, actor_source

    def _resolve_ai_provider_for_actor(
        self,
        actor_name: str | None,
    ) -> tuple[
        AIProviderInterface,
        str,
        str,
        Actor,
        dict[str, Any],
        ActorInvocationContext,
    ]:
        """Resolve the AI provider for the selected actor."""

        actor, actor_source = self._resolve_actor(actor_name)

        actor_provider: str | None = actor.provider or None
        actor_model: str | None = actor.model or None

        provider_value: str = actor_provider or ""
        model_value: str = actor_model or provider_value

        if not provider_value:
            raise PlanError(
                message="Actor is missing provider configuration",
                details=self._provider_error_details(
                    provider=provider_value,
                    model=model_value,
                    actor=actor.name,
                ),
            )

        if actor.unsafe:
            self._logger.warning(
                "plan_service.actor_marked_unsafe",
                actor=actor.name,
                actor_source=actor_source,
            )

        def _normalize_provider(name: str | None, fallback: str) -> str:
            base = str(name or fallback or "mock-provider")
            return "mock-provider" if base.lower().startswith("mock") else base

        provider_instance: AIProviderInterface | None = None
        provider_name: str = ""
        model_name: str = model_value or provider_value

        if self.ai_provider is not None:
            provider_instance = self.ai_provider
            provider_attr_raw = getattr(provider_instance, "name", None)
            provider_attr: str | None = (
                str(provider_attr_raw)
                if isinstance(provider_attr_raw, str) and provider_attr_raw
                else None
            )
            model_attr_raw = getattr(provider_instance, "model_id", None)
            model_attr: str | None = (
                str(model_attr_raw)
                if isinstance(model_attr_raw, str) and model_attr_raw
                else None
            )
            provider_name = _normalize_provider(
                actor_provider,
                provider_attr if provider_attr is not None else provider_value,
            )
            model_name = (
                actor_model
                or model_attr
                or model_value
                or provider_value
                or provider_name
            )

        elif self._use_mock_provider():
            provider_instance = self.ai_provider
            if provider_instance is None:
                raise PlanError(
                    message="No AI provider configured",
                    details=self._provider_error_details(
                        provider=provider_value,
                        model=model_value,
                        actor=actor.name,
                    ),
                )
            provider_name = _normalize_provider(actor_provider, provider_value)
            model_name = actor_model or model_value or provider_value or provider_name

        elif self._provider_registry is not None:
            registry = self._provider_registry
            try:
                provider_instance = registry.create_ai_provider(
                    provider_type=provider_value,
                    model_id=model_value or None,
                )
            except (ValidationError, ValueError) as exc:
                raise PlanError(
                    message=str(exc),
                    details=self._provider_error_details(
                        provider=provider_value,
                        model=model_value,
                        actor=actor.name,
                    ),
                ) from exc
            provider_name = provider_value
            model_name = model_value or provider_name

        else:
            registry = self._get_provider_registry()
            try:
                provider_instance = registry.create_ai_provider(
                    provider_type=provider_value,
                    model_id=model_value or None,
                )
            except (ValidationError, ValueError) as exc:
                raise PlanError(
                    message=str(exc),
                    details=self._provider_error_details(
                        provider=provider_value,
                        model=model_value,
                        actor=actor.name,
                    ),
                ) from exc
            provider_name = provider_value
            model_name = model_value or provider_name

        provider_instance_mutable = cast(Any, provider_instance)
        if hasattr(provider_instance_mutable, "name"):
            provider_instance_mutable.name = provider_name
        if hasattr(provider_instance_mutable, "model_id"):
            provider_instance_mutable.model_id = model_name

        selection_metadata = self._provider_selection_metadata(
            actor=actor,
            actor_source=actor_source,
            resolved_provider=provider_name,
            resolved_model=model_name,
        )

        actor_context = self._build_actor_context(
            actor=actor,
            provider=provider_name,
            model=model_name,
        )

        return (
            provider_instance,
            provider_name,
            model_name,
            actor,
            selection_metadata,
            actor_context,
        )

    def get_memory_service(
        self,
        session_id: str,
        *,
        persistent: bool = True,
        max_messages: int | None = None,
    ) -> MemoryService:
        """Retrieve or initialize a ``MemoryService`` instance for a session."""
        from cleveragents.application.services.memory_service import MemoryService

        service = self._memory_services.get(session_id)
        if service is None:
            connection_string = self.settings.database_url if persistent else None
            service = MemoryService(
                session_id=session_id,
                connection_string=connection_string,
                max_messages=max_messages,
            )
            self._memory_services[session_id] = service
            return service

        # Replace non-persistent memory with persistent one if requested
        if persistent and service.connection_string is None:
            service = MemoryService(
                session_id=session_id,
                connection_string=self.settings.database_url,
                max_messages=max_messages,
            )
            self._memory_services[session_id] = service
            return service

        # Replace persistent memory with in-memory variant when explicitly requested
        if not persistent and service.connection_string is not None:
            service = MemoryService(session_id=session_id, max_messages=max_messages)
            self._memory_services[session_id] = service
            return service

        if max_messages is not None and service.max_messages != max_messages:
            service.set_max_messages(max_messages)

        return service

    def get_conversation_memory(
        self,
        session_id: str,
        *,
        persistent: bool = True,
        memory_key: str = "history",
        input_key: str = "input",
        output_key: str = "output",
        return_messages: bool = True,
        max_messages: int | None = None,
    ) -> ConversationBufferMemoryAdapter:
        """Return a conversation buffer memory adapter for the session."""

        service = self.get_memory_service(
            session_id,
            persistent=persistent,
            max_messages=max_messages,
        )
        return service.create_conversation_memory(
            memory_key=memory_key,
            input_key=input_key,
            output_key=output_key,
            return_messages=return_messages,
        )

    def clear_memory(self, session_id: str, *, forget_history: bool = False) -> None:
        """Remove cached memory service for a session."""

        service = self._memory_services.pop(session_id, None)
        if service and forget_history:
            service.clear()

    def _build_langsmith_config(
        self,
        project: Project,
        plan: Plan | None,
        *,
        run_name: str,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Build base LangSmith configuration with project/plan metadata."""

        if not getattr(self.settings, "is_langsmith_enabled", False):
            return {}

        base_metadata: dict[str, Any] = {
            "project_id": project.id,
            "project_name": project.name,
        }
        if plan and plan.id:
            base_metadata["plan_id"] = plan.id
            base_metadata["plan_name"] = plan.name
        user_id = getattr(self.settings, "langsmith_user_id", None)
        if user_id:
            base_metadata.setdefault("user_id", user_id)
        if metadata:
            base_metadata |= metadata

        base_tags = ["service:plan"]
        global_tags = list(self.settings.langsmith_tags)
        if global_tags:
            base_tags.extend(global_tags)
        if project.id is not None:
            base_tags.append(f"project:{project.id}")
        if plan and plan.id is not None:
            base_tags.append(f"plan:{plan.id}")
        if tags:
            base_tags.extend(tags)

        return (
            self.settings.build_langsmith_config(
                tags=base_tags,
                metadata=base_metadata,
                run_name=run_name,
            )
            or {}
        )

    def _prepare_langsmith_config(
        self,
        project: Project,
        plan: Plan | None,
        *,
        run_name: str,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        thread_prefix: str = "plan-service",
    ) -> dict[str, Any]:
        config = self._build_langsmith_config(
            project,
            plan,
            run_name=run_name,
            tags=tags,
            metadata=metadata,
        )
        if not config:
            return {}
        config.setdefault("configurable", {})
        config["configurable"]["thread_id"] = f"{thread_prefix}-{uuid.uuid4()}"
        return config

    def create_plan(
        self, project: Project, prompt: str, name: str | None = None
    ) -> Plan:
        """Create a new plan with instructions for AI.

        Args:
            project: The project to create the plan in
            prompt: Instructions for what the plan should do
            name: Optional plan name (auto-generated if not provided)

        Returns:
            Plan: The created plan

        Raises:
            ValidationError: If plan creation fails
        """
        if not name:
            # Generate name from prompt (first few words)
            words = prompt.split()[:3] if prompt else ["new", "plan"]
            name = "_".join(words).lower()

        # Ensure project has a valid ID
        if not project.id:
            raise ValidationError(
                message=(
                    "Cannot create plan: this directory is not linked to a saved "
                    "CleverAgents project. No project found for this directory. "
                    "Run 'cleveragents init' (or 'agents init') at the repository "
                    "root to create the project metadata, then rerun this command."
                ),
                details={
                    "project_name": project.name,
                    "why": (
                        "'.cleveragents/project.name' or the local project database "
                        "entry was not found for this directory"
                    ),
                    "how_to_fix": (
                        "Run 'cleveragents init' (or 'agents init') at the repo root "
                        "to create the project metadata, then rerun this command"
                    ),
                },
            )

        plan = Plan(
            id=None,
            project_id=project.id,
            name=name,
            prompt=prompt,
            status=PlanStatus.PENDING,
            current=False,  # Will be set to current after creation
            created_at=datetime.now(),
            updated_at=datetime.now(),
            build=None,
            build_started_at=None,
            build_completed_at=None,
            model_used=None,
            token_count=None,
            result=None,
            applied_at=None,
            files_created=None,
            files_modified=None,
            files_deleted=None,
        )

        with self.unit_of_work.transaction() as ctx:
            # Create the plan
            created_plan = ctx.plans.create(plan)

            # Set as current plan for the project
            if project.id and created_plan.id:
                ctx.plans.set_current(project.id, created_plan.id)
                created_plan.current = True

        return created_plan

    def new_plan(self, project: Project, name: str | None = None) -> Plan:
        """Create a new empty plan.

        Args:
            project: The project to create the plan in
            name: Optional plan name (defaults to 'new-plan')

        Returns:
            Plan: The created plan
        """
        # Use a placeholder prompt since Plan model requires non-empty string
        return self.create_plan(project, prompt="New plan", name=name or "new-plan")

    def build_plan(
        self,
        project: Project,
        progress_callback: Callable[[int], None] | None = None,
        actor: str | None = None,
    ) -> list[Change]:
        """Build the current plan to generate changes.

        Args:
            project: The project containing the plan
            progress_callback: Optional callback for progress updates (0-100)
            actor: Optional actor name (defaults to configured default actor)

        Returns:
            List of generated changes

        Raises:
            PlanError: If no current plan or build fails
        """
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                raise PlanError(
                    message="No current plan to build",
                    details={"hint": "Create a plan first with 'agents tell'"},
                )

            # Update plan status to building
            current_plan.status = PlanStatus.BUILDING
            current_plan.updated_at = datetime.now()
            ctx.plans.update(current_plan)

        # Ensure we have a valid plan ID
        if not current_plan.id:
            raise PlanError(
                message="Plan does not have a valid ID",
                details={"plan_name": current_plan.name},
            )

        (
            provider_instance,
            provider_name,
            model_name,
            selected_actor,
            selection_metadata,
            actor_context,
        ) = self._resolve_ai_provider_for_actor(actor)

        self._logger.info(
            "plan_service.build_plan.actor_selected",
            actor=selected_actor.name,
            actor_source=selection_metadata["actor_source"],
            actor_is_default=selected_actor.is_default,
            actor_unsafe=selected_actor.unsafe,
            provider=provider_name,
            model=model_name,
            default_provider=selection_metadata["default_provider"],
            default_provider_source=selection_metadata["default_provider_source"],
            default_model=selection_metadata["default_model"],
            default_model_source=selection_metadata["default_model_source"],
        )

        # Get context files for the plan
        with self.unit_of_work.transaction() as ctx:
            contexts = ctx.contexts.get_for_plan(current_plan.id)

        # Call AI provider to generate changes
        provider_response = provider_instance.generate_changes(
            project=project,
            plan=current_plan,
            contexts=contexts,
            actor_context=actor_context,
            progress_callback=progress_callback,
        )

        # Check if the provider returned an error
        if provider_response.error_message:
            raise PlanError(
                message=provider_response.error_message,
                details={
                    "plan": current_plan.name,
                    "model": provider_response.model_used or model_name,
                    "provider": provider_name,
                    "actor": selected_actor.name,
                    "provider_selection": selection_metadata,
                },
            )

        changes = provider_response.changes
        model_used = provider_response.model_used or model_name
        token_count = provider_response.token_count

        # Save changes and update plan status
        with self.unit_of_work.transaction() as ctx:
            for change in changes:
                ctx.changes.add(change)

            # Update plan with build info
            current_plan.status = PlanStatus.BUILT
            current_plan.updated_at = datetime.now()
            current_plan.build = PlanBuild(
                id=None,
                plan_id=current_plan.id or 0,
                started_at=datetime.now(),
                completed_at=datetime.now(),
                model_used=model_used,
                token_count=token_count,
                error_message=None,
            )
            ctx.plans.update(current_plan)

        return changes

    def auto_debug_build(
        self,
        project: Project,
        max_attempts: int = 3,
        progress_callback: Callable[[int], None] | None = None,
    ) -> tuple[bool, list[Change], str | None]:
        """Build the current plan with automatic debugging on failure.

        Args:
            project: The project containing the plan
            max_attempts: Maximum number of debug attempts (default: 3)
            progress_callback: Optional callback for progress updates (0-100)

        Returns:
            Tuple of (success, changes, error_message)
            - success: Whether the build ultimately succeeded
            - changes: List of generated changes (if any)
            - error_message: Final error message if failed

        Raises:
            PlanError: If no current plan exists
        """
        from cleveragents.agents import AutoDebugAgent, AutoDebugState
        from cleveragents.domain.models.core import DebugAttempt

        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                raise PlanError(
                    message="No current plan to build",
                    details={"hint": "Create a plan first with 'agents tell'"},
                )

        attempt_number = 0
        last_error: str | None = None
        changes: list[Change] = []
        last_debug_attempt_id: int | None = None

        while attempt_number < max_attempts:
            attempt_number += 1

            try:
                # Try to build the plan
                changes = self.build_plan(project, progress_callback)

                # If this was a retry after a debug attempt, mark it as successful
                if last_debug_attempt_id is not None:
                    with self.unit_of_work.transaction() as ctx:
                        debug_attempt = ctx.debug_attempts.get(last_debug_attempt_id)
                        if debug_attempt:
                            debug_attempt.success = True
                            ctx.debug_attempts.update(debug_attempt)

                # If successful, return immediately
                return (True, changes, None)

            except Exception as e:
                last_error = str(e)

                # Get the code context for debugging
                with self.unit_of_work.transaction() as ctx:
                    if current_plan.id:
                        contexts = ctx.contexts.get_for_plan(current_plan.id)
                        code_context = "\n\n".join(
                            [
                                f"File: {c.path}\n{c.content or ''}"
                                for c in contexts
                                if c.content
                            ]
                        )
                    else:
                        code_context = ""

                # Initialize AutoDebugAgent
                agent = AutoDebugAgent(
                    llm=self._llm,
                    provider="openai",
                    model="gpt-4",
                    temperature=0.3,
                    max_fix_attempts=1,  # Single fix attempt per debug session
                )

                # Prepare debug state
                debug_state: AutoDebugState = {
                    "error_message": last_error,
                    "code_context": code_context,
                    "attempted_fixes": [],
                    "current_fix": {},
                    "fix_validated": False,
                    "messages": [],
                    "context": {},
                    "result": None,
                    "error": None,
                    "metadata": {},
                }

                # Run debug workflow with LangSmith metadata when enabled
                config = self._prepare_langsmith_config(
                    project,
                    current_plan,
                    run_name=f"PlanService.auto_debug_build.attempt_{attempt_number}",
                    tags=["auto-debug", f"retry:{attempt_number}"],
                    metadata={
                        "retry_attempt": attempt_number,
                        "retry_limit": max_attempts,
                        "error_message": last_error,
                    },
                    thread_prefix="auto-debug",
                )
                final_state: AutoDebugState = agent.invoke(debug_state, config or None)

                # Extract the fix result
                result: dict[str, Any] = final_state.get("result") or {}
                fix_successful = result.get("success", False)
                attempted_fix = result.get("fix", {})

                # Save debug attempt to database
                debug_attempt = DebugAttempt(
                    id=None,
                    plan_id=current_plan.id or 0,
                    error_message=last_error,
                    attempted_fix=str(attempted_fix) if attempted_fix else None,
                    success=False,  # Will be updated to True if the retry succeeds
                    attempt_number=attempt_number,
                )

                with self.unit_of_work.transaction() as ctx:
                    created_attempt = ctx.debug_attempts.add(debug_attempt)
                    # Store the ID so we can update it if the retry succeeds
                    if created_attempt and created_attempt.id:
                        last_debug_attempt_id = created_attempt.id

                # If the fix was successful, apply it and retry the build
                if fix_successful and attempted_fix:
                    # Apply the fix (this would update the plan's prompt or context)
                    # For now, we'll update the plan prompt with the fix suggestion
                    fix_description = attempted_fix.get("description", "")
                    fix_code = attempted_fix.get("code", "")

                    with self.unit_of_work.transaction() as ctx:
                        if current_plan.prompt:
                            current_plan.prompt += (
                                f"\n\nAuto-debug fix attempt {attempt_number}:"
                                f"\n{fix_description}\n{fix_code}"
                            )
                        current_plan.updated_at = datetime.now()
                        ctx.plans.update(current_plan)

                    # Continue to next iteration to retry build
                    continue
                else:
                    # If debug couldn't fix it, continue trying other attempts
                    continue

        # If we exhausted all attempts, return failure
        return (False, changes, last_error)

    def get_pending_changes(self, project: Project) -> list[Change]:
        """Get pending (not applied) changes for the current plan.

        Args:
            project: The project to get changes for

        Returns:
            List of pending changes
        """
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                return []

            all_changes = ctx.changes.get_for_plan(current_plan.id)
            return [c for c in all_changes if not c.applied]

    def apply_changes(self, project: Project) -> int:
        """Apply pending changes to the filesystem.

        Args:
            project: The project to apply changes in

        Returns:
            Number of changes applied

        Raises:
            PlanError: If apply fails
        """
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                raise PlanError(
                    message="No current plan to apply",
                    details={"hint": "Build a plan first with 'agents build'"},
                )

            changes = ctx.changes.get_for_plan(current_plan.id)
            pending_changes = [c for c in changes if not c.applied]

            project_root = project.path.resolve()

            def _safe_resolve(raw_path: str) -> Path:
                """Resolve *raw_path* and reject it if it escapes *project_root*."""
                if Path(raw_path).is_absolute():
                    resolved = Path(raw_path).resolve()
                else:
                    resolved = (project.path / raw_path).resolve()
                if not resolved.is_relative_to(project_root):
                    raise PlanError(
                        message=(
                            f"Path traversal rejected: {raw_path} resolves "
                            f"outside project directory"
                        ),
                        details={"file": raw_path},
                    )
                return resolved

            applied_count = 0
            for change in pending_changes:
                try:
                    file_path = _safe_resolve(change.file_path)

                    if change.operation == OperationType.CREATE:
                        file_path.parent.mkdir(parents=True, exist_ok=True)
                        file_path.write_text(change.new_content or "")
                    elif change.operation == OperationType.MODIFY:
                        file_path.write_text(change.new_content or "")
                    elif change.operation == OperationType.DELETE:
                        if file_path.exists():
                            file_path.unlink()
                    elif change.operation == OperationType.MOVE:
                        if change.new_path:
                            new_path = _safe_resolve(change.new_path)
                            if file_path.exists():
                                new_path.parent.mkdir(parents=True, exist_ok=True)
                                file_path.rename(new_path)

                    if change.id:
                        ctx.changes.mark_applied(change.id)
                        applied_count += 1

                except PlanError:
                    raise  # Re-raise path traversal and other PlanErrors directly
                except Exception as e:
                    # Handle operation as either enum or string
                    operation_str = (
                        change.operation.value
                        if hasattr(change.operation, "value")
                        else str(change.operation)
                    )
                    raise PlanError(
                        message=f"Failed to apply change to {change.file_path}: {e}",
                        details={
                            "file": str(change.file_path),
                            "operation": operation_str,
                        },
                    ) from e

            # Update plan status
            current_plan.status = PlanStatus.APPLIED
            current_plan.updated_at = datetime.now()
            current_plan.result = PlanResult(
                success=True,
                files_created=len(
                    [c for c in pending_changes if c.operation == OperationType.CREATE]
                ),
                files_modified=len(
                    [c for c in pending_changes if c.operation == OperationType.MODIFY]
                ),
                files_deleted=len(
                    [c for c in pending_changes if c.operation == OperationType.DELETE]
                ),
                applied_at=datetime.now(),
                error_message=None,
            )
            ctx.plans.update(current_plan)

        return applied_count

    def get_current_plan(self, project: Project) -> Plan | None:
        """Get the current active plan for a project.

        Args:
            project: The project to get the current plan for

        Returns:
            Current plan or None
        """
        if not project.id:
            return None

        with self.unit_of_work.transaction() as ctx:
            return ctx.plans.get_current_for_project(project.id)

    def list_plans(self, project: Project) -> list[Plan]:
        """List all plans in the project.

        Args:
            project: The project to list plans for

        Returns:
            List of plans
        """
        if not project.id:
            return []

        with self.unit_of_work.transaction() as ctx:
            return ctx.plans.get_all_for_project(project.id)

    def switch_to_plan(self, project: Project, name: str) -> Plan:
        """Switch to a different plan.

        Args:
            project: The project containing the plans
            name: Plan name to switch to

        Returns:
            The plan switched to

        Raises:
            ValidationError: If plan not found
        """
        if not project.id:
            raise ValidationError(
                message="Project not initialized",
                details={"hint": "Initialize project first with 'agents init'"},
            )

        with self.unit_of_work.transaction() as ctx:
            plans = ctx.plans.get_all_for_project(project.id)

            for plan in plans:
                if plan.name == name:
                    if plan.id:
                        ctx.plans.set_current(project.id, plan.id)
                    return plan

            raise ValidationError(
                message=f"Plan '{name}' not found",
                details={"available_plans": [p.name for p in plans]},
            )

    def add_to_plan(self, project: Project, prompt: str) -> None:
        """Add additional instructions to the current plan.

        Args:
            project: The project containing the plan
            prompt: Additional instructions

        Raises:
            PlanError: If no current plan
        """
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                raise PlanError(
                    message="No current plan to add to",
                    details={"hint": "Create a plan first with 'agents new'"},
                )

            if current_plan.prompt:
                current_plan.prompt += f"\n\n{prompt}"
            else:
                current_plan.prompt = prompt

            current_plan.updated_at = datetime.now()
            current_plan.status = PlanStatus.PENDING
            ctx.plans.update(current_plan)

    def continue_plan(self, project: Project, prompt: str) -> None:
        """Alias for add_to_plan for compatibility.

        Args:
            project: The project containing the plan
            prompt: Additional instructions
        """
        self.add_to_plan(project, prompt)

    async def generate_plan_streaming(
        self,
        project: Project,
        description: str,
        name: str | None = None,
        actor: str | None = None,
    ) -> AsyncIterator[dict[str, Any]]:
        """Generate plan with streaming events for real-time progress."""

        (
            provider_instance,
            provider_name,
            model_name,
            selected_actor,
            selection_metadata,
            actor_context,
        ) = self._resolve_ai_provider_for_actor(actor)

        plan = self.create_plan(project, description, name)
        with self.unit_of_work.transaction() as ctx:
            contexts = ctx.contexts.get_for_plan(plan.id) if plan.id else []

        stream_iter = provider_instance.stream_changes(
            project=project,
            plan=plan,
            contexts=contexts,
            actor_context=actor_context,
            progress_callback=None,
        )
        sentinel = object()

        async def _pull_event() -> dict[str, Any] | object:
            return await asyncio.to_thread(lambda: next(stream_iter, sentinel))

        final_payload: dict[str, Any] | None = None
        while True:
            event = await _pull_event()
            if event is sentinel:
                break
            if not isinstance(event, dict):
                self._logger.warning(
                    "plan_service.generate_plan_streaming.invalid_event",
                    event_type=type(event).__name__,
                )
                continue
            event_dict = cast(dict[str, Any], event)
            if "__end__" in event_dict:
                final_payload = self._normalize_stream_payload(event_dict["__end__"])
                break
            yield event_dict

        if final_payload is None:
            raise PlanError(
                message="Provider streaming ended before completion",
                details={
                    "plan": plan.name,
                    "provider": provider_name,
                    "actor": selected_actor.name,
                    "provider_selection": selection_metadata,
                },
            )

        error_message = str(final_payload.get("error_message") or "").strip() or None
        if error_message:
            yield {
                "generate_plan": {
                    "status": "failed",
                    "error": error_message,
                }
            }
            raise PlanError(
                message=error_message,
                details={
                    "plan": plan.name,
                    "provider": provider_name,
                    "actor": selected_actor.name,
                    "provider_selection": selection_metadata,
                },
            )

        raw_changes = final_payload.get("changes", [])
        changes = self._coerce_change_list(
            raw_changes,
            plan.name,
            provider_name,
            actor_name=selected_actor.name,
            selection_metadata=selection_metadata,
        )

        model_candidate = final_payload.get("model_used")
        model_used = str(model_candidate) if model_candidate else model_name

        token_raw = final_payload.get("token_count")
        try:
            token_count = int(token_raw) if token_raw is not None else 0
        except (TypeError, ValueError):
            token_count = 0

        validation_errors: list[str] = []
        for change in changes:
            if not (change.new_content and change.file_path.endswith(".py")):
                continue

            sanitized_content, error, reason = self._sanitize_python_content(
                change.new_content
            )
            if error:
                validation_errors.append(
                    f"Syntax error in {change.file_path}: {error!s}"
                )
                continue

            if reason == "docstring_wrapped":
                validation_errors.append(
                    f"{change.file_path} contained invalid Python and was wrapped "
                    "in a placeholder docstring during validation. "
                    "Provide complete, syntactically correct code or rerun the "
                    "request with clearer instructions."
                )

            if sanitized_content != change.new_content:
                self._logger.info(
                    "plan_service.generate_plan_streaming.python_sanitized",
                    file_path=change.file_path,
                    reason=reason or "sanitized",
                )
                change.new_content = sanitized_content

        if validation_errors:
            raise PlanError(
                message="Validation failed: " + "; ".join(validation_errors),
                details={
                    "plan": plan.name,
                    "provider": provider_name,
                    "actor": selected_actor.name,
                    "provider_selection": selection_metadata,
                },
            )

        with self.unit_of_work.transaction() as ctx:
            if plan.id:
                for change in changes:
                    ctx.changes.add(change)

                plan.status = PlanStatus.BUILT
                plan.updated_at = datetime.now()
                plan.build_completed_at = datetime.now()
                plan.model_used = model_used
                plan.token_count = token_count
                ctx.plans.update(plan)

        yield {"__end__": {"plan": plan, "changes": changes}}

    def _normalize_stream_payload(self, payload: Any) -> dict[str, Any]:
        """Coerce provider stream termination payloads into a standard dict."""

        if isinstance(payload, dict):
            payload_dict = cast(dict[str, Any], payload)
            if "response" in payload_dict:
                return self._normalize_stream_payload(payload_dict["response"])
            return payload_dict
        if isinstance(payload, ProviderResponse):
            return {
                "changes": payload.changes,
                "model_used": payload.model_used,
                "token_count": payload.token_count,
                "error_message": payload.error_message,
            }
        return {
            "changes": [],
            "model_used": "",
            "token_count": 0,  # nosec B105
            "error_message": str(payload),
        }

    def _strip_code_fences(self, content: str) -> str:
        """Remove surrounding markdown code fences if present."""

        fence_match = re.match(
            r"^```(?:\w+)?\s*\n?(?P<body>.*)\n?```$", content.strip(), re.DOTALL
        )
        if fence_match:
            return fence_match.group("body").strip()
        return content.strip()

    def _wrap_in_docstring(self, content: str) -> str:
        """Wrap arbitrary text in a safe module docstring."""

        safe_content = content.replace('"""', '\\"""')
        return f'"""\n{safe_content}\n"""'

    def _sanitize_python_content(
        self, content: str
    ) -> tuple[str, SyntaxError | None, str | None]:
        """Attempt to make AI output valid Python before validation."""

        stripped = content.strip()
        attempts: list[tuple[str, str]] = [(stripped, "original")]

        fence_stripped = self._strip_code_fences(stripped)
        if fence_stripped != stripped:
            attempts.append((fence_stripped, "code_fence_removed"))

        docstring_wrapped = self._wrap_in_docstring(stripped)
        attempts.append((docstring_wrapped, "docstring_wrapped"))

        last_error: SyntaxError | None = None
        for candidate, reason in attempts:
            try:
                ast.parse(candidate)
                return candidate, None, reason if candidate != stripped else None
            except SyntaxError as exc:
                last_error = exc

        return stripped, last_error, None

    def _coerce_change_list(
        self,
        raw_changes: Sequence[object],
        plan_name: str,
        provider_name: str,
        *,
        actor_name: str | None = None,
        selection_metadata: dict[str, Any] | None = None,
    ) -> list[Change]:
        """Convert streamed change payloads into a validated list of Change models."""

        if not isinstance(raw_changes, list):
            raise PlanError(
                message="Provider stream returned an invalid change payload",
                details={"plan": plan_name, "provider": provider_name},
            )

        converted: list[Change] = []
        for entry in raw_changes:
            if isinstance(entry, dict):
                entry_payload = cast(dict[str, Any], entry)
                entry_dict: dict[str, Any] = {
                    str(key): value for key, value in entry_payload.items()
                }
                try:
                    converted.append(Change(**entry_dict))
                except Exception as exc:  # pragma: no cover - defensive guard
                    raise PlanError(
                        message="Unable to parse streamed change entry",
                        details={"plan": plan_name, "provider": provider_name},
                    ) from exc
                continue
            if isinstance(entry, Change):
                converted.append(entry)
                continue
            raise PlanError(
                message="Provider stream returned a non-change entry",
                details={
                    "plan": plan_name,
                    "provider": provider_name,
                    "actor": actor_name or "",
                    "provider_selection": selection_metadata or {},
                },
            )

        return converted
