"""
:mod:`etlplus.workflow.jobs` module.

Data classes modeling job orchestration references (extract, validate,
transform, load).

Notes
-----
- All attributes are simple and optional where appropriate, keeping parsing
    tolerant.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Self

from ..utils import coerce_dict
from ..utils import maybe_mapping
from ..utils.types import StrAnyMap

# SECTION: EXPORTS ========================================================== #


__all__ = [
    # Data Classes
    'ExtractRef',
    'JobConfig',
    'LoadRef',
    'TransformRef',
    'ValidationRef',
]


# SECTION: INTERNAL FUNCTIONS =============================================== #


def _coerce_optional_str(value: Any) -> str | None:
    """
    Normalize optional string values, coercing non-strings when needed.

    Parameters
    ----------
    value : Any
        Optional value to normalize.

    Returns
    -------
    str | None
        ``None`` when *value* is ``None``; otherwise a string value.
    """
    if value is None:
        return None
    return value if isinstance(value, str) else str(value)


def _parse_depends_on(
    value: Any,
) -> list[str]:
    """
    Normalize dependency declarations into a string list.

    Parameters
    ----------
    value : Any
        Input dependency specification (string or list of strings).

    Returns
    -------
    list[str]
        Normalized dependency list.
    """
    if isinstance(value, str):
        return [value]
    if isinstance(value, Sequence) and not isinstance(
        value,
        (str, bytes, bytearray),
    ):
        return [entry for entry in value if isinstance(entry, str)]
    return []


def _require_str(
    data: StrAnyMap,
    key: str,
) -> str | None:
    """
    Extract a required string field from a mapping.

    Parameters
    ----------
    data : StrAnyMap
        Mapping containing the target field.
    key : str
        Field name to extract.

    Returns
    -------
    str | None
        The string value when present and valid; otherwise ``None``.
    """
    value = data.get(key)
    return value if isinstance(value, str) else None


# SECTION: DATA CLASSES ===================================================== #


@dataclass(kw_only=True, slots=True)
class ExtractRef:
    """
    Reference to a data source for extraction.

    Attributes
    ----------
    source : str
        Name of the source connector.
    options : dict[str, Any]
        Optional extract-time options (e.g., query parameters overrides).
    """

    # -- Attributes -- #

    source: str
    options: dict[str, Any] = field(default_factory=dict)

    # -- Class Methods -- #

    @classmethod
    def from_obj(
        cls,
        obj: Any,
    ) -> Self | None:
        """
        Parse a mapping into an :class:`ExtractRef` instance.

        Parameters
        ----------
        obj : Any
            Mapping with :attr:`source` and optional :attr:`options`.

        Returns
        -------
        Self | None
            Parsed reference or ``None`` when the payload is invalid.
        """
        data = maybe_mapping(obj)
        if not data:
            return None
        if (source := _require_str(data, 'source')) is None:
            return None
        return cls(source=source, options=coerce_dict(data.get('options')))


@dataclass(kw_only=True, slots=True)
class JobConfig:
    """
    Configuration for a data processing job.

    Attributes
    ----------
    name : str
        Unique job name.
    description : str | None
        Optional human-friendly description.
    depends_on : list[str]
        Optional job dependency list. Dependencies must refer to other jobs.
    extract : ExtractRef | None
        Extraction reference.
    validate : ValidationRef | None
        Validation reference.
    transform : TransformRef | None
        Transform reference.
    load : LoadRef | None
        Load reference.
    """

    # -- Attributes -- #

    name: str
    description: str | None = None
    depends_on: list[str] = field(default_factory=list)
    extract: ExtractRef | None = None
    validate: ValidationRef | None = None
    transform: TransformRef | None = None
    load: LoadRef | None = None

    # -- Class Methods -- #

    @classmethod
    def from_obj(
        cls,
        obj: Any,
    ) -> Self | None:
        """
        Parse a mapping into a :class:`JobConfig` instance.

        Parameters
        ----------
        obj : Any
            Mapping describing a job block.

        Returns
        -------
        Self | None
            Parsed job configuration or ``None`` if invalid.
        """
        data = maybe_mapping(obj)
        if not data:
            return None
        if (name := _require_str(data, 'name')) is None:
            return None

        return cls(
            name=name,
            description=_coerce_optional_str(data.get('description')),
            depends_on=_parse_depends_on(data.get('depends_on')),
            extract=ExtractRef.from_obj(data.get('extract')),
            validate=ValidationRef.from_obj(data.get('validate')),
            transform=TransformRef.from_obj(data.get('transform')),
            load=LoadRef.from_obj(data.get('load')),
        )


@dataclass(kw_only=True, slots=True)
class LoadRef:
    """
    Reference to a data target for loading.

    Attributes
    ----------
    target : str
        Name of the target connector.
    overrides : dict[str, Any]
        Optional load-time overrides (e.g., headers).
    """

    # -- Attributes -- #

    target: str
    overrides: dict[str, Any] = field(default_factory=dict)

    # -- Class Methods -- #

    @classmethod
    def from_obj(
        cls,
        obj: Any,
    ) -> Self | None:
        """
        Parse a mapping into a :class:`LoadRef` instance.

        Parameters
        ----------
        obj : Any
            Mapping with :attr:`target` and optional :attr:`overrides`.

        Returns
        -------
        Self | None
            Parsed reference or ``None`` when invalid.
        """
        data = maybe_mapping(obj)
        if not data:
            return None
        if (target := _require_str(data, 'target')) is None:
            return None
        return cls(
            target=target,
            overrides=coerce_dict(data.get('overrides')),
        )


@dataclass(kw_only=True, slots=True)
class TransformRef:
    """
    Reference to a transformation pipeline.

    Attributes
    ----------
    pipeline : str
        Name of the transformation pipeline.
    """

    # -- Attributes -- #

    pipeline: str

    # -- Class Methods -- #

    @classmethod
    def from_obj(
        cls,
        obj: Any,
    ) -> Self | None:
        """
        Parse a mapping into a :class:`TransformRef` instance.

        Parameters
        ----------
        obj : Any
            Mapping with :attr:`pipeline`.

        Returns
        -------
        Self | None
            Parsed reference or ``None`` when invalid.
        """
        data = maybe_mapping(obj)
        if not data:
            return None
        if (pipeline := _require_str(data, 'pipeline')) is None:
            return None
        return cls(pipeline=pipeline)


@dataclass(kw_only=True, slots=True)
class ValidationRef:
    """
    Reference to a validation rule set.

    Attributes
    ----------
    ruleset : str
        Name of the validation rule set.
    severity : str | None
        Severity level (``"warn"`` or ``"error"``).
    phase : str | None
        Execution phase (``"before_transform"``, ``"after_transform"``,
        or ``"both"``).
    """

    # -- Attributes -- #

    ruleset: str
    severity: str | None = None  # warn|error
    phase: str | None = None  # before_transform|after_transform|both

    # -- Class Methods -- #

    @classmethod
    def from_obj(
        cls,
        obj: Any,
    ) -> Self | None:
        """
        Parse a mapping into a :class:`ValidationRef` instance.

        Parameters
        ----------
        obj : Any
            Mapping with :attr:`ruleset` plus optional metadata.

        Returns
        -------
        Self | None
            Parsed reference or ``None`` when invalid.
        """
        data = maybe_mapping(obj)
        if not data:
            return None
        if (ruleset := _require_str(data, 'ruleset')) is None:
            return None
        return cls(
            ruleset=ruleset,
            severity=_coerce_optional_str(data.get('severity')),
            phase=_coerce_optional_str(data.get('phase')),
        )
