"""DockerComposeAnalyzer — YAML-based UKO triple extraction from Compose files.

Parses Docker Compose YAML content and extracts:

- Deployment unit declaration (``uko-infra:DeploymentUnit``).
- Service definitions (``uko-infra:Service``).
- Port mappings with exposure relationships (``uko-infra:Port``).
- Environment variable declarations (``uko-infra:EnvironmentVariable``).
- Service dependency edges (``uko-infra:connectsTo``).
- Volume mount mappings (``uko:contains``).

All extracted elements are represented as ``UKOTriple`` instances with
``uko://`` URI schemes following the UKO ontology hierarchy:

- Layer 0 core: ``uko:contains``
- Layer 2 infra: ``uko-infra:Service``, ``uko-infra:Port``,
  ``uko-infra:EnvironmentVariable``, ``uko-infra:DeploymentUnit``,
  ``uko-infra:exposes``, ``uko-infra:connectsTo``

Since ``.yml``/``.yaml`` extensions are shared with other YAML formats,
the analyzer validates that the document contains a ``services`` key
before extracting triples.

Based on ``docs/specification.md`` §42285-42331 — DockerComposeAnalyzer.
"""

from __future__ import annotations

import logging

import yaml

from cleveragents.domain.models.acms.analyzers import (
    AnalyzerProtocol,
    UKOTriple,
    safe_uri_segment,
)

__all__ = ["DockerComposeAnalyzer"]

logger = logging.getLogger(__name__)

# Maximum content size in bytes before parsing.  Guards against
# billion-laughs-style alias expansion attacks in YAML.
_MAX_COMPOSE_BYTES = 1_048_576  # 1 MiB

# ---------------------------------------------------------------------------
# URI helpers
# ---------------------------------------------------------------------------

_safe = safe_uri_segment  # B4 fix: shared implementation


def _deployment_uri(resource_uri: str) -> str:
    """Build a UKO deployment-unit URI from a resource URI."""
    return f"uko://infra/deployment/{_safe(resource_uri)}"


def _service_uri(resource_uri: str, service_name: str) -> str:
    """Build a UKO service URI."""
    return f"uko://infra/service/{_safe(resource_uri)}/{_safe(service_name)}"


def _port_uri(resource_uri: str, service_name: str, port_mapping: str) -> str:
    """Build a UKO port URI."""
    return (
        f"uko://infra/port/{_safe(resource_uri)}"
        f"/{_safe(service_name)}/{_safe(port_mapping)}"
    )


def _env_uri(resource_uri: str, service_name: str, var_name: str) -> str:
    """Build a UKO environment-variable URI."""
    return (
        f"uko://infra/env/{_safe(resource_uri)}/{_safe(service_name)}/{_safe(var_name)}"
    )


def _volume_uri(resource_uri: str, service_name: str, volume_mapping: str) -> str:
    """Build a UKO volume URI."""
    return (
        f"uko://infra/volume/{_safe(resource_uri)}"
        f"/{_safe(service_name)}/{_safe(volume_mapping)}"
    )


# ---------------------------------------------------------------------------
# DockerComposeAnalyzer
# ---------------------------------------------------------------------------


class DockerComposeAnalyzer:
    """YAML-based Docker Compose analyzer producing UKO triples.

    Satisfies :class:`AnalyzerProtocol`.  Handles ``.yml`` and ``.yaml``
    files that contain Docker Compose definitions.  Validates the
    presence of a ``services`` key before extracting triples.  Returns
    an empty list for unparsable or non-Compose YAML.

    Example::

        analyzer = DockerComposeAnalyzer()
        triples = analyzer.analyze(
            "services:\\n  web:\\n    image: nginx\\n",
            "infra/docker-compose.yml",
        )
    """

    @property
    def supported_extensions(self) -> frozenset[str]:
        """File extensions handled by this analyzer."""
        return frozenset({".yml", ".yaml"})

    @property
    def domain(self) -> str:
        """Human-readable domain label."""
        return "docker-compose"

    def analyze(self, content: str, resource_uri: str) -> list[UKOTriple]:
        """Parse *content* as Docker Compose YAML and extract UKO triples.

        Args:
            content: Raw YAML text.
            resource_uri: Canonical URI of the resource.

        Returns:
            List of ``UKOTriple`` instances.  Returns an empty list when
            the content is unparsable or not a Docker Compose document.

        Raises:
            ValueError: If *content* or *resource_uri* is empty.
        """
        if not content or not content.strip():
            raise ValueError("content must not be empty.")
        if not resource_uri or not resource_uri.strip():
            raise ValueError("resource_uri must not be empty.")

        # Size guard: reject oversized inputs to mitigate billion-laughs
        # alias-expansion attacks (quadratic memory via nested YAML
        # anchors/aliases).  1 MiB is generous for any Compose file.
        if len(content.encode("utf-8")) > _MAX_COMPOSE_BYTES:
            logger.warning(
                "DockerComposeAnalyzer: content exceeds %d byte limit; skipping '%s'",
                _MAX_COMPOSE_BYTES,
                resource_uri,
            )
            return []

        # yaml.safe_load disables custom constructors, limiting alias
        # expansion impact.  The _MAX_COMPOSE_BYTES check above provides
        # a further guard against decompression-bomb-style YAML aliases.
        try:
            data = yaml.safe_load(content)
        except yaml.YAMLError:
            logger.warning(
                "DockerComposeAnalyzer: YAML parse error in '%s'; returning empty",
                resource_uri,
                exc_info=True,
            )
            return []

        # safe_load may return None for empty documents, or a non-dict scalar.
        if not isinstance(data, dict):
            return []

        # Docker Compose detection: require 'services' key.  Older Compose
        # files (v2/v3) always have a 'services' mapping alongside 'version'.
        if "services" not in data:
            return []

        triples: list[UKOTriple] = []
        deploy_uri = _deployment_uri(resource_uri)

        # Deployment unit declaration
        triples.append(
            UKOTriple(
                subject_uri=deploy_uri,
                predicate="rdf:type",
                object_uri="uko-infra:DeploymentUnit",
            )
        )
        triples.append(
            UKOTriple(
                subject_uri=deploy_uri,
                predicate="rdfs:label",
                object_value=resource_uri,
            )
        )

        services = data.get("services")
        if not isinstance(services, dict):
            # Has 'version' but no valid 'services' — return just the
            # deployment unit triples.
            return triples

        for service_name, service_def in services.items():
            try:
                svc_uri = _service_uri(resource_uri, str(service_name))

                # Service declaration
                triples.append(
                    UKOTriple(
                        subject_uri=svc_uri,
                        predicate="rdf:type",
                        object_uri="uko-infra:Service",
                    )
                )
                triples.append(
                    UKOTriple(
                        subject_uri=svc_uri,
                        predicate="rdfs:label",
                        object_value=str(service_name),
                    )
                )

                # Containment: deployment unit -> service
                triples.append(
                    UKOTriple(
                        subject_uri=deploy_uri,
                        predicate="uko:contains",
                        object_uri=svc_uri,
                    )
                )

                # Guard against null service definitions (e.g. `web:` with
                # no body).
                if not isinstance(service_def, dict):
                    continue

                # Ports
                triples.extend(
                    self._extract_ports(
                        service_def, resource_uri, service_name, svc_uri
                    )
                )

                # Environment variables
                triples.extend(
                    self._extract_environment(
                        service_def, resource_uri, service_name, svc_uri
                    )
                )

                # Dependencies
                triples.extend(
                    self._extract_depends_on(service_def, resource_uri, svc_uri)
                )

                # Volumes
                triples.extend(
                    self._extract_volumes(
                        service_def, resource_uri, service_name, svc_uri
                    )
                )
            except Exception:
                logger.warning(
                    "DockerComposeAnalyzer: error processing service '%s' "
                    "in '%s'; skipping",
                    service_name,
                    resource_uri,
                    exc_info=True,
                )
                continue

        return triples

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

    def _extract_ports(
        self,
        service_def: dict,
        resource_uri: str,
        service_name: str,
        svc_uri: str,
    ) -> list[UKOTriple]:
        """Extract port mapping triples from a service definition."""
        triples: list[UKOTriple] = []
        ports = service_def.get("ports")
        if not isinstance(ports, list):
            return triples

        for port_entry in ports:
            # Normalise dict-form ports (extended syntax) to a
            # human-readable string instead of Python dict repr.
            if isinstance(port_entry, dict):
                target = port_entry.get("target", "")
                published = port_entry.get("published")
                port_str = (
                    f"{published}:{target}" if published is not None else str(target)
                )
            else:
                port_str = str(port_entry)
            p_uri = _port_uri(resource_uri, service_name, port_str)

            triples.append(
                UKOTriple(
                    subject_uri=p_uri,
                    predicate="rdf:type",
                    object_uri="uko-infra:Port",
                )
            )
            # NOTE: Spec §42327-42330 defines exposes with range
            # uko-infra:Endpoint, but Docker Compose port bindings map
            # most naturally to uko-infra:Port.  We use exposes here for
            # its semantic meaning ("service exposes this port") rather
            # than creating an intermediate Endpoint wrapper.
            triples.append(
                UKOTriple(
                    subject_uri=svc_uri,
                    predicate="uko-infra:exposes",
                    object_uri=p_uri,
                )
            )
            triples.append(
                UKOTriple(
                    subject_uri=p_uri,
                    predicate="rdfs:label",
                    object_value=port_str,
                )
            )

        return triples

    def _extract_environment(
        self,
        service_def: dict,
        resource_uri: str,
        service_name: str,
        svc_uri: str,
    ) -> list[UKOTriple]:
        """Extract environment variable triples from a service definition."""
        triples: list[UKOTriple] = []
        env = service_def.get("environment")
        if env is None:
            return triples

        var_names: list[str] = []

        if isinstance(env, dict):
            # Mapping form: environment: { KEY: value, ... }
            var_names = [str(k) for k in env]
        elif isinstance(env, list):
            # List form: environment: [ "KEY=value", ... ]
            for entry in env:
                entry_str = str(entry)
                name = entry_str.split("=", 1)[0]
                var_names.append(name)

        for var_name in var_names:
            e_uri = _env_uri(resource_uri, service_name, var_name)

            triples.append(
                UKOTriple(
                    subject_uri=e_uri,
                    predicate="rdf:type",
                    object_uri="uko-infra:EnvironmentVariable",
                )
            )
            triples.append(
                UKOTriple(
                    subject_uri=svc_uri,
                    predicate="uko:contains",
                    object_uri=e_uri,
                )
            )
            triples.append(
                UKOTriple(
                    subject_uri=e_uri,
                    predicate="rdfs:label",
                    object_value=var_name,
                )
            )

        return triples

    def _extract_depends_on(
        self,
        service_def: dict,
        resource_uri: str,
        svc_uri: str,
    ) -> list[UKOTriple]:
        """Extract service dependency triples from ``depends_on``."""
        triples: list[UKOTriple] = []
        depends_on = service_def.get("depends_on")
        if depends_on is None:
            return triples

        dep_names: list[str] = []

        if isinstance(depends_on, str):
            # Single string form: depends_on: db
            dep_names = [depends_on]
        elif isinstance(depends_on, list):
            # Short form: depends_on: [db, redis]
            dep_names = [str(d) for d in depends_on]
        elif isinstance(depends_on, dict):
            # Long form: depends_on: { db: { condition: ... }, ... }
            dep_names = [str(k) for k in depends_on]

        for dep_name in dep_names:
            dep_uri = _service_uri(resource_uri, dep_name)
            triples.append(
                UKOTriple(
                    subject_uri=svc_uri,
                    predicate="uko-infra:connectsTo",
                    object_uri=dep_uri,
                )
            )

        return triples

    def _extract_volumes(
        self,
        service_def: dict,
        resource_uri: str,
        service_name: str,
        svc_uri: str,
    ) -> list[UKOTriple]:
        """Extract volume mapping triples from a service definition."""
        triples: list[UKOTriple] = []
        volumes = service_def.get("volumes")
        if not isinstance(volumes, list):
            return triples

        for vol_entry in volumes:
            # Normalise dict-form volumes (long syntax) to a
            # human-readable "source:target" string.
            if isinstance(vol_entry, dict):
                source = vol_entry.get("source", "")
                target = vol_entry.get("target", "")
                vol_str = f"{source}:{target}" if source else str(target)
            else:
                vol_str = str(vol_entry)
            v_uri = _volume_uri(resource_uri, service_name, vol_str)

            # Volume mounts are typed as ConfigKey per the uko-infra
            # vocabulary (spec §42303-42305) — the nearest fit since
            # no dedicated uko-infra:Volume class exists in the spec.
            triples.append(
                UKOTriple(
                    subject_uri=v_uri,
                    predicate="rdf:type",
                    object_uri="uko-infra:ConfigKey",
                )
            )
            triples.append(
                UKOTriple(
                    subject_uri=svc_uri,
                    predicate="uko:contains",
                    object_uri=v_uri,
                )
            )
            triples.append(
                UKOTriple(
                    subject_uri=v_uri,
                    predicate="rdfs:label",
                    object_value=vol_str,
                )
            )

        return triples


# Protocol compliance assertion (zero-cost at import time).
_: type[AnalyzerProtocol] = DockerComposeAnalyzer
