"""Manifest schemas."""

from collections.abc import Iterator
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING, Any

from pydantic import Field, HttpUrl, model_validator

from porringer.core.schema import Ecosystem, PackageRef, PlatformScoped, PluginKind, PorringerModel

if TYPE_CHECKING:
    from porringer.utility.exception import ManifestValidationCode


class ManifestDiagnosticSeverity(Enum):
    """Severity level for a manifest validation diagnostic."""

    ERROR = auto()
    WARNING = auto()


@dataclass(slots=True)
class ManifestDiagnostic:
    """A single diagnostic produced by manifest validation.

    Args:
        field: Dot-path to the relevant field (e.g. `"packages.python"`, `"preferences.python"`).
        message: Human-readable description of the problem or concern.
        code: Machine-readable diagnostic code.
        severity: Whether this diagnostic is an error or a warning.
    """

    field: str
    message: str
    code: ManifestValidationCode
    severity: ManifestDiagnosticSeverity


@dataclass(slots=True)
class ManifestValidationResult:
    """Structured result of manifest validation.

    Args:
        diagnostics: All validation diagnostics (errors and warnings).
    """

    diagnostics: list[ManifestDiagnostic] = field(default_factory=list)

    @property
    def valid(self) -> bool:
        """A manifest is valid when it has no error-level diagnostics."""
        return not any(d.severity == ManifestDiagnosticSeverity.ERROR for d in self.diagnostics)

    @property
    def errors(self) -> list[ManifestDiagnostic]:
        """All error-level diagnostics."""
        return [d for d in self.diagnostics if d.severity == ManifestDiagnosticSeverity.ERROR]

    @property
    def warnings(self) -> list[ManifestDiagnostic]:
        """All warning-level diagnostics."""
        return [d for d in self.diagnostics if d.severity == ManifestDiagnosticSeverity.WARNING]


class PluginSpec(PorringerModel):
    """A plugin sub-package entry with optional metadata.

    Supports both string shorthand (just a package specifier) and object form
    with per-plugin options::

        "cppython"  # string shorthand

        {'name': 'cppython', 'include_prereleases': true}  # object form
    """

    name: PackageRef = Field(description='The plugin package reference (name with optional version constraint)')
    description: str | None = Field(default=None, description='Human-readable description of this plugin')
    include_prereleases: bool = Field(
        default=False,
        description='Include pre-release versions when checking this plugin for updates',
    )

    @model_validator(mode='before')
    @classmethod
    def _coerce_string(cls, data: Any) -> Any:
        """Allow plain strings as shorthand for `{"name": "..."}`."""
        if isinstance(data, str):
            return {'name': data}
        return data


class PackageSpec(PlatformScoped):
    """A package entry with optional display metadata.

    Supports both string shorthand (just a package specifier) and object form
    with additional metadata for GUI consumers.

    The optional `plugins` list declares sub-packages that should be
    added to the parent package via its native plugin management
    after it is installed.  For example, a PDM installation can
    declare `cppython` as a plugin so that `pdm self add cppython`
    is executed automatically::

        {'name': 'pdm', 'plugins': ['cppython']}
        {'name': 'pdm', 'plugins': [{'name': 'cppython', 'include_prereleases': true}]}

    The field is generic — any tool whose project-environment plugin
    implements ``PluginManager`` can use it.
    """

    name: PackageRef = Field(description='The package reference (name with optional version constraint)')
    description: str | None = Field(default=None, description='Human-readable description of this package')
    include_prereleases: bool = Field(
        default=False,
        description='Include pre-release versions when checking this package for updates',
    )
    plugins: list[PluginSpec] = Field(
        default_factory=list,
        description="Sub-packages to add via this tool's native plugin management after installation",
    )

    @model_validator(mode='before')
    @classmethod
    def _coerce_string(cls, data: Any) -> Any:
        """Allow plain strings as shorthand for `{"name": "..."}`."""
        if isinstance(data, str):
            return {'name': data}
        return data


class WslDistroManifest(PorringerModel):
    """Package/runtime/project/scm sections for a single WSL2 distribution.

    Mirrors the kind-grouped sections of :class:`SetupManifest` so
    users can declare per-distro packages using the same syntax::

        "wsl2": {
            "Ubuntu-22.04": {
                "packages": {"apt": ["curl", "build-essential"]},
                "runtimes": {"python": ["3.12"]}
            }
        }
    """

    packages: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Packages to install per ecosystem'
    )
    tools: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='CLI tools to install per ecosystem'
    )
    projects: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Project sync targets per ecosystem'
    )
    runtimes: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Language runtimes to install per ecosystem'
    )
    scm: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='SCM repositories to clone per ecosystem'
    )
    preferences: dict[Ecosystem, str] = Field(default_factory=dict, description='Preferred installer per ecosystem')

    def iter_sections(self) -> Iterator[tuple[PluginKind, Ecosystem, list[PackageSpec]]]:
        """Yield `(kind, ecosystem, packages)` for every non-empty section."""
        for kind in PluginKind:
            section: dict[Ecosystem, list[PackageSpec]] = getattr(self, kind.value, {})
            for ecosystem, packages in section.items():
                yield kind, ecosystem, packages


class SetupManifest(PorringerModel):
    """The setup manifest schema for .porringer files or pyproject.toml [tool.porringer].

    Manifest entries are grouped by **kind** (`packages`, `tools`,
    `projects`, `runtimes`), each containing a dict keyed by
    **ecosystem** (e.g. `"python"`, `"node"`, `"system"`).

    Ecosystem names are free-form strings declared by plugins — the core
    schema does not enumerate them.  A third-party Cargo plugin declaring
    `ecosystem() = "rust"` "just works" with
    `"packages": {"rust": ["serde"]}` — zero core changes required.
    """

    version: str = Field(default='1', description='Manifest schema version')
    name: str | None = Field(default=None, description='Human-readable project/environment name')
    description: str | None = Field(default=None, description='Short description shown in the install preview header')
    author: str | None = Field(default=None, description='Author or organization name')
    url: HttpUrl | None = Field(default=None, description='Project URL for reference')
    packages: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Packages to install per ecosystem (e.g. {"python": ["requests"]})'
    )
    tools: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='CLI tools to install per ecosystem (e.g. {"python": ["pdm"]})'
    )
    projects: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Project sync targets per ecosystem (e.g. {"python": []})'
    )
    runtimes: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict, description='Language runtimes to install per ecosystem (e.g. {"python": ["3.12"]})'
    )
    scm: dict[Ecosystem, list[PackageSpec]] = Field(
        default_factory=dict,
        description='SCM repositories to clone per ecosystem (e.g. {"git": ["https://github.com/org/repo"]})',
    )
    preferences: dict[Ecosystem, str] = Field(
        default_factory=dict,
        description='Preferred installer per ecosystem (e.g. {"python": "uv"})',
    )
    extends: list[str] = Field(
        default_factory=list,
        description='Paths to other manifests whose state is merged (base layers)',
    )
    post_sync: list[str] = Field(default_factory=list, description='Commands to run after state synchronisation')
    wsl2: dict[str, WslDistroManifest] = Field(
        default_factory=dict,
        description=(
            'Per-distro WSL2 package/runtime/project/scm sections. '
            'Keys are WSL distribution names (e.g. "Ubuntu-22.04").'
        ),
    )

    @model_validator(mode='before')
    @classmethod
    def _strip_schema_key(cls, data: Any) -> Any:
        """Strip ``$schema`` from input data so editors can include a schema URL.

        The ``$schema`` key is a JSON Schema convention for pointing editors
        at the schema that validates the file.  It is not part of the manifest
        domain model, so we silently remove it before Pydantic validation
        (which would otherwise reject it due to ``extra='forbid'``).
        """
        if isinstance(data, dict) and '$schema' in data:
            return {k: v for k, v in data.items() if k != '$schema'}
        return data

    def iter_sections(self) -> Iterator[tuple[PluginKind, Ecosystem, list[PackageSpec]]]:
        """Yield `(kind, ecosystem, packages)` for every non-empty section.

        Replaces the repeated `for kind in PluginKind: getattr(…)`
        pattern used throughout the sync engine.
        """
        for kind in PluginKind:
            section: dict[Ecosystem, list[PackageSpec]] = getattr(self, kind.value, {})
            for ecosystem, packages in section.items():
                yield kind, ecosystem, packages

    def iter_wsl_sections(self) -> Iterator[tuple[str, PluginKind, Ecosystem, list[PackageSpec]]]:
        """Yield `(distro, kind, ecosystem, packages)` for every non-empty WSL2 section.

        Similar to :meth:`iter_sections` but includes the WSL distribution
        name as the first element so the action builder can create
        transport-aware plugin instances.
        """
        for distro, distro_manifest in self.wsl2.items():
            for kind in PluginKind:
                section: dict[Ecosystem, list[PackageSpec]] = getattr(distro_manifest, kind.value, {})
                for ecosystem, packages in section.items():
                    yield distro, kind, ecosystem, packages


@dataclass(slots=True)
class ManifestResult:
    """Result of locating and loading a manifest.

    Separates the physical manifest file from the logical project root.
    For a native ``porringer.json`` the root is the file's parent.
    For a reference from ``pyproject.toml`` (via ``manifest = "path"``) the
    root is the referencing file's parent while ``manifest_path`` points to
    the resolved target.

    Args:
        manifest_path: Absolute path to the file that was actually parsed
            as a ``SetupManifest``.
        root_directory: Logical project root — the directory that downstream
            phases use as the working directory.
        manifest: The parsed manifest data.
    """

    manifest_path: Path
    root_directory: Path
    manifest: SetupManifest


@dataclass(slots=True)
class ManifestMetadata:
    """Display metadata from a setup manifest.

    Carries optional human-readable information for GUI consumers
    (e.g. install preview screens).

    Args:
        name: Human-readable project/environment name.
        description: Short description shown in the install preview header.
        author: Author or organization name.
        url: Project URL for reference.
    """

    name: str | None = None
    description: str | None = None
    author: str | None = None
    url: str | None = None
