from __future__ import annotations

import json
import os
import re
import time
import logging
from pathlib import Path

try:
    import requests as _requests
except ImportError:
    _requests = None

logger = logging.getLogger("skylos.sca")

OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
OSV_BATCH_LIMIT = 1000
CACHE_TTL_SECONDS = 3600

ECOSYSTEM_PYPI = "PyPI"
ECOSYSTEM_NPM = "npm"
ECOSYSTEM_GO = "Go"

_VERSION_RE = re.compile(r"([=~!<>]=?)\s*([A-Za-z0-9._*+-]+)")
_REQ_LINE_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)")


def _extract_pinned_version(spec: str) -> str | None:
    matches = _VERSION_RE.findall(spec)
    for op, ver in matches:
        if op == "==":
            return ver.rstrip(".*")
        if op in ("~=", ">="):
            return ver
    return None


def parse_requirements_txt(path: Path) -> list[dict]:
    deps = []
    try:
        lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
    except Exception:
        return deps

    for lineno, line in enumerate(lines, 1):
        raw = line.strip()
        if not raw or raw.startswith("#") or raw.startswith("-"):
            continue
        if raw.startswith("git+") or raw.startswith("http"):
            continue

        m = _REQ_LINE_RE.match(raw)
        if not m:
            continue

        name = m.group(1)
        rest = raw[m.end() :]
        version = _extract_pinned_version(rest)
        if not version:
            continue

        deps.append(
            {
                "name": name,
                "version": version,
                "ecosystem": ECOSYSTEM_PYPI,
                "file": str(path),
                "line": lineno,
                "snippet": raw,
            }
        )

    return deps


def parse_pyproject_toml(path: Path) -> list[dict]:
    deps = []
    try:
        txt = path.read_text(encoding="utf-8", errors="ignore")
    except Exception:
        return deps

    dep_block = _extract_toml_array(txt, "dependencies")
    if dep_block:
        items = re.findall(r"""['"]([^'"]+)['"]""", dep_block)
        for item in items:
            m = _REQ_LINE_RE.match(item.strip())
            if not m:
                continue

            name = m.group(1)
            rest = item[m.end() :]
            version = _extract_pinned_version(rest)
            if not version:
                continue

            lineno = _find_line(txt, item)
            deps.append(
                {
                    "name": name,
                    "version": version,
                    "ecosystem": ECOSYSTEM_PYPI,
                    "file": str(path),
                    "line": lineno,
                    "snippet": item.strip(),
                }
            )

    in_poetry = False
    for lineno, raw_line in enumerate(txt.splitlines(), 1):
        line = raw_line.strip()
        if line.startswith("[") and line.endswith("]"):
            in_poetry = line == "[tool.poetry.dependencies]"
            continue

        if not in_poetry or not line or line.startswith("#"):
            continue

        parts = line.split("=", 1)
        if len(parts) != 2:
            continue

        key = parts[0].strip()
        val = parts[1].strip()
        if key == "python":
            continue

        ver_match = re.search(r"""['"]?\^?~?([0-9][A-Za-z0-9._*+-]*)""", val)
        if ver_match:
            deps.append(
                {
                    "name": key,
                    "version": ver_match.group(1),
                    "ecosystem": ECOSYSTEM_PYPI,
                    "file": str(path),
                    "line": lineno,
                    "snippet": line,
                }
            )

    return deps


def parse_package_json(path: Path) -> list[dict]:
    deps = []
    try:
        data = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
    except Exception:
        return deps

    txt = path.read_text(encoding="utf-8", errors="ignore")

    for section in ("dependencies", "devDependencies"):
        pkg_deps = data.get(section, {})
        if not isinstance(pkg_deps, dict):
            continue

        for name, version_spec in pkg_deps.items():
            if not isinstance(version_spec, str):
                continue

            version = re.sub(r"^[\^~>=<]+", "", version_spec).strip()
            if not version or not version[0].isdigit():
                continue

            lineno = _find_line(txt, f'"{name}"')
            deps.append(
                {
                    "name": name,
                    "version": version,
                    "ecosystem": ECOSYSTEM_NPM,
                    "file": str(path),
                    "line": lineno,
                    "snippet": f'"{name}": "{version_spec}"',
                }
            )

    return deps


def parse_go_mod(path: Path) -> list[dict]:
    deps = []
    try:
        lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
    except Exception:
        return deps

    in_require = False
    for lineno, line in enumerate(lines, 1):
        stripped = line.strip()
        if stripped.startswith("require ("):
            in_require = True
            continue

        if stripped == ")":
            in_require = False
            continue

        if in_require or stripped.startswith("require "):
            parts = stripped.replace("require ", "").strip().split()
            if len(parts) >= 2 and parts[1].startswith("v"):
                deps.append(
                    {
                        "name": parts[0],
                        "version": parts[1].lstrip("v"),
                        "ecosystem": ECOSYSTEM_GO,
                        "file": str(path),
                        "line": lineno,
                        "snippet": stripped,
                    }
                )

    return deps


def _extract_toml_array(txt: str, key: str) -> str | None:
    pattern = re.compile(r"(?m)^\s*" + re.escape(key) + r"\s*=\s*\[")
    match = pattern.search(txt)
    if not match:
        return None
    start = match.end()
    depth = 1
    pos = start
    while pos < len(txt) and depth > 0:
        ch = txt[pos]
        if ch == "[":
            depth += 1
        elif ch == "]":
            depth -= 1
        elif ch in ('"', "'"):
            quote = ch
            pos += 1
            while pos < len(txt) and txt[pos] != quote:
                if txt[pos] == "\\":
                    pos += 1
                pos += 1
        pos += 1
    if depth != 0:
        return None
    return txt[start : pos - 1]


def _find_line(text: str, needle: str) -> int:
    idx = text.find(needle)
    if idx < 0:
        return 1
    return text[:idx].count("\n") + 1


def _cvss_to_severity(score: float | None) -> str:
    if score is None:
        return "UNKNOWN"
    if score >= 9.0:
        return "CRITICAL"
    if score >= 7.0:
        return "HIGH"
    if score >= 4.0:
        return "MEDIUM"
    return "LOW"


def _get_cvss_score(vuln: dict) -> float | None:
    db = vuln.get("database_specific", {})
    for key in ("cvss_score", "score", "cvss"):
        val = db.get(key)
        if val is not None:
            if isinstance(val, dict):
                val = val.get("score")
            try:
                return float(val)
            except (ValueError, TypeError):
                continue

    for entry in vuln.get("severity", []):
        score_str = entry.get("score", "")
        try:
            return float(score_str)
        except (ValueError, TypeError):
            pass

    ghsa_severity = db.get("severity")
    if isinstance(ghsa_severity, str):
        label_map = {"CRITICAL": 9.5, "HIGH": 8.0, "MODERATE": 5.5, "LOW": 2.5}
        score = label_map.get(ghsa_severity.upper())
        if score is not None:
            return score

    return None


def _get_fixed_version(vuln: dict, ecosystem: str) -> str | None:
    for affected in vuln.get("affected", []):
        pkg = affected.get("package", {})
        if pkg.get("ecosystem", "").lower() != ecosystem.lower():
            continue

        for rng in affected.get("ranges", []):
            for event in rng.get("events", []):
                if "fixed" in event:
                    return event["fixed"]
    return None


def _get_affected_range(vuln: dict) -> str:
    parts = []
    for affected in vuln.get("affected", []):
        for rng in affected.get("ranges", []):
            events = rng.get("events", [])

            introduced = None
            fixed = None
            for e in events:
                if "introduced" in e and introduced is None:
                    introduced = e["introduced"]
                if "fixed" in e and fixed is None:
                    fixed = e["fixed"]

            if introduced and fixed:
                parts.append(f">={introduced}, <{fixed}")
            elif introduced:
                parts.append(f">={introduced}")
    return "; ".join(parts) if parts else "unknown"


def _load_cache(cache_path: Path) -> dict:
    try:
        if cache_path.exists():
            data = json.loads(cache_path.read_text(encoding="utf-8"))
            if data.get("_ts", 0) + CACHE_TTL_SECONDS > time.time():
                return data
    except Exception:
        pass
    return {}


def _save_cache(cache_path: Path, cache: dict):
    try:
        cache["_ts"] = time.time()
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
    except Exception:
        pass


def scan_dependencies(root: Path) -> list[dict]:
    if _requests is None:
        logger.warning("requests library not available, skipping SCA scan")
        return []

    root = Path(root).resolve()
    all_deps: list[dict] = []

    dep_files = {
        "requirements.txt": parse_requirements_txt,
        "pyproject.toml": parse_pyproject_toml,
        "package.json": parse_package_json,
        "go.mod": parse_go_mod,
    }

    skip_dirs = {
        "node_modules",
        ".git",
        "__pycache__",
        ".venv",
        "venv",
        ".tox",
        ".mypy_cache",
        "dist",
        "build",
        ".eggs",
    }

    for dirpath, dirnames, filenames in os.walk(root):
        rel = os.path.relpath(dirpath, root)
        depth = 0 if rel == "." else rel.count(os.sep) + 1
        if depth > 3:
            dirnames.clear()
            continue

        dirnames[:] = [d for d in dirnames if d not in skip_dirs]

        for fname in filenames:
            parser = dep_files.get(fname)
            if parser:
                fpath = Path(dirpath) / fname
                try:
                    parsed = parser(fpath)
                    all_deps.extend(parsed)
                except Exception as e:
                    logger.debug(f"Failed to parse {fpath}: {e}")

    if not all_deps:
        return []

    seen = set()
    unique_deps = []
    for dep in all_deps:
        key = (dep["name"], dep["version"], dep["ecosystem"])
        if key not in seen:
            seen.add(key)
            unique_deps.append(dep)

    cache_path = root / ".skylos" / "cache" / "osv_cache.json"
    cache = _load_cache(cache_path)

    findings = []
    to_query = []

    for dep in unique_deps:
        cache_key = f"{dep['ecosystem']}:{dep['name']}:{dep['version']}"
        cached = cache.get(cache_key)
        if cached is not None:
            if cached:
                for vuln_data in cached:
                    findings.append(_make_finding(dep, vuln_data))
        else:
            to_query.append(dep)

    if to_query:
        findings.extend(_query_osv_batch(to_query, cache))
        _save_cache(cache_path, cache)

    return findings


def _query_osv_batch(deps: list[dict], cache: dict) -> list[dict]:
    findings = []

    for batch_start in range(0, len(deps), OSV_BATCH_LIMIT):
        batch = deps[batch_start : batch_start + OSV_BATCH_LIMIT]
        queries = []
        for dep in batch:
            queries.append(
                {
                    "package": {
                        "name": dep["name"],
                        "ecosystem": dep["ecosystem"],
                    },
                    "version": dep["version"],
                }
            )

        try:
            resp = _requests.post(
                OSV_BATCH_URL,
                json={"queries": queries},
                timeout=30,
                headers={"User-Agent": "skylos-sca/1.0"},
            )
            resp.raise_for_status()
            results = resp.json().get("results", [])
        except Exception as e:
            logger.warning(f"OSV.dev API error: {e}")
            continue

        for dep, result in zip(batch, results):
            cache_key = f"{dep['ecosystem']}:{dep['name']}:{dep['version']}"
            vulns = result.get("vulns", [])

            if not vulns:
                cache[cache_key] = []
                continue

            vuln_summaries = []
            for vuln in vulns:
                vuln_id = vuln.get("id", "UNKNOWN")
                summary = (
                    vuln.get("summary")
                    or vuln.get("details", "")[:200]
                    or ", ".join(vuln.get("aliases", []))
                    or f"Known vulnerability ({vuln_id})"
                )
                cvss = _get_cvss_score(vuln)
                fixed = _get_fixed_version(vuln, dep["ecosystem"])
                affected_range = _get_affected_range(vuln)
                refs = [
                    r.get("url") for r in vuln.get("references", []) if r.get("url")
                ][:5]
                aliases = vuln.get("aliases", [])

                vuln_data = {
                    "vuln_id": vuln_id,
                    "aliases": aliases,
                    "summary": summary[:300],
                    "cvss_score": cvss,
                    "fixed_version": fixed,
                    "affected_range": affected_range,
                    "references": refs,
                }
                vuln_summaries.append(vuln_data)
                findings.append(_make_finding(dep, vuln_data))

            cache[cache_key] = vuln_summaries

    return findings


def _make_finding(dep: dict, vuln_data: dict) -> dict:
    vuln_id = vuln_data["vuln_id"]
    cvss = vuln_data.get("cvss_score")
    severity = _cvss_to_severity(cvss)
    fixed = vuln_data.get("fixed_version")
    aliases = vuln_data.get("aliases", [])

    display_id = vuln_id
    for alias in aliases:
        if alias.startswith("CVE-"):
            display_id = alias
            break

    if fixed:
        fix_hint = f" Upgrade to {fixed}."
    else:
        fix_hint = ""

    message = f"{dep['name']}@{dep['version']}: {vuln_data['summary']}{fix_hint}"

    return {
        "rule_id": f"SKY-SCA-{vuln_id}",
        "category": "DEPENDENCY",
        "severity": severity,
        "message": message,
        "file": dep["file"],
        "file_path": dep["file"],
        "line": dep["line"],
        "line_number": dep["line"],
        "snippet": dep.get("snippet", ""),
        "symbol": f"{dep['name']}@{dep['version']}",
        "metadata": {
            "vuln_id": vuln_id,
            "display_id": display_id,
            "aliases": aliases,
            "affected_range": vuln_data.get("affected_range", ""),
            "fixed_version": fixed,
            "cvss_score": cvss,
            "references": vuln_data.get("references", []),
            "ecosystem": dep["ecosystem"],
            "package_name": dep["name"],
            "package_version": dep["version"],
        },
    }
