"""Core orchestrator for depwhy dependency conflict analysis.

Provides the public :func:`analyze` function that ties together parsing,
fetching, graph building, conflict detection, fix ranking, and explanation
generation into a single synchronous call.
"""

from __future__ import annotations

import asyncio
import logging
import os
from pathlib import Path
from typing import Any

from depwhy.explain.generator import generate_explanation
from depwhy.fetcher.cache import DiskCache
from depwhy.fetcher.pypi import PyPIFetcher
from depwhy.graph.builder import GraphBuilder
from depwhy.graph.models import Conflict, ConflictReport
from depwhy.parsers.pyproject import parse_pyproject
from depwhy.parsers.requirements import parse_requirements
from depwhy.solver.detector import (
    detect_conflicts,  # pyright: ignore[reportUnknownVariableType]
    extract_available_versions,
)
from depwhy.solver.ranker import rank_fixes  # pyright: ignore[reportUnknownVariableType]

logger = logging.getLogger(__name__)


def _empty_report() -> ConflictReport:
    """Return an empty conflict report for cases with no requirements."""
    return ConflictReport(
        has_conflicts=False,
        conflicts=[],
        warnings=[],
        analyzed_packages=0,
    )


def _parse_source(source: str | list[str] | os.PathLike[str]) -> list[str]:
    """Determine the source type and parse it into a list of requirement strings.

    Args:
        source: Either a list of requirement strings, a path to a
            ``requirements.txt`` file, or a path to a ``pyproject.toml`` file.

    Returns:
        A list of PEP 508 requirement strings.

    Raises:
        FileNotFoundError: If the source is a file path that does not exist.
        ValueError: If the source file cannot be parsed.
    """
    if isinstance(source, list):
        return source

    path = Path(source)
    if path.suffix == ".toml" or path.name == "pyproject.toml":
        return parse_pyproject(path)
    return parse_requirements(path)


async def _run_pipeline(
    requirements: list[str],
    *,
    cache: DiskCache,
    max_concurrency: int,
    python_version: str | None,
    platform: str | None,
) -> ConflictReport:
    """Execute the async analysis pipeline.

    Args:
        requirements: List of PEP 508 requirement strings.
        cache: Disk cache instance for PyPI metadata.
        max_concurrency: Maximum number of concurrent HTTP requests.
        python_version: Target Python version for marker evaluation.
        platform: Target platform for marker evaluation.

    Returns:
        A :class:`ConflictReport` summarising all detected conflicts.
    """
    async with PyPIFetcher(cache, max_concurrency=max_concurrency) as fetcher:
        # Build the constraint graph (this fetches metadata internally)
        builder = GraphBuilder(
            fetcher,
            python_version=python_version,
            platform=platform,
        )
        graph = await builder.build(requirements)  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]

        # Collect the set of unique package names from the graph (excluding root)
        root_sentinel = "__root__"
        package_names: list[str] = [
            str(node)  # pyright: ignore[reportUnknownArgumentType]
            for node in graph.nodes  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
            if str(node) != root_sentinel  # pyright: ignore[reportUnknownArgumentType]
        ]

        # Fetch PyPI metadata for all packages to get available versions
        pypi_responses: dict[str, dict[str, Any]] = await fetcher.fetch_many(package_names)

    # Extract available versions from the fetched metadata
    available_versions = extract_available_versions(pypi_responses)

    # Detect conflicts
    raw_conflicts, warnings = detect_conflicts(graph, available_versions)

    # For each conflict, rank fixes and generate explanations
    enriched_conflicts: list[Conflict] = []
    for conflict in raw_conflicts:
        solution, alternative = rank_fixes(conflict, graph, available_versions)
        problem = generate_explanation(conflict)
        enriched_conflicts.append(
            Conflict(
                package=conflict.package,
                problem=problem,
                constraints=conflict.constraints,
                solution=solution,
                alternative=alternative,
            )
        )

    return ConflictReport(
        has_conflicts=len(enriched_conflicts) > 0,
        conflicts=enriched_conflicts,
        warnings=warnings,
        analyzed_packages=len(package_names),
    )


def analyze(
    source: str | list[str] | os.PathLike[str],
    *,
    python_version: str | None = None,
    platform: str | None = None,
    offline: bool = False,
    cache_ttl_hours: int = 24,
    max_concurrency: int = 10,
) -> ConflictReport:
    """Analyse Python dependencies for version conflicts.

    This is the main public entry point for the depwhy library. It parses
    requirements from the given *source*, fetches package metadata from PyPI,
    builds a constraint graph, detects conflicts, and returns a structured
    report with plain-English explanations and ranked fix suggestions.

    Args:
        source: The dependency source to analyse. Can be:

            - A ``list[str]`` of PEP 508 requirement strings
              (e.g. ``["requests>=2.28", "urllib3==1.26.0"]``).
            - A ``str`` or :class:`os.PathLike` path to a ``requirements.txt``
              file.
            - A ``str`` or :class:`os.PathLike` path to a ``pyproject.toml``
              file.

        python_version: Target Python version for environment marker
            evaluation, e.g. ``"3.11"``. Defaults to the running
            interpreter's version.
        platform: Target platform for environment marker evaluation,
            e.g. ``"linux"``. Defaults to the current platform.
        offline: If ``True``, only use cached metadata and do not make
            network requests. Requires a warm cache to be useful.
        cache_ttl_hours: Number of hours before cached PyPI metadata is
            considered stale. Defaults to 24.
        max_concurrency: Maximum number of concurrent HTTP requests to
            PyPI. Defaults to 10.

    Returns:
        A :class:`~depwhy.graph.models.ConflictReport` summarising all
        detected conflicts, warnings, and fix suggestions. Never raises
        on logical conflicts — only on I/O errors or unparseable input.

    Raises:
        FileNotFoundError: If *source* is a file path that does not exist.
        ValueError: If the source file cannot be parsed or contains
            invalid requirement strings.
    """
    # Parse requirements from the source
    requirements = _parse_source(source)

    if not requirements:
        logger.info("No requirements found in source; returning empty report")
        return _empty_report()

    logger.info("Analysing %d requirements", len(requirements))

    # Create the disk cache
    cache = DiskCache(ttl_hours=cache_ttl_hours)

    # Run the async pipeline synchronously
    report = asyncio.run(
        _run_pipeline(
            requirements,
            cache=cache,
            max_concurrency=max_concurrency,
            python_version=python_version,
            platform=platform,
        )
    )

    if report.has_conflicts:
        logger.info(
            "Found %d conflict(s) across %d packages",
            len(report.conflicts),
            report.analyzed_packages,
        )
    else:
        logger.info("No conflicts found across %d packages", report.analyzed_packages)

    return report
