"""Multi-project service for plan scope management.

Coordinates the creation and management of per-project scopes within
multi-project plans.  Provides methods to:

- Initialize project scopes from ``ProjectLink`` entries
- Resolve scoped context views for actors
- Record per-project changeset summaries
- Validate cross-project constraints and sandbox isolation

Design decisions:
    - Dependency injection: ``DecisionService`` is injected via the
      constructor (consistent with existing service patterns).
    - Stateless: All scope state is derived from the ``Plan`` model's
      ``multi_project_metadata`` field.
    - Immutable plan updates: Methods return a new ``Plan`` instance
      rather than mutating the input (Pydantic ``model_copy``).

Based on:
    - docs/specification.md (multi-project support)
    - Forgejo issue #199
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from cleveragents.domain.models.core.multi_project import (
    ChangeSetSummary,
    MultiProjectMetadata,
    ProjectScope,
    ProjectScopeResolver,
)
from cleveragents.domain.models.core.plan import Plan

if TYPE_CHECKING:
    from cleveragents.application.services.decision_service import DecisionService

logger = logging.getLogger(__name__)


class MultiProjectService:
    """Service for managing multi-project plan scopes.

    Coordinates project scope initialization, context resolution,
    changeset recording, and cross-project validation.

    Args:
        decision_service: Injected decision service for querying
            decision records when validating cross-project constraints.

    Raises:
        ValueError: If *decision_service* is ``None``.
    """

    def __init__(self, decision_service: DecisionService) -> None:
        if decision_service is None:
            raise ValueError("decision_service must not be None")
        self._decision_service: DecisionService = decision_service
        self._resolver: ProjectScopeResolver = ProjectScopeResolver()

    @property
    def decision_service(self) -> DecisionService:
        """The injected decision service."""
        return self._decision_service

    # ------------------------------------------------------------------
    # initialize_scopes
    # ------------------------------------------------------------------

    def initialize_scopes(
        self,
        plan: Plan,
        available_resources: dict[str, list[str]],
    ) -> Plan:
        """Create ``MultiProjectMetadata`` on the plan.

        Builds a ``ProjectScope`` for each project link, populated
        with matching resource IDs from *available_resources*.

        Args:
            plan: The plan to initialize scopes on.
            available_resources: Mapping of project name to resource IDs.

        Returns:
            A new ``Plan`` instance with ``multi_project_metadata`` set.

        Raises:
            ValueError: If *plan* or *available_resources* is ``None``.
        """
        if plan is None:
            raise ValueError("plan must not be None")
        if available_resources is None:
            raise ValueError("available_resources must not be None")

        scopes = self._resolver.resolve_scopes(plan.project_links, available_resources)

        # Validate access modes
        errors = self._resolver.validate_access_modes(
            plan.project_links,
            allow_mixed=False,
        )
        if errors:
            logger.warning(
                "multi_project_mixed_access",
                extra={
                    "plan_id": plan.identity.plan_id,
                    "warnings": errors,
                },
            )

        metadata = MultiProjectMetadata(
            project_scopes=scopes,
            cross_project_dependencies=[],
            allow_mixed_access=False,
        )

        updated = plan.model_copy(update={"multi_project_metadata": metadata})

        logger.info(
            "multi_project_scopes_initialized",
            extra={
                "plan_id": plan.identity.plan_id,
                "project_count": len(scopes),
            },
        )

        return updated

    # ------------------------------------------------------------------
    # resolve_context_view
    # ------------------------------------------------------------------

    def resolve_context_view(
        self,
        plan: Plan,
        project_name_or_alias: str,
    ) -> ProjectScope:
        """Return the scoped view for a specific project.

        Actors call this to understand which resources belong to a
        specific project and whether access is read-only.

        Args:
            plan: The plan with initialized multi-project metadata.
            project_name_or_alias: Project name or alias to resolve.

        Returns:
            The matching ``ProjectScope``.

        Raises:
            ValueError: If multi-project metadata is not initialized.
            KeyError: If no matching project scope is found.
        """
        if plan is None:
            raise ValueError("plan must not be None")
        if plan.multi_project_metadata is None:
            raise ValueError(
                "Multi-project metadata not initialized. Call initialize_scopes first."
            )

        scope = plan.get_project_scope(project_name_or_alias)
        if scope is None:
            raise KeyError(f"No project scope found for '{project_name_or_alias}'")
        return scope

    # ------------------------------------------------------------------
    # record_changeset
    # ------------------------------------------------------------------

    def record_changeset(
        self,
        plan: Plan,
        project_name: str,
        summary: ChangeSetSummary,
    ) -> Plan:
        """Record a per-project changeset summary.

        Updates the ``ProjectScope`` matching *project_name* with the
        provided ``ChangeSetSummary``.

        Args:
            plan: The plan to update.
            project_name: The project to record changes for.
            summary: The changeset summary.

        Returns:
            A new ``Plan`` instance with the updated scope.

        Raises:
            ValueError: If multi-project metadata is not initialized.
            KeyError: If no matching project scope is found.
        """
        if plan is None:
            raise ValueError("plan must not be None")
        if plan.multi_project_metadata is None:
            raise ValueError(
                "Multi-project metadata not initialized. Call initialize_scopes first."
            )

        mp = plan.multi_project_metadata
        updated_scopes: list[ProjectScope] = []
        found = False

        for scope in mp.project_scopes:
            if scope.project_name == project_name:
                updated_scope = scope.model_copy(update={"changeset_summary": summary})
                updated_scopes.append(updated_scope)
                found = True
            else:
                updated_scopes.append(scope)

        if not found:
            raise KeyError(f"No project scope found for '{project_name}'")

        updated_metadata = mp.model_copy(update={"project_scopes": updated_scopes})
        updated_plan = plan.model_copy(
            update={"multi_project_metadata": updated_metadata}
        )

        logger.info(
            "multi_project_changeset_recorded",
            extra={
                "plan_id": plan.identity.plan_id,
                "project_name": project_name,
                "files_changed": summary.files_changed,
            },
        )

        return updated_plan

    # ------------------------------------------------------------------
    # validate_cross_project
    # ------------------------------------------------------------------

    def validate_cross_project(self, plan: Plan) -> list[str]:
        """Validate cross-project constraints and sandbox isolation.

        Checks:
        1. Access mode consistency (mixed read-only/write without opt-in).
        2. Read-only projects must not have changeset summaries with
           modifications.
        3. Cross-project dependencies reference valid project names.

        Args:
            plan: The plan to validate.

        Returns:
            List of validation error messages (empty if valid).
        """
        if plan is None:
            return ["plan must not be None"]
        if plan.multi_project_metadata is None:
            return []  # Single-project plans are always valid

        errors: list[str] = []
        mp = plan.multi_project_metadata

        # 1. Access mode validation
        access_errors = self._resolver.validate_access_modes(
            plan.project_links,
            allow_mixed=mp.allow_mixed_access,
        )
        errors.extend(access_errors)

        # 2. Read-only sandbox isolation
        for scope in mp.project_scopes:
            if scope.read_only and scope.changeset_summary is not None:
                cs = scope.changeset_summary
                total_writes = cs.files_changed + cs.files_added + cs.files_deleted
                if total_writes > 0:
                    errors.append(
                        f"Read-only project '{scope.project_name}' has "
                        f"{total_writes} file modification(s). "
                        f"Sandbox isolation violated."
                    )

        # 3. Cross-project dependency validity
        known_projects = {s.project_name for s in mp.project_scopes}
        for dep in mp.cross_project_dependencies:
            if dep.source_project not in known_projects:
                errors.append(
                    f"Cross-project dependency references unknown source "
                    f"project '{dep.source_project}'"
                )
            if dep.target_project not in known_projects:
                errors.append(
                    f"Cross-project dependency references unknown target "
                    f"project '{dep.target_project}'"
                )

        return errors


__all__: list[str] = [
    "MultiProjectService",
]
