import uuid
import logging
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.sbom_entry import Ecosystem, SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.schemas.sbom import (
    LicenceGroup,
    LicenceReport,
    SBOMEntryRead,
    SBOMIngest,
    SBOMRepoView,
    SBOMSnapshotDetail,
    SBOMSnapshotRead,
)
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.services.sbom_nexus import (
    SBOMNexusError,
    get_json,
    post_json,
    reads_from_nexus,
    writes_to_nexus,
)

router = APIRouter(prefix="/sbom", tags=["sbom"])
logger = logging.getLogger(__name__)

_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}


def _is_copyleft(spdx: str | None) -> bool:
    if not spdx:
        return False
    upper = spdx.upper()
    return any(pat in upper for pat in _COPYLEFT_PATTERNS)


def _latest_snapshot_ids_subquery():
    """Subquery returning the latest SBOMSnapshot.id per repo."""
    max_at_sq = (
        select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at"))
        .group_by(SBOMSnapshot.repo_id)
        .subquery("max_snap_at")
    )
    return (
        select(SBOMSnapshot.id)
        .join(
            max_at_sq,
            and_(
                SBOMSnapshot.repo_id == max_at_sq.c.repo_id,
                SBOMSnapshot.snapshot_at == max_at_sq.c.max_at,
            ),
        )
        .subquery("latest_snap_ids")
    )


@router.post("/ingest/")
async def ingest_sbom(
    body: SBOMIngest,
    request: Request,
    session: AsyncSession = Depends(get_session),
) -> dict:
    """Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
    repo = await _get_repo_by_slug(body.repo_slug, session)
    if writes_to_nexus():
        payload = await _nexus_post("/sbom/ingest/", body=body.model_dump(mode="json"))
        try:
            snapshot_at = datetime.fromisoformat(
                payload["snapshot_at"].replace("Z", "+00:00")
            )
            result = {
                "repo_slug": payload["repo_slug"],
                "snapshot_id": payload["snapshot_id"],
                "ingested": payload["ingested"],
                "snapshot_at": payload["snapshot_at"],
            }
        except (AttributeError, KeyError, TypeError, ValueError) as exc:
            raise HTTPException(
                status_code=502,
                detail="SBOM Nexus returned an invalid ingest response",
            ) from exc
        repo.last_sbom_at = snapshot_at
        repo.sbom_source = "sbom-nexus"
        await session.commit()
        await _meter_compat(session, request, "POST", "/sbom/ingest/")
        return result

    now = datetime.now(tz=timezone.utc)

    snap = SBOMSnapshot(
        repo_id=repo.id,
        snapshot_at=now,
        source="manual",
        entry_count=len(body.entries),
        created_at=now,
    )
    session.add(snap)
    await session.flush()  # materialise snap.id before creating entries

    for entry in body.entries:
        sbom = SBOMEntry(
            repo_id=repo.id,
            snapshot_id=snap.id,
            package_name=entry.package_name,
            package_version=entry.package_version,
            ecosystem=entry.ecosystem,
            license_spdx=entry.license_spdx,
            is_direct=entry.is_direct,
            is_dev=entry.is_dev,
            snapshot_at=now,
            created_at=now,
        )
        session.add(sbom)

    repo.last_sbom_at = now
    if not repo.sbom_source:
        repo.sbom_source = "manual"

    await session.commit()
    await _meter_compat(session, request, "POST", "/sbom/ingest/")
    return {
        "repo_slug": body.repo_slug,
        "snapshot_id": str(snap.id),
        "ingested": len(body.entries),
        "snapshot_at": now.isoformat(),
    }


@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots(
    request: Request,
    repo_slug: str | None = Query(None),
    session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]:
    """List SBOM snapshots, newest first. Optionally filter by repo."""
    await _meter_compat(session, request, "GET", "/sbom/snapshots/")
    if reads_from_nexus():
        payload = await _nexus_get(
            "/sbom/snapshots/",
            params={"repo_slug": repo_slug} if repo_slug else None,
        )
        return await _translate_snapshots(payload, session)

    q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
    if repo_slug:
        repo = await _get_repo_by_slug(repo_slug, session)
        q = q.where(SBOMSnapshot.repo_id == repo.id)
    result = await session.execute(q)
    return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]


@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot(
    snapshot_id: uuid.UUID,
    request: Request,
    session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail:
    """Get a snapshot with its full entry list."""
    await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}")
    if reads_from_nexus():
        payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}")
        repo_ids = await _local_repo_ids([payload], session)
        repo_id = repo_ids[payload["repo_slug"]]
        translated = _translate_snapshot(payload, repo_id)
        translated["entries"] = [
            _translate_entry(entry, repo_id) for entry in payload.get("entries", [])
        ]
        return SBOMSnapshotDetail.model_validate(translated)

    snap = await session.get(SBOMSnapshot, snapshot_id)
    if snap is None:
        raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
    result = await session.execute(
        select(SBOMEntry)
        .where(SBOMEntry.snapshot_id == snapshot_id)
        .order_by(SBOMEntry.package_name)
    )
    entries = list(result.scalars().all())
    return SBOMSnapshotDetail(
        id=snap.id,
        repo_id=snap.repo_id,
        snapshot_at=snap.snapshot_at,
        source=snap.source,
        entry_count=snap.entry_count,
        created_at=snap.created_at,
        entries=[SBOMEntryRead.model_validate(e) for e in entries],
    )


@router.get("/")
async def list_sbom_entries(
    request: Request,
    repo_slug: str | None = Query(None),
    ecosystem: Ecosystem | None = Query(None),
    license_spdx: str | None = Query(None),
    is_direct: bool | None = Query(None),
    is_dev: bool | None = Query(None),
    session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]:
    """Return entries from the latest snapshot per repo (default) or filter by repo."""
    await _meter_compat(session, request, "GET", "/sbom/")
    if reads_from_nexus():
        params = {
            key: value
            for key, value in {
                "repo_slug": repo_slug,
                "ecosystem": ecosystem.value if ecosystem is not None else None,
                "license_spdx": license_spdx,
                "is_direct": is_direct,
                "is_dev": is_dev,
            }.items()
            if value is not None
        }
        payload = await _nexus_get("/sbom/", params=params)
        repo_ids = await _local_repo_ids(payload, session)
        return [
            SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]]))
            for entry in payload
        ]

    if repo_slug:
        repo = await _get_repo_by_slug(repo_slug, session)
        latest_snap_id_sq = (
            select(SBOMSnapshot.id)
            .where(SBOMSnapshot.repo_id == repo.id)
            .order_by(SBOMSnapshot.snapshot_at.desc())
            .limit(1)
            .scalar_subquery()
        )
        q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq)
    else:
        latest_ids_sq = _latest_snapshot_ids_subquery()
        q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))

    if ecosystem is not None:
        q = q.where(SBOMEntry.ecosystem == ecosystem)
    if license_spdx:
        q = q.where(SBOMEntry.license_spdx == license_spdx)
    if is_direct is not None:
        q = q.where(SBOMEntry.is_direct == is_direct)
    if is_dev is not None:
        q = q.where(SBOMEntry.is_dev == is_dev)
    q = q.order_by(SBOMEntry.package_name)
    result = await session.execute(q)
    return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]


@router.get("/report/licences/", response_model=LicenceReport)
async def licence_report(
    request: Request,
    session: AsyncSession = Depends(get_session),
) -> LicenceReport:
    """Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
    await _meter_compat(session, request, "GET", "/sbom/report/licences/")
    if reads_from_nexus():
        payload = await _nexus_get("/sbom/report/licences/")
        return LicenceReport.model_validate(payload)

    latest_ids_sq = _latest_snapshot_ids_subquery()
    rows = await session.execute(
        select(SBOMEntry, ManagedRepo.slug)
        .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id)
        .where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
    )
    groups: dict[str | None, dict] = {}
    copyleft_direct_count = 0
    for entry, repo_slug in rows.all():
        key = entry.license_spdx
        if key not in groups:
            groups[key] = {"count": 0, "repos": set()}
        groups[key]["count"] += 1
        groups[key]["repos"].add(repo_slug)
        if _is_copyleft(key) and entry.is_direct and not entry.is_dev:
            copyleft_direct_count += 1

    licence_groups = [
        LicenceGroup(
            license_spdx=lic,
            count=info["count"],
            repos=sorted(info["repos"]),
            is_copyleft=_is_copyleft(lic),
        )
        for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"])
    ]
    return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)


@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
    repo_slug: str,
    request: Request,
    session: AsyncSession = Depends(get_session),
) -> SBOMRepoView:
    """Return the latest snapshot entries for a specific repo."""
    repo = await _get_repo_by_slug(repo_slug, session)
    await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
    if reads_from_nexus():
        payload = await _nexus_get(f"/sbom/{repo_slug}")
        payload["entries"] = [
            _translate_entry(entry, repo.id) for entry in payload.get("entries", [])
        ]
        return SBOMRepoView.model_validate(payload)

    latest_snap_id_sq = (
        select(SBOMSnapshot.id)
        .where(SBOMSnapshot.repo_id == repo.id)
        .order_by(SBOMSnapshot.snapshot_at.desc())
        .limit(1)
        .scalar_subquery()
    )
    rows = await session.execute(
        select(SBOMEntry)
        .where(SBOMEntry.snapshot_id == latest_snap_id_sq)
        .order_by(SBOMEntry.package_name)
    )
    entries = list(rows.scalars().all())
    return SBOMRepoView(
        repo_slug=repo_slug,
        last_sbom_at=repo.last_sbom_at,
        entry_count=len(entries),
        entries=[SBOMEntryRead.model_validate(e) for e in entries],
    )


async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
    result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
    repo = result.scalar_one_or_none()
    if repo is None:
        raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
    return repo


async def _nexus_get(path: str, *, params: dict | None = None):
    try:
        return await get_json(path, params=params)
    except SBOMNexusError as exc:
        raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc


async def _nexus_post(path: str, *, body: dict):
    try:
        return await post_json(path, body=body)
    except SBOMNexusError as exc:
        raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc


async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]:
    slugs = {item.get("repo_slug") for item in items}
    if None in slugs:
        raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
    result = await session.execute(
        select(ManagedRepo.slug, ManagedRepo.id).where(ManagedRepo.slug.in_(slugs))
    )
    repo_ids = dict(result.all())
    missing = sorted(slugs - repo_ids.keys())
    if missing:
        raise HTTPException(
            status_code=502,
            detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}",
        )
    return repo_ids


async def _translate_snapshots(
    items: list[dict], session: AsyncSession
) -> list[SBOMSnapshotRead]:
    repo_ids = await _local_repo_ids(items, session)
    return [
        SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]]))
        for item in items
    ]


def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict:
    translated = {**item, "repo_id": repo_id}
    source = translated.get("source")
    if (
        translated.get("legacy_id")
        and isinstance(source, str)
        and source.startswith("state-hub:")
    ):
        translated["source"] = source.removeprefix("state-hub:")
    return translated


def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict:
    return {**item, "repo_id": repo_id}


async def _meter_compat(
    session: AsyncSession,
    request: Request,
    method: str,
    route: str,
) -> None:
    """Meter the compatibility surface without making route success depend on it."""
    interface_key = f"rest_api:{method} {route}"
    try:
        await record_legacy_usage(
            session,
            interface_key=interface_key,
            interface_kind="rest_api",
            replacement_ref="sbom-nexus:/sbom/",
            owner_component="state-hub.sbom-compat",
            replacement_verified=reads_from_nexus(),
            identity=identity_from_request(request),
        )
    except Exception:
        await session.rollback()
        logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
