"""Agent Skills Standard loader for CleverAgents v3.

Implements the three-tier progressive disclosure model for Agent Skills:

.. code-block:: text

    Tier 1 — Metadata  : discover() → name + description (~50-100 tokens)
    Tier 2 — Instructions: activate() → full SKILL.md body (< 5000 tokens)
    Tier 3 — Resources  : list_resources() → scripts/, references/, assets/

## Usage

.. code-block:: python

    loader = AgentSkillLoader.from_folder(Path("./skills/deploy-to-staging"))
    descriptor = loader.discover()   # Tier 1: lightweight metadata
    loader.activate()                # Tier 2: loads instructions into active_body
    resources = loader.list_resources()  # Tier 3: on-demand resource paths
    tool = loader.to_tool_descriptor()   # Map to tool descriptor (source=agent_skill)
    loader.deactivate()              # Clear active body, free token budget

## SKILL.md Format

Agent Skills follow the AgentSkills.io standard:

.. code-block:: markdown

    ---
    name: local/deploy-staging
    description: Deploy the current branch to the staging environment.
    steps:                    # optional — structured step list with stable ordering
      - Run tests and verify they pass
      - Push branch to remote repository
      - Deploy to staging environment
    version: 1.0.0            # optional
    compatibility:            # optional
      min_agent_version: "3.0.0"
    metadata:                 # optional — arbitrary key/value pairs
      author: team
      tags: [deploy, staging]
    allowed-tools:            # optional — restrict which tools the skill may call
      - local/read-file
      - local/shell
    ---
    ## Detailed Instructions

    Additional prose context for each step.

## Namespaced Naming

All skill names follow the ``namespace/short_name`` pattern identical to
tools and actors (ADR-002). The ``local/`` namespace is the default.

## Source Type

Agent Skills are registered in the Tool Registry with ``source=agent_skill``.
They are **read-only by default** because the skill's instructions do not
themselves write — the LLM agent decides which write-capable tools to invoke
during execution. Individual ``allowed-tools`` may write; the skill descriptor
itself does not.

## Folder Structure

    my-skill/
    ├── SKILL.md            ← required — frontmatter + instructions
    ├── scripts/            ← optional — bundled Python/shell scripts
    │   └── deploy.py
    ├── references/         ← optional — supplementary Markdown docs
    │   └── runbook.md
    └── assets/             ← optional — any binary or text assets
        └── config.json

Based on:
- docs/specification.md — AgentSkillAdapter, progressive disclosure, ADR-028
- Aditya_standards_and_tasks.md — Task M2.4a
"""

from __future__ import annotations

import logging
import re
from pathlib import Path

import yaml
from pydantic import BaseModel, ConfigDict, Field

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Private type aliases
# ---------------------------------------------------------------------------

# Mapping type for YAML frontmatter data produced by yaml.safe_load.
# Using `object` values avoids dict invariance issues while still
# eliminating bare `Any` from all signatures.
type _YamlMapping = dict[str, object]

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

#: Frontmatter delimiter in SKILL.md files.
_FRONTMATTER_DELIMITER = "---"

#: Required frontmatter fields.
_REQUIRED_FIELDS: tuple[str, ...] = ("name", "description")

#: Pattern for ``namespace/short_name`` (alphanumeric, hyphens, underscores).
_NAMESPACED_NAME_RE = re.compile(
    r"^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9_-]*$"
)

#: Sub-directories discovered as resources.
_RESOURCE_DIRS: tuple[str, ...] = ("scripts", "references", "assets")


# ---------------------------------------------------------------------------
# SkillStep — a single structured step from the frontmatter steps list
# ---------------------------------------------------------------------------


class SkillStep(BaseModel):
    """A single structured step parsed from the SKILL.md frontmatter ``steps`` list.

    Steps are 1-indexed and stored in stable insertion order so that
    the LLM agent always sees them in the author-intended sequence.

    Attributes:
        index: 1-based stable position of this step (never reordered).
        content: Non-empty step instruction text.
    """

    index: int = Field(..., ge=1, description="1-based stable step index")
    content: str = Field(..., min_length=1, description="Step instruction text")

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# AgentSkillResourceSlot — read-only binding slot for a resource directory
# ---------------------------------------------------------------------------


class AgentSkillResourceSlot(BaseModel):
    """A read-only resource binding slot for an Agent Skill support directory.

    Each discovered sub-directory (``scripts/``, ``references/``,
    ``assets/``) that exists on disk becomes a slot in the tool descriptor.
    All Agent Skill resource slots are unconditionally **read-only** —
    the skill's instructions may read from them, never write.

    Attributes:
        name: Slot identifier (``"scripts"``, ``"references"``,
            ``"assets"``).
        resource_type: Typed label for the slot
            (e.g. ``"agent_skill_scripts"``).
        access: Always ``"read_only"`` for Agent Skills.
        path: Absolute path to the resource directory on disk.
    """

    name: str = Field(..., min_length=1, description="Slot name identifier")
    resource_type: str = Field(..., min_length=1, description="Resource type label")
    access: str = Field(
        default="read_only",
        description="Access level — always read_only for Agent Skills",
    )
    path: str = Field(..., min_length=1, description="Absolute directory path")

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# AgentSkillSpec — parsed SKILL.md frontmatter + body
# ---------------------------------------------------------------------------


class AgentSkillSpec(BaseModel):
    """Parsed representation of a SKILL.md file.

    Produced by :meth:`AgentSkillSpec.from_file`. Contains all
    frontmatter fields and the full Markdown body.

    Attributes:
        name: Namespaced skill name (e.g. ``local/deploy-staging``).
        description: Short human-readable description.
        body: Full Markdown body text (everything after the frontmatter).
        steps: Ordered list of structured :class:`SkillStep` objects
            parsed from the optional frontmatter ``steps`` list.
        version: Semantic version string (default ``"0.0.0"``).
        compatibility: Compatibility metadata dict (optional).
        metadata: Arbitrary key/value metadata from frontmatter (optional).
        allowed_tools: List of tool names the skill may invoke (optional).
    """

    name: str = Field(..., min_length=1, description="Namespaced skill name")
    description: str = Field(
        ..., min_length=1, description="Short description of the skill"
    )
    body: str = Field(default="", description="Full Markdown body (instructions)")
    steps: list[SkillStep] = Field(
        default_factory=list,
        description=(
            "Ordered structured steps parsed from frontmatter 'steps' list. "
            "Empty when the frontmatter omits the 'steps' key."
        ),
    )
    version: str = Field(default="0.0.0", description="Semantic version of the skill")
    compatibility: _YamlMapping = Field(
        default_factory=dict,
        description="Compatibility constraints dict",
    )
    metadata: _YamlMapping = Field(
        default_factory=dict,
        description="Arbitrary metadata key/value pairs",
    )
    allowed_tools: list[str] = Field(
        default_factory=list,
        description="Tools the skill is allowed to invoke",
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )

    # -- Factory ---------------------------------------------------------------

    @classmethod
    def from_file(cls, path: Path) -> AgentSkillSpec:
        """Parse a SKILL.md file and return an ``AgentSkillSpec``.

        Reads the file at *path*, splits out the YAML frontmatter block
        between the first two ``---`` delimiters, and parses the body
        as the remaining Markdown text.

        Args:
            path: Absolute or relative path to the ``SKILL.md`` file.

        Returns:
            A validated ``AgentSkillSpec`` instance.

        Raises:
            FileNotFoundError: If *path* does not exist.
            ValueError: If frontmatter is missing, malformed, or fails
                validation (missing required fields, invalid namespaced name,
                invalid steps list).
        """
        if not path.exists():
            raise FileNotFoundError(f"SKILL.md not found at path '{path}'")

        raw_text = path.read_text(encoding="utf-8")
        return cls.from_string(raw_text)

    @classmethod
    def from_string(cls, text: str) -> AgentSkillSpec:
        """Parse a SKILL.md string and return an ``AgentSkillSpec``.

        Args:
            text: Raw SKILL.md content (frontmatter + body).

        Returns:
            A validated ``AgentSkillSpec`` instance.

        Raises:
            ValueError: If frontmatter is missing, malformed, or fails
                validation (missing required fields, invalid namespaced name,
                invalid steps list).
        """
        frontmatter_data, body = _split_frontmatter(text)
        return _build_spec(frontmatter_data, body)


# ---------------------------------------------------------------------------
# AgentSkillToolDescriptor — tool-level view of a loaded skill
# ---------------------------------------------------------------------------


class AgentSkillToolDescriptor(BaseModel):
    """Lightweight descriptor returned by :meth:`AgentSkillLoader.discover`.

    Tier 1 of progressive disclosure — only metadata, no instructions.

    Attributes:
        name: Namespaced skill name.
        description: Short description.
        source: Always ``"agent_skill"``.
        read_only: Always ``True`` (default) — skill itself does not write.
        writes: Always ``False`` (default).
        body: Empty at discover; populated after :meth:`AgentSkillLoader.activate`.
        agent_skill_path: Absolute path to the skill folder.
        allowed_tools: Tools the skill is permitted to invoke.
        resource_slots: Read-only binding slots for discovered resource
            directories (``scripts/``, ``references/``, ``assets/``).
    """

    name: str = Field(..., min_length=1, description="Namespaced skill name")
    description: str = Field(..., min_length=1, description="Short description")
    source: str = Field(default="agent_skill", description="Tool source type")
    read_only: bool = Field(default=True, description="Skill is read-only by default")
    writes: bool = Field(default=False, description="Skill does not write by default")
    body: str = Field(
        default="",
        description="Instructions body (empty until activated)",
    )
    agent_skill_path: str = Field(
        default="", description="Absolute path to the skill folder"
    )
    allowed_tools: list[str] = Field(
        default_factory=list,
        description="Tools the skill is permitted to invoke",
    )
    resource_slots: list[AgentSkillResourceSlot] = Field(
        default_factory=list,
        description=(
            "Read-only resource binding slots for discovered resource "
            "directories (scripts/, references/, assets/)."
        ),
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
    )


# ---------------------------------------------------------------------------
# AgentSkillLoader — folder discovery + progressive disclosure
# ---------------------------------------------------------------------------


class AgentSkillLoader:
    """Loads an Agent Skills Standard folder and implements progressive disclosure.

    The loader manages the three-tier disclosure lifecycle:

    1. :meth:`discover` — returns metadata-only descriptor (Tier 1).
    2. :meth:`activate` — loads the full body into :attr:`active_body` (Tier 2).
    3. :meth:`list_resources` — returns paths from ``scripts/``, ``references/``,
       and ``assets/`` sub-directories on demand (Tier 3).
    4. :meth:`deactivate` — clears :attr:`active_body` to free token budget.

    Use :meth:`to_tool_descriptor` to produce the ``AgentSkillToolDescriptor``
    view suitable for registration in the Tool Registry.

    Args:
        spec: Parsed ``AgentSkillSpec`` from ``SKILL.md``.
        folder: Absolute path to the skill folder.
        script_paths: Files discovered in ``scripts/`` (sorted).
        reference_paths: Files discovered in ``references/`` (sorted).
        asset_paths: Files discovered in ``assets/`` (sorted).
        resource_slots: Read-only binding slots for discovered directories.
    """

    def __init__(
        self,
        spec: AgentSkillSpec,
        folder: Path,
        script_paths: list[Path],
        reference_paths: list[Path],
        asset_paths: list[Path],
        resource_slots: list[AgentSkillResourceSlot] | None = None,
    ) -> None:
        if spec is None:
            raise ValueError("spec must not be None")
        if folder is None:
            raise ValueError("folder must not be None")

        self._spec = spec
        self._folder = folder.resolve()
        self._script_paths = script_paths
        self._reference_paths = reference_paths
        self._asset_paths = asset_paths
        self._resource_slots: list[AgentSkillResourceSlot] = (
            resource_slots if resource_slots is not None else []
        )
        self._active_body: str | None = None

    # -- Properties --------------------------------------------------------

    @property
    def spec(self) -> AgentSkillSpec:
        """The parsed ``AgentSkillSpec``."""
        return self._spec

    @property
    def folder(self) -> Path:
        """Resolved absolute path to the skill folder."""
        return self._folder

    @property
    def script_paths(self) -> list[Path]:
        """Sorted list of files found in the ``scripts/`` sub-directory."""
        return self._script_paths

    @property
    def reference_paths(self) -> list[Path]:
        """Sorted list of files found in the ``references/`` sub-directory."""
        return self._reference_paths

    @property
    def asset_paths(self) -> list[Path]:
        """Sorted list of files found in the ``assets/`` sub-directory."""
        return self._asset_paths

    @property
    def active_body(self) -> str | None:
        """The full SKILL.md body once activated; ``None`` before activation
        or after deactivation."""
        return self._active_body

    @property
    def resource_slots(self) -> list[AgentSkillResourceSlot]:
        """Read-only binding slots for discovered resource directories."""
        return self._resource_slots

    # -- Factory -----------------------------------------------------------

    @classmethod
    def from_folder(cls, folder: Path) -> AgentSkillLoader:
        """Load an Agent Skills folder.

        Expects a ``SKILL.md`` file at the root of *folder*.  Discovers
        ``scripts/``, ``references/``, and ``assets/`` sub-directories if
        present.  Builds a read-only resource binding slot for each
        sub-directory that exists.

        Args:
            folder: Path to the Agent Skills Standard folder.

        Returns:
            A new ``AgentSkillLoader`` instance ready for progressive
            disclosure.

        Raises:
            FileNotFoundError: If *folder* does not exist.
            ValueError: If ``SKILL.md`` is not found in *folder*, or if
                the ``SKILL.md`` fails validation.
        """
        if folder is None:
            raise ValueError("folder must not be None")

        resolved = folder.resolve()
        if not resolved.exists():
            raise FileNotFoundError(f"Agent Skills folder not found: '{folder}'")
        if not resolved.is_dir():
            raise ValueError(f"Agent Skills path is not a directory: '{folder}'")

        skill_md = resolved / "SKILL.md"
        if not skill_md.exists():
            raise ValueError(f"SKILL.md not found in agent skills folder '{folder}'")

        spec = AgentSkillSpec.from_file(skill_md)

        script_paths = _discover_subdir(resolved, "scripts")
        reference_paths = _discover_subdir(resolved, "references")
        asset_paths = _discover_subdir(resolved, "assets")

        resource_slots = _build_resource_slots(resolved)

        logger.debug(
            "Loaded agent skill '%s' from '%s' "
            "(scripts=%d, references=%d, assets=%d, slots=%d)",
            spec.name,
            resolved,
            len(script_paths),
            len(reference_paths),
            len(asset_paths),
            len(resource_slots),
        )

        return cls(
            spec=spec,
            folder=resolved,
            script_paths=script_paths,
            reference_paths=reference_paths,
            asset_paths=asset_paths,
            resource_slots=resource_slots,
        )

    # -- Progressive Disclosure lifecycle ----------------------------------

    def discover(self) -> AgentSkillToolDescriptor:
        """Tier 1 — return metadata-only descriptor.

        Returns only ``name``, ``description``, and capability flags.
        The body is **not** loaded; this is cheap (< 100 tokens).

        Returns:
            An :class:`AgentSkillToolDescriptor` with ``body=""`` and all
            metadata fields populated, including resource binding slots.
        """
        return AgentSkillToolDescriptor(
            name=self._spec.name,
            description=self._spec.description,
            source="agent_skill",
            read_only=True,
            writes=False,
            body="",
            agent_skill_path=str(self._folder),
            allowed_tools=list(self._spec.allowed_tools),
            resource_slots=list(self._resource_slots),
        )

    def activate(self) -> AgentSkillToolDescriptor:
        """Tier 2 — load the full SKILL.md body into active context.

        Returns:
            An :class:`AgentSkillToolDescriptor` with ``body`` populated
            with the full Markdown instructions.
        """
        self._active_body = self._spec.body
        logger.debug("Activated agent skill '%s'", self._spec.name)
        return AgentSkillToolDescriptor(
            name=self._spec.name,
            description=self._spec.description,
            source="agent_skill",
            read_only=True,
            writes=False,
            body=self._active_body,
            agent_skill_path=str(self._folder),
            allowed_tools=list(self._spec.allowed_tools),
            resource_slots=list(self._resource_slots),
        )

    def list_resources(self) -> list[Path]:
        """Tier 3 — return all resource file paths on demand.

        Returns files from ``scripts/``, ``references/``, and ``assets/``
        sub-directories, sorted alphabetically.

        Returns:
            Combined sorted list of all resource file paths.
        """
        return sorted(self._script_paths + self._reference_paths + self._asset_paths)

    def deactivate(self) -> None:
        """Clear the active body to free token budget.

        After deactivation, :attr:`active_body` returns ``None``. The
        skill can be re-activated by calling :meth:`activate` again.
        """
        self._active_body = None
        logger.debug("Deactivated agent skill '%s'", self._spec.name)

    # -- Tool descriptor mapping -------------------------------------------

    def to_tool_descriptor(self) -> AgentSkillToolDescriptor:
        """Convert this loader to a tool descriptor for Tool Registry use.

        Produces a descriptor with ``source="agent_skill"``, ``read_only=True``,
        ``writes=False``, and read-only ``resource_slots`` for every
        discovered resource directory.

        Returns:
            An :class:`AgentSkillToolDescriptor` ready for Tool Registry
            registration.
        """
        return AgentSkillToolDescriptor(
            name=self._spec.name,
            description=self._spec.description,
            source="agent_skill",
            read_only=True,
            writes=False,
            body=self._active_body or "",
            agent_skill_path=str(self._folder),
            allowed_tools=list(self._spec.allowed_tools),
            resource_slots=list(self._resource_slots),
        )


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------


def _split_frontmatter(text: str) -> tuple[_YamlMapping, str]:
    """Split a SKILL.md string into frontmatter dict and body text.

    The YAML frontmatter must be enclosed between two ``---`` delimiters
    at the very start of the file.

    Args:
        text: Raw SKILL.md content.

    Returns:
        A 2-tuple of (frontmatter_dict, body_text).

    Raises:
        ValueError: If the frontmatter delimiter is missing, or the YAML
            block cannot be parsed.
    """
    stripped = text.strip()
    if not stripped.startswith(_FRONTMATTER_DELIMITER):
        raise ValueError(
            "SKILL.md is missing YAML frontmatter. "
            "The file must start with '---' followed by a YAML block "
            "and a closing '---' delimiter."
        )

    # Find the closing delimiter (skipping the opening one)
    rest = stripped[len(_FRONTMATTER_DELIMITER) :]
    delimiter_pos = rest.find(f"\n{_FRONTMATTER_DELIMITER}")
    if delimiter_pos == -1:
        raise ValueError(
            "SKILL.md frontmatter is not properly closed. "
            "Expected a closing '---' delimiter after the YAML block."
        )

    yaml_block = rest[:delimiter_pos].strip()
    body_raw = rest[delimiter_pos + len(f"\n{_FRONTMATTER_DELIMITER}") :]
    body = body_raw.lstrip("\n")

    if not yaml_block:
        raise ValueError(
            "SKILL.md frontmatter block is empty. "
            "Add at least 'name' and 'description' fields."
        )

    try:
        raw = yaml.safe_load(yaml_block)
    except yaml.YAMLError as exc:
        raise ValueError(f"SKILL.md frontmatter YAML is invalid: {exc}") from exc

    if not isinstance(raw, dict):
        raise ValueError(
            "SKILL.md frontmatter must be a YAML mapping (key: value pairs)."
        )

    data: _YamlMapping = {str(k): v for k, v in raw.items()}
    return data, body


def _build_spec(data: _YamlMapping, body: str) -> AgentSkillSpec:
    """Validate frontmatter data and build an ``AgentSkillSpec``.

    Args:
        data: Parsed frontmatter dict.
        body: Markdown body text.

    Returns:
        A validated ``AgentSkillSpec``.

    Raises:
        ValueError: If required fields are missing, the name is not in
            ``namespace/short_name`` format, or the ``steps`` list is
            invalid.
    """
    # Check required fields
    for field in _REQUIRED_FIELDS:
        if field not in data or not data[field]:
            raise ValueError(
                f"SKILL.md frontmatter is missing required field '{field}'. "
                f"Add '{field}: <value>' to the YAML block."
            )

    name: str = str(data["name"]).strip()
    description: str = str(data["description"]).strip()

    # Validate namespaced name format
    if not _NAMESPACED_NAME_RE.match(name):
        raise ValueError(
            f"SKILL.md 'name' must follow the namespace/short_name format "
            f"(alphanumeric, hyphens, underscores only), got: '{name}'. "
            "Example: 'local/deploy-staging'"
        )

    # Parse optional structured steps with validation
    steps = _parse_steps(data)

    version: str = str(data.get("version", "0.0.0"))
    compat_raw = data.get("compatibility")
    compatibility: _YamlMapping = compat_raw if isinstance(compat_raw, dict) else {}
    meta_raw = data.get("metadata")
    metadata: _YamlMapping = meta_raw if isinstance(meta_raw, dict) else {}

    # allowed-tools: YAML key uses a hyphen so check both forms
    _raw_allowed = data.get("allowed-tools") or data.get("allowed_tools")
    raw_allowed: list[object] = _raw_allowed if isinstance(_raw_allowed, list) else []
    allowed_tools: list[str] = [str(t) for t in raw_allowed]

    return AgentSkillSpec(
        name=name,
        description=description,
        body=body,
        steps=steps,
        version=version,
        compatibility=compatibility,
        metadata=metadata,
        allowed_tools=allowed_tools,
    )


def _parse_steps(data: _YamlMapping) -> list[SkillStep]:
    """Parse the optional ``steps`` list from frontmatter data.

    Extracts and validates the ``steps`` key when present. Each entry
    must be a non-empty string. Steps are returned in stable insertion
    order with 1-based integer indices.

    Args:
        data: Parsed frontmatter dict.

    Returns:
        Ordered list of :class:`SkillStep` objects. Empty list when the
        ``steps`` key is absent from the frontmatter.

    Raises:
        ValueError: If ``steps`` is present but is not a list, or if any
            individual step entry is empty or non-string.
    """
    raw = data.get("steps")
    if raw is None:
        return []

    if not isinstance(raw, list):
        raise ValueError(
            "SKILL.md frontmatter 'steps' must be a list of step strings. "
            "Example:\nsteps:\n  - Run tests\n  - Deploy to staging"
        )

    steps: list[SkillStep] = []
    for idx, entry in enumerate(raw, start=1):
        content = str(entry).strip() if entry is not None else ""
        if not content:
            raise ValueError(
                f"SKILL.md frontmatter 'steps[{idx}]' must not be empty. "
                "Provide a non-empty step description."
            )
        steps.append(SkillStep(index=idx, content=content))

    return steps


def _build_resource_slots(folder: Path) -> list[AgentSkillResourceSlot]:
    """Build read-only resource binding slots for discovered sub-directories.

    Creates one :class:`AgentSkillResourceSlot` for each of
    ``scripts/``, ``references/``, and ``assets/`` that exists inside
    *folder*. Slots are always ``access="read_only"``.

    Args:
        folder: Resolved absolute path to the skill folder.

    Returns:
        List of :class:`AgentSkillResourceSlot` instances, one per
        existing sub-directory, in ``_RESOURCE_DIRS`` order.
    """
    slots: list[AgentSkillResourceSlot] = []
    for subdir in _RESOURCE_DIRS:
        sub_path = folder / subdir
        if sub_path.exists() and sub_path.is_dir():
            slots.append(
                AgentSkillResourceSlot(
                    name=subdir,
                    resource_type=f"agent_skill_{subdir}",
                    access="read_only",
                    path=str(sub_path),
                )
            )
    return slots


def _discover_subdir(folder: Path, subdir: str) -> list[Path]:
    """Discover files in a sub-directory of *folder*.

    Returns paths that are:
    - Within ``folder / subdir``
    - Regular files (not directories)
    - Sorted alphabetically by full path
    - Expressed as absolute paths (within the skill folder)

    Args:
        folder: Resolved absolute path to the skill folder.
        subdir: Sub-directory name (``"scripts"``, ``"references"``,
            ``"assets"``).

    Returns:
        Sorted list of absolute ``Path`` objects.  Empty list if the
        sub-directory does not exist.
    """
    sub_path = folder / subdir
    if not sub_path.exists() or not sub_path.is_dir():
        return []

    files: list[Path] = []
    for item in sorted(sub_path.rglob("*")):
        if item.is_file():
            files.append(item)

    return files
