from __future__ import annotations

from dataclasses import asdict, dataclass
from email.parser import Parser
import os
from pathlib import Path
import site
import sys
from typing import Iterable

from .advisories import ADVISORIES, Advisory
from .versioning import is_less_than, parse_version


SUSPICIOUS_FILENAMES = {
    ".env",
    ".env.local",
    "credentials.py",
    "payload.sh",
    "postinstall.sh",
}
SUSPICIOUS_SUFFIXES = {".sh", ".bash", ".zsh", ".command", ".ps1", ".bat", ".exe"}
PACKAGE_CONTAINER_DIRS = {"site-packages", "dist-packages"}
CRAWL_PRUNE_DIRS = {
    ".git",
    ".hg",
    ".svn",
    ".tox",
    ".nox",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    "node_modules",
    "__pycache__",
    "proc",
    "dev",
}


@dataclass
class Finding:
    name: str
    version: str
    metadata_path: str
    package_path: str | None
    entry_points: list[str]
    suspicious_files: list[str]
    advisories: list[dict[str, str]]
    notes: list[str]
    status: str

    def to_dict(self) -> dict[str, object]:
        return asdict(self)


def discover_roots(
    extra_roots: Iterable[str] | None = None,
    *,
    crawl: bool = False,
) -> list[Path]:
    roots: list[Path] = []
    candidates: list[str | Path] = []

    if crawl:
        crawl_roots = list(extra_roots) if extra_roots else _default_crawl_roots()
        candidates.extend(_discover_package_containers(crawl_roots))
    else:
        candidates.extend(sys.path)
        candidates.extend(site.getsitepackages())
        user_site = site.getusersitepackages()
        if user_site:
            candidates.append(user_site)
        if extra_roots:
            candidates.extend(extra_roots)

    seen: set[Path] = set()
    for candidate in candidates:
        path = Path(candidate).expanduser()
        if not path.exists():
            continue
        resolved = _safe_resolve(path)
        if resolved is None or resolved in seen:
            continue
        seen.add(resolved)
        roots.append(path)
    return roots


def scan(
    extra_roots: Iterable[str] | None = None,
    *,
    crawl: bool = False,
) -> list[Finding]:
    findings: list[Finding] = []
    seen_metadata: set[Path] = set()

    for root in discover_roots(extra_roots, crawl=crawl):
        for metadata_dir in _iter_litellm_metadata_dirs(root):
            resolved = _safe_resolve(metadata_dir)
            if resolved is None or resolved in seen_metadata:
                continue
            seen_metadata.add(resolved)
            findings.append(_build_finding(metadata_dir))

    findings.sort(key=lambda item: (_status_rank(item.status), item.version, item.metadata_path))
    return findings


def _iter_litellm_metadata_dirs(root: Path) -> Iterable[Path]:
    patterns = ("litellm-*.dist-info", "litellm-*.egg-info")
    for pattern in patterns:
        try:
            yield from root.glob(pattern)
        except OSError:
            return

    if root.name not in PACKAGE_CONTAINER_DIRS:
        for container in _discover_package_containers([root]):
            yield from _iter_litellm_metadata_dirs(container)


def _build_finding(metadata_dir: Path) -> Finding:
    metadata = _parse_metadata(metadata_dir)
    version = metadata.get("Version", "unknown")
    entry_points = _parse_entry_points(metadata_dir)
    package_path = _resolve_package_path(metadata_dir)
    suspicious_files = _scan_suspicious_files(package_path)
    advisory_hits = [advisory for advisory in ADVISORIES if advisory.matches(version)]
    notes = _build_notes(version, advisory_hits, suspicious_files, package_path)
    status = _pick_status(advisory_hits, suspicious_files)

    return Finding(
        name=metadata.get("Name", "litellm"),
        version=version,
        metadata_path=str(metadata_dir),
        package_path=str(package_path) if package_path else None,
        entry_points=entry_points,
        suspicious_files=suspicious_files,
        advisories=[_serialize_advisory(advisory) for advisory in advisory_hits],
        notes=notes,
        status=status,
    )


def _parse_metadata(metadata_dir: Path) -> dict[str, str]:
    for metadata_name in ("METADATA", "PKG-INFO"):
        metadata_file = metadata_dir / metadata_name
        if metadata_file.exists():
            content = metadata_file.read_text(encoding="utf-8", errors="replace")
            return dict(Parser().parsestr(content).items())
    return {}


def _parse_entry_points(metadata_dir: Path) -> list[str]:
    entry_points_file = metadata_dir / "entry_points.txt"
    if not entry_points_file.exists():
        return []

    active_section = None
    entry_points: list[str] = []
    for line in entry_points_file.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line:
            continue
        if line.startswith("[") and line.endswith("]"):
            active_section = line.strip("[]")
            continue
        if active_section == "console_scripts":
            entry_points.append(line)
    return entry_points


def _resolve_package_path(metadata_dir: Path) -> Path | None:
    candidate = metadata_dir.parent / "litellm"
    return candidate if candidate.exists() else None


def _scan_suspicious_files(package_path: Path | None) -> list[str]:
    if not package_path:
        return []

    suspicious: list[str] = []
    for path in package_path.rglob("*"):
        if not path.is_file():
            continue
        if path.name in SUSPICIOUS_FILENAMES or path.suffix.lower() in SUSPICIOUS_SUFFIXES:
            suspicious.append(str(path))
    suspicious.sort()
    return suspicious


def _build_notes(
    version: str,
    advisory_hits: list[Advisory],
    suspicious_files: list[str],
    package_path: Path | None,
) -> list[str]:
    notes: list[str] = []
    if advisory_hits:
        for advisory in advisory_hits:
            notes.append(
                f"Version {version} matches {advisory.id}; upgrade to >= {advisory.fixed_version}."
            )
    else:
        notes.append("No built-in advisory rule matched this version.")

    if suspicious_files:
        notes.append(f"Found {len(suspicious_files)} suspicious file(s) next to the package.")
    if not package_path:
        notes.append("Package directory was not found alongside distribution metadata.")

    if parse_version(version) is None:
        notes.append("Version string could not be normalized; manual review is recommended.")
    elif is_less_than(version, "1.63.14"):
        notes.append(
            "Defensive heuristic: versions lower than 1.63.14 are treated as higher risk "
            "because 1.63.14 is the published corrective release for CVE-2025-11203."
        )

    return notes


def _pick_status(advisory_hits: list[Advisory], suspicious_files: list[str]) -> str:
    if advisory_hits or suspicious_files:
        return "warning"
    return "ok"


def _serialize_advisory(advisory: Advisory) -> dict[str, str]:
    return {
        "id": advisory.id,
        "title": advisory.title,
        "affected_versions": advisory.affected_versions,
        "fixed_version": advisory.fixed_version,
        "severity": advisory.severity,
        "source": advisory.source,
        "published_on": advisory.published_on,
        "summary": advisory.summary,
    }


def _status_rank(status: str) -> int:
    return {"warning": 0, "ok": 1}.get(status, 99)


def _discover_package_containers(start_roots: Iterable[str | Path]) -> list[Path]:
    containers: list[Path] = []
    seen: set[Path] = set()

    for start in start_roots:
        root = Path(start).expanduser()
        if not root.exists():
            continue
        if root.name in PACKAGE_CONTAINER_DIRS and root.is_dir():
            resolved = _safe_resolve(root)
            if resolved is not None and resolved not in seen:
                seen.add(resolved)
                containers.append(root)
            continue

        for current_root, dirnames, _ in os.walk(root, topdown=True, onerror=lambda _: None, followlinks=False):
            current = Path(current_root)
            dirnames[:] = [
                name for name in dirnames
                if name not in CRAWL_PRUNE_DIRS and not name.startswith(".Trash")
            ]
            for dirname in tuple(dirnames):
                if dirname not in PACKAGE_CONTAINER_DIRS:
                    continue
                container = current / dirname
                resolved = _safe_resolve(container)
                if resolved is None or resolved in seen or not container.is_dir():
                    continue
                seen.add(resolved)
                containers.append(container)
                dirnames.remove(dirname)

    return containers


def _default_crawl_roots() -> list[Path]:
    if os.name == "nt":
        roots: list[Path] = []
        for code in range(ord("A"), ord("Z") + 1):
            drive = Path(f"{chr(code)}:/")
            if drive.exists():
                roots.append(drive)
        return roots
    return [Path("/")]


def _safe_resolve(path: Path) -> Path | None:
    try:
        return path.resolve()
    except OSError:
        return None
