"""Workspace loader — convention-based project auto-discovery.

Scans a project root for skills, pathways, and agents using filesystem
conventions and optionally an ``albus.yaml`` manifest.

Convention layout::

    project/
    ├── albus.yaml          # optional manifest (overrides convention)
    ├── skills/
    │   ├── servicem8/
    │   │   ├── SKILL.md
    │   │   ├── tools/
    │   │   └── pathways/   # skill-scoped pathways
    │   └── xero/
    │       ├── SKILL.md
    │       └── tools/
    ├── pathways/            # cross-skill pathways
    │   ├── invoice_flow.py
    │   └── reconcile.yaml
    └── agents/              # one agent per file
        ├── host.py
        └── triage.py

Pathway file convention (``.py``)::

    # Module-level ``pathway`` variable or ``build()`` function.
    from albusos import PathwayBuilder
    pathway = PathwayBuilder("my-flow", pathway_id="my-flow") ...  .build()

Agent file convention (``.py``)::

    # Module-level ``agent`` variable or ``build()`` function.
    from albusos import agent

    agent = agent("triage", instructions="...", tools=["servicem8.*"])
"""

from __future__ import annotations

import importlib.util
import logging
import sys
from pathlib import Path  # noqa: TC003 — needed at runtime by Pydantic
from typing import TYPE_CHECKING, Any

import yaml
from pydantic import BaseModel, Field

if TYPE_CHECKING:
    import types

    from core.pathways.registry import PathwayRegistry

logger = logging.getLogger(__name__)


# =============================================================================
# Models
# =============================================================================


class WorkspaceInfo(BaseModel):
    """Summary returned by :func:`load_workspace`."""

    root: Path
    skills: list[str] = Field(default_factory=list)
    pathways: list[str] = Field(default_factory=list)
    agents: list[str] = Field(default_factory=list)


class AgentManifestEntry(BaseModel):
    """Agent declaration inside ``albus.yaml``."""

    pathway: str = "__default__"
    tools: list[str] = Field(default_factory=lambda: ["*"])
    instructions: str | None = None
    model: str = "reasoning"
    temperature: float = 0.7
    max_steps: int = 10


class WorkspaceManifest(BaseModel):
    """Schema for ``albus.yaml``."""

    skills: list[str] = Field(default_factory=list)
    pathways: list[str] = Field(default_factory=list)
    agents: dict[str, AgentManifestEntry] = Field(default_factory=dict)


# =============================================================================
# Module import helper
# =============================================================================


def _load_python_module(file_path: Path) -> types.ModuleType:
    """Import a Python file as a module.

    Uses ``importlib.util`` so the file does not need to be on
    ``sys.path`` or inside a package.

    Parameters
    ----------
    file_path
        Absolute path to a ``.py`` file.

    Returns
    -------
    types.ModuleType
        The imported module.
    """
    module_name = f"_albus_workspace_.{file_path.stem}"
    spec = importlib.util.spec_from_file_location(module_name, file_path)
    if spec is None or spec.loader is None:
        raise ImportError(f"Cannot create module spec for {file_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    spec.loader.exec_module(module)
    return module


def _extract_attr(module: types.ModuleType, *names: str) -> Any | None:
    """Return the first module-level attribute found, or call ``build()``.

    Tries each *name* in order, then falls back to a ``build()`` callable.
    Returns ``None`` when nothing is found.
    """
    for name in names:
        val = getattr(module, name, None)
        if val is not None:
            return val

    build_fn = getattr(module, "build", None)
    if callable(build_fn):
        return build_fn()

    return None


# =============================================================================
# Discovery helpers
# =============================================================================


def discover_skills(root: Path) -> list[Path]:
    """Return skill directories (those containing ``SKILL.md``)."""
    skills_dir = root / "skills"
    if not skills_dir.is_dir():
        return []
    return sorted(
        d
        for d in skills_dir.iterdir()
        if d.is_dir() and not d.name.startswith((".", "_")) and (d / "SKILL.md").exists()
    )


def discover_pathways(root: Path) -> list[Path]:
    """Return pathway files (``.py`` and ``.yaml``) in ``pathways/``."""
    pathways_dir = root / "pathways"
    if not pathways_dir.is_dir():
        return []
    return sorted(
        f
        for f in pathways_dir.iterdir()
        if f.is_file()
        and not f.name.startswith((".", "_"))
        and f.suffix in (".py", ".yaml", ".yml")
    )


def discover_agents(root: Path) -> list[Path]:
    """Return agent ``.py`` files in ``agents/``."""
    agents_dir = root / "agents"
    if not agents_dir.is_dir():
        return []
    return sorted(
        f
        for f in agents_dir.iterdir()
        if f.is_file() and not f.name.startswith((".", "_")) and f.suffix == ".py"
    )


# =============================================================================
# Pathway loading from file
# =============================================================================


def _load_pathway_from_python(file_path: Path) -> Any | None:
    """Import a pathway ``.py`` file and extract the ``Pathway`` object.

    Convention: module defines ``pathway`` variable or ``build()`` function.
    """
    try:
        module = _load_python_module(file_path)
        return _extract_attr(module, "pathway")
    except Exception as e:
        logger.warning("Failed to load pathway from %s: %s", file_path, e)
        return None


def _load_pathway_from_yaml(file_path: Path) -> Any | None:
    """Load a pathway from a YAML file."""
    try:
        from core.pathways.loader import pathway_from_dict

        raw = yaml.safe_load(file_path.read_text(encoding="utf-8"))
        if not isinstance(raw, dict):
            logger.warning("Pathway YAML %s is not a mapping", file_path)
            return None
        # Inject id from filename if not present.
        if "id" not in raw:
            raw["id"] = file_path.stem
        return pathway_from_dict(raw)
    except Exception as e:
        logger.warning("Failed to load pathway from %s: %s", file_path, e)
        return None


def load_pathway_from_file(file_path: Path) -> Any | None:
    """Load a single pathway from a ``.py`` or ``.yaml`` file.

    Parameters
    ----------
    file_path
        Path to a pathway file.

    Returns
    -------
    Pathway | None
        The loaded pathway, or ``None`` on failure.
    """
    loaders: dict[str, Any] = {
        ".py": _load_pathway_from_python,
        ".yaml": _load_pathway_from_yaml,
        ".yml": _load_pathway_from_yaml,
    }
    loader = loaders.get(file_path.suffix)
    if loader is None:
        logger.warning("Unsupported pathway file type: %s", file_path.suffix)
        return None
    return loader(file_path)


# =============================================================================
# Agent loading from file
# =============================================================================


def _load_agent_from_python(file_path: Path) -> Any | None:
    """Import an agent ``.py`` file and extract the ``AgentDefinition``.

    Convention: module defines ``agent`` variable or ``build()`` function.
    """
    try:
        module = _load_python_module(file_path)
        return _extract_attr(module, "agent")
    except Exception as e:
        logger.warning("Failed to load agent from %s: %s", file_path, e)
        return None


# =============================================================================
# Manifest parsing
# =============================================================================


def _load_manifest(root: Path) -> WorkspaceManifest | None:
    """Read ``albus.yaml`` if it exists. Returns ``None`` when absent."""
    for name in ("albus.yaml", "albus.yml"):
        manifest_path = root / name
        if manifest_path.exists():
            try:
                raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
                if not isinstance(raw, dict):
                    logger.warning("albus.yaml at %s is not a mapping", manifest_path)
                    return None
                return WorkspaceManifest.model_validate(raw)
            except Exception as e:
                logger.warning("Failed to parse %s: %s", manifest_path, e)
                return None
    return None


def _validate_manifest_paths(manifest: WorkspaceManifest, root: Path) -> list[str]:
    """Validate that manifest references exist. Return list of warnings."""
    warnings: list[str] = []
    for skill_path in manifest.skills:
        resolved = root / skill_path
        if not resolved.exists():
            warnings.append(f"Skill not found at {skill_path}")
        elif not (resolved / "SKILL.md").exists():
            warnings.append(f"No SKILL.md in {skill_path}")
    for pathway_path in manifest.pathways:
        resolved = root / pathway_path
        if not resolved.exists():
            warnings.append(f"Pathway path not found: {pathway_path}")
    return warnings


# =============================================================================
# Core load_workspace
# =============================================================================


def load_workspace(root: Path) -> WorkspaceInfo:
    """Scan *root* and load everything into global registries.

    If ``albus.yaml`` exists, uses it as the manifest. Otherwise, falls
    back to convention-based discovery.

    Parameters
    ----------
    root
        Absolute path to the workspace root.

    Returns
    -------
    WorkspaceInfo
    """
    info = WorkspaceInfo(root=root)

    manifest = _load_manifest(root)
    if manifest is not None:
        _load_from_manifest(manifest, root, info)
    else:
        _load_by_convention(root, info)

    logger.info(
        "Workspace loaded: %d skills, %d pathways, %d agents",
        len(info.skills),
        len(info.pathways),
        len(info.agents),
    )
    return info


# ---------------------------------------------------------------------------
# Convention-based loading
# ---------------------------------------------------------------------------


def _load_by_convention(root: Path, info: WorkspaceInfo) -> None:
    """Discover and load using filesystem conventions."""
    # 1. Skills
    for skill_dir in discover_skills(root):
        _do_load_skill(skill_dir, info)

    # 2. Pathways (cross-skill, top-level pathways/ directory)
    for pathway_file in discover_pathways(root):
        _do_load_pathway(pathway_file, info)

    # 3. Agents
    for agent_file in discover_agents(root):
        _do_load_agent(agent_file, info)


# ---------------------------------------------------------------------------
# Manifest-based loading
# ---------------------------------------------------------------------------


def _load_from_manifest(
    manifest: WorkspaceManifest,
    root: Path,
    info: WorkspaceInfo,
) -> None:
    """Load resources declared in ``albus.yaml``."""
    # Validate paths upfront.
    warnings = _validate_manifest_paths(manifest, root)
    for w in warnings:
        logger.warning("Manifest validation: %s", w)

    # 1. Skills
    for skill_path in manifest.skills:
        resolved = (root / skill_path).resolve()
        if resolved.is_dir():
            _do_load_skill(resolved, info)

    # 2. Pathways — each entry is either a file or a directory.
    for pathway_entry in manifest.pathways:
        resolved = (root / pathway_entry).resolve()
        if resolved.is_dir():
            for f in sorted(resolved.iterdir()):
                if f.is_file() and f.suffix in (".py", ".yaml", ".yml"):
                    _do_load_pathway(f, info)
        elif resolved.is_file():
            _do_load_pathway(resolved, info)

    # 3. Agents (from manifest agent declarations).
    for agent_id, entry in manifest.agents.items():
        _do_load_agent_from_manifest(agent_id, entry, info)


# ---------------------------------------------------------------------------
# Individual resource loaders (shared by both paths)
# ---------------------------------------------------------------------------


def _do_load_skill(skill_dir: Path, info: WorkspaceInfo) -> None:
    """Load a single skill directory and register it."""
    from core.skills.loader import load_skill_from_directory
    from core.skills.registry import get_skill_registry

    try:
        skill = load_skill_from_directory(skill_dir)

        if skill is None:
            logger.warning("Skill directory %s has no SKILL.md", skill_dir)
            return

        skill.tags.add("external")
        get_skill_registry().register(skill)
        info.skills.append(skill.id)
        logger.debug("Loaded skill: %s from %s", skill.id, skill_dir)
    except Exception as e:
        logger.warning("Failed to load skill from %s: %s", skill_dir, e)


def _do_load_pathway(pathway_file: Path, info: WorkspaceInfo) -> None:
    """Load a single pathway file and deploy it."""
    pathway = load_pathway_from_file(pathway_file)
    if pathway is None:
        return

    try:
        # Use a module-level registry for workspace pathways.
        registry = _get_workspace_pathway_registry()
        registry.deploy(pathway, source="workspace")
        info.pathways.append(pathway.id)
        logger.debug("Loaded pathway: %s from %s", pathway.id, pathway_file)
    except Exception as e:
        logger.warning("Failed to deploy pathway %s: %s", pathway_file, e)


def _register_agent_globally(defn: Any) -> None:
    """Register an agent definition in the global :class:`AgentRepository`.

    Best-effort: logs a warning on failure rather than propagating.
    """
    try:
        import asyncio

        from core.agent import get_agent_repository

        repo = get_agent_repository()

        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            loop = None

        if loop is not None:
            from concurrent.futures import ThreadPoolExecutor

            with ThreadPoolExecutor(max_workers=1) as pool:
                pool.submit(asyncio.run, repo.save(defn)).result()
        else:
            asyncio.run(repo.save(defn))
    except Exception as e:
        logger.warning("Failed to register agent %s globally: %s", getattr(defn, "id", "?"), e)


def _do_load_agent_from_manifest(
    agent_id: str,
    entry: AgentManifestEntry,
    info: WorkspaceInfo,
) -> None:
    """Create an AgentDefinition from a manifest entry."""
    from core.types.agents import AgentDefinition

    try:
        defn = AgentDefinition(
            id=agent_id,
            name=agent_id,
            instructions=entry.instructions or "",
            tools=entry.tools,
            model=entry.model,
            pathway=entry.pathway,
            temperature=entry.temperature,
            max_steps=entry.max_steps,
        )
        _get_workspace_agents().append(defn)
        _register_agent_globally(defn)
        info.agents.append(agent_id)
        logger.debug("Registered agent from manifest: %s", agent_id)
    except Exception as e:
        logger.warning("Failed to create agent %s from manifest: %s", agent_id, e)


def _do_load_agent(agent_file: Path, info: WorkspaceInfo) -> None:
    """Load a single agent file and register its definition."""
    defn = _load_agent_from_python(agent_file)
    if defn is None:
        return

    try:
        _get_workspace_agents().append(defn)
        _register_agent_globally(defn)
        info.agents.append(defn.id)
        logger.debug("Loaded agent: %s from %s", defn.id, agent_file)
    except Exception as e:
        logger.warning("Failed to register agent from %s: %s", agent_file, e)


# =============================================================================
# Workspace-level registries (retrieved by Session / runner)
# =============================================================================

_workspace_pathway_registry: PathwayRegistry | None = None
_workspace_agents: list[Any] = []


def _get_workspace_pathway_registry() -> PathwayRegistry:
    """Lazily create a module-level ``PathwayRegistry`` for workspace pathways."""
    global _workspace_pathway_registry
    if _workspace_pathway_registry is None:
        from core.pathways.registry import PathwayRegistry

        _workspace_pathway_registry = PathwayRegistry()
    return _workspace_pathway_registry


def get_workspace_pathway_registry() -> PathwayRegistry | None:
    """Return the workspace pathway registry, if one has been created."""
    return _workspace_pathway_registry


def _get_workspace_agents() -> list[Any]:
    """Return the workspace agent definition list."""
    return _workspace_agents


def get_workspace_agents() -> list[Any]:
    """Return all agent definitions discovered by :func:`load_workspace`.

    Returns
    -------
    list[AgentDefinition]
        Agent definitions loaded from ``agents/`` or ``albus.yaml``.
    """
    return list(_workspace_agents)


__all__ = [
    "AgentManifestEntry",
    "WorkspaceInfo",
    "WorkspaceManifest",
    "discover_agents",
    "discover_pathways",
    "discover_skills",
    "get_workspace_agents",
    "get_workspace_pathway_registry",
    "load_pathway_from_file",
    "load_workspace",
]
