from __future__ import annotations

import secrets
import shutil
from pathlib import Path

from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session

from ..auth import require_session
from ..database import get_db
from ..http_cache import build_etag, is_matching_etag, set_no_store, set_private_revalidate, set_public_immutable
from ..models import AudioAsset, AudioClip, GameAssignment, UserSession
from ..schemas import (
    AudioAssetImportCreate,
    AudioAssetResponse,
    AudioAssetUpdate,
    AudioClipCreate,
    AudioClipResponse,
    AudioClipUpdate,
    AudioClipReorder,
)
from ..storage import storage

router = APIRouter(prefix="/media", tags=["media"])

DEFAULT_CLIP_LENGTH_MS = 30_000


def resolve_media_scope(
    session: UserSession,
    *,
    requested_team_id: str | None = None,
    requested_player_id: str | None = None,
    require_player: bool = False,
) -> tuple[str, str | None]:
    if session.is_admin:
        team_id = requested_team_id or session.external_team_id
        player_id = requested_player_id if requested_player_id is not None else session.external_player_id
        if not team_id:
            raise HTTPException(status_code=422, detail="Select a team before managing media")
        if require_player and not player_id:
            raise HTTPException(status_code=422, detail="Select a player before managing media")
        return team_id, player_id

    if not session.external_team_id or not session.external_player_id:
        raise HTTPException(status_code=422, detail="Select a team and player before managing media")
    if requested_team_id and requested_team_id != session.external_team_id:
        raise HTTPException(status_code=403, detail="This team does not match your selected session")
    if requested_player_id and requested_player_id != session.external_player_id:
        raise HTTPException(status_code=403, detail="This player does not match your selected session")
    return session.external_team_id, session.external_player_id


def clip_to_response(clip: AudioClip) -> AudioClipResponse:
    normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None
    waveform = storage.load_or_generate_waveform(clip.asset.storage_path)
    return AudioClipResponse(
        id=clip.id,
        asset_id=clip.asset_id,
        external_team_id=clip.asset.external_team_id,
        owner_external_player_id=clip.asset.owner_external_player_id,
        asset_title=clip.asset.title,
        label=clip.label,
        start_ms=clip.start_ms,
        end_ms=clip.end_ms,
        sort_order=clip.sort_order,
        hidden=clip.hidden,
        normalization_status=clip.normalization_status,
        normalized_url=normalized_url,
        waveform_duration_ms=waveform["duration_ms"] if waveform else None,
        waveform_peaks=waveform["peaks"] if waveform else None,
        created_at=clip.created_at,
    )


def prepare_conditional_response(
    request: Request,
    payload: object,
    *,
    exclude_keys: set[str] | None = None,
) -> tuple[str, bool]:
    etag = build_etag(payload, exclude_keys=exclude_keys)
    return etag, is_matching_etag(request, etag)


def can_manage_asset(session: UserSession, asset: AudioAsset) -> bool:
    if session.is_admin:
        return True
    return (
        session.external_team_id is not None
        and session.external_player_id is not None
        and asset.external_team_id == session.external_team_id
        and asset.owner_external_player_id == session.external_player_id
    )


def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int:
    highest_sort_order = db.scalar(
        select(func.max(AudioClip.sort_order))
        .join(AudioClip.asset)
        .where(
            AudioAsset.external_team_id == external_team_id,
            AudioAsset.owner_external_player_id == owner_external_player_id,
        )
    )
    return highest_sort_order + 1 if highest_sort_order is not None else 0


def create_asset_with_default_clip(
    *,
    db: Session,
    session: UserSession,
    external_team_id: str,
    owner_external_player_id: str,
    title: str,
    original_filename: str,
    mime_type: str,
    size_bytes: int,
    storage_path: str,
) -> AudioAssetResponse:
    normalized_path: str | None = None
    try:
        asset = AudioAsset(
            external_team_id=external_team_id,
            owner_external_player_id=owner_external_player_id,
            uploaded_by_session_id=session.id,
            title=title,
            original_filename=original_filename,
            mime_type=mime_type,
            size_bytes=size_bytes,
            storage_path=storage_path,
        )
        db.add(asset)
        db.flush()

        clip = AudioClip(
            asset_id=asset.id,
            label=asset.title,
            start_ms=0,
            end_ms=DEFAULT_CLIP_LENGTH_MS,
            sort_order=next_clip_sort_order(
                db,
                external_team_id=external_team_id,
                owner_external_player_id=owner_external_player_id,
            ),
            normalization_status="processing",
        )
        db.add(clip)
        db.flush()

        normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
        normalized_path = str(Path("normalized") / normalized_name)
        normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
        clip.normalized_path = normalized_path
        clip.normalization_status = "ready"
        storage.generate_waveform(asset.storage_path)

        db.commit()
        db.refresh(asset)
        return AudioAssetResponse.model_validate(asset, from_attributes=True)
    except Exception:
        db.rollback()
        if normalized_path:
            storage.delete_relative_path(normalized_path)
        storage.delete_relative_path(storage_path)
        raise


def download_media_to_storage(url: str) -> tuple[str, int, str, str]:
    try:
        from yt_dlp import YoutubeDL
        from yt_dlp.utils import DownloadError
    except ImportError as exc:  # pragma: no cover - guarded by dependency install
        raise HTTPException(status_code=500, detail="yt-dlp is not installed") from exc

    storage_name = f"{secrets.token_hex(16)}.%(ext)s"
    outtmpl = str(storage.uploads_dir / storage_name)
    options = {
        "format": "bestaudio/best",
        "noplaylist": True,
        "quiet": True,
        "no_warnings": True,
        "outtmpl": outtmpl,
        "restrictfilenames": True,
        "extractor_args": {"youtube": {"player_client": ["android"]}},
    }
    node_path = shutil.which("node")
    if node_path:
        options["js_runtimes"] = {"node": {"path": node_path}}

    try:
        with YoutubeDL(options) as ydl:
            info = ydl.extract_info(url, download=True)
            downloaded_path = Path(ydl.prepare_filename(info))
    except DownloadError as exc:  # pragma: no cover - exercised via HTTP behavior
        message = str(exc).strip() or "Could not download media from that URL"
        raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
    except Exception as exc:  # pragma: no cover - exercised via HTTP behavior
        message = str(exc).strip() or "Could not download media from that URL"
        raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc

    if not downloaded_path.exists():
        raise HTTPException(status_code=502, detail="Downloaded file was not created")

    size_bytes = downloaded_path.stat().st_size
    original_filename = downloaded_path.name
    source_title = str(info.get("title") or downloaded_path.stem)
    return str(downloaded_path.relative_to(storage.root)), size_bytes, original_filename, source_title


@router.post("/uploads", response_model=AudioAssetResponse)
async def upload_audio(
    external_team_id: str = Form(...),
    owner_external_player_id: str = Form(...),
    title: str = Form(...),
    file: UploadFile = File(...),
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> AudioAssetResponse:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=external_team_id,
        requested_player_id=owner_external_player_id,
        require_player=True,
    )
    extension = Path(file.filename or "upload.bin").suffix or ".bin"
    storage_name = f"{secrets.token_hex(16)}{extension}"
    relative_path, size = storage.save_upload(file, storage_name)
    return create_asset_with_default_clip(
        db=db,
        session=session,
        external_team_id=external_team_id,
        owner_external_player_id=owner_external_player_id,
        title=title,
        original_filename=file.filename or storage_name,
        mime_type=file.content_type or "application/octet-stream",
        size_bytes=size,
        storage_path=relative_path,
    )


@router.post("/imports", response_model=AudioAssetResponse)
def import_audio(
    payload: AudioAssetImportCreate,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> AudioAssetResponse:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=payload.external_team_id,
        requested_player_id=payload.owner_external_player_id,
        require_player=True,
    )
    relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url)
    title = payload.title.strip() if payload.title else ""
    if not title:
        title = source_title

    return create_asset_with_default_clip(
        db=db,
        session=session,
        external_team_id=payload.external_team_id,
        owner_external_player_id=payload.owner_external_player_id,
        title=title,
        original_filename=original_filename,
        mime_type="application/octet-stream",
        size_bytes=size_bytes,
        storage_path=relative_path,
    )


@router.get("/assets", response_model=list[AudioAssetResponse])
def list_assets(
    request: Request,
    response: Response,
    external_team_id: str,
    owner_external_player_id: str | None = None,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> list[AudioAssetResponse]:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=external_team_id,
        requested_player_id=owner_external_player_id,
    )
    query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id)
    if owner_external_player_id:
        query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
    assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all()
    payload = [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets]
    etag, not_modified = prepare_conditional_response(request, payload)
    set_private_revalidate(response, etag=etag)
    if not_modified:
        return Response(status_code=304, headers=dict(response.headers))
    return payload


@router.delete("/assets/{asset_id}", status_code=204)
def delete_asset(
    asset_id: int,
    owner_external_player_id: str | None = None,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> None:
    asset = db.get(AudioAsset, asset_id)
    if asset is None:
        raise HTTPException(status_code=404, detail="Asset not found")
    if not can_manage_asset(session, asset):
        raise HTTPException(status_code=403, detail="You can only delete your own uploads")

    clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
    clip_ids = [clip.id for clip in clips]
    if clip_ids:
        db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids)))
    for clip in clips:
        if clip.normalized_path:
            storage.delete_relative_path(clip.normalized_path)
        db.delete(clip)

    storage.delete_relative_path(asset.storage_path)
    db.delete(asset)
    db.commit()


@router.patch("/assets/{asset_id}", response_model=AudioAssetResponse)
def update_asset(
    asset_id: int,
    payload: AudioAssetUpdate,
    owner_external_player_id: str | None = None,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> AudioAssetResponse:
    asset = db.get(AudioAsset, asset_id)
    if asset is None:
        raise HTTPException(status_code=404, detail="Asset not found")
    if not can_manage_asset(session, asset):
        raise HTTPException(status_code=403, detail="You can only update your own uploads")

    title = payload.title.strip()
    if not title:
        raise HTTPException(status_code=422, detail="File name cannot be blank")

    asset.title = title
    db.commit()
    db.refresh(asset)
    return AudioAssetResponse.model_validate(asset, from_attributes=True)


@router.post("/clips", response_model=AudioClipResponse)
def create_clip(
    payload: AudioClipCreate,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> AudioClipResponse:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=payload.external_team_id,
        requested_player_id=payload.owner_external_player_id,
        require_player=True,
    )
    asset = db.get(AudioAsset, payload.asset_id)
    if asset is None:
        raise HTTPException(status_code=404, detail="Asset not found")
    if asset.external_team_id != external_team_id:
        raise HTTPException(status_code=422, detail="Clip does not belong to this team")
    if asset.owner_external_player_id != owner_external_player_id:
        raise HTTPException(status_code=403, detail="You can only create clips for that player")
    if payload.end_ms <= payload.start_ms:
        raise HTTPException(status_code=422, detail="Clip end must be greater than start")

    clip = AudioClip(
        asset_id=asset.id,
        label=payload.label,
        start_ms=payload.start_ms,
        end_ms=payload.end_ms,
        sort_order=next_clip_sort_order(
            db,
            external_team_id=external_team_id,
            owner_external_player_id=owner_external_player_id,
        ),
        normalization_status="processing",
    )
    db.add(clip)
    db.flush()

    normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
    clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
    clip.normalization_status = "ready"
    db.commit()
    db.refresh(clip)
    return clip_to_response(clip)


@router.patch("/clips/{clip_id}", response_model=AudioClipResponse)
def update_clip(
    clip_id: int,
    payload: AudioClipUpdate,
    owner_external_player_id: str | None = None,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> AudioClipResponse:
    clip = db.get(AudioClip, clip_id)
    if clip is None:
        raise HTTPException(status_code=404, detail="Clip not found")
    if not can_manage_asset(session, clip.asset):
        raise HTTPException(status_code=403, detail="You can only update clips from your own uploads")
    if payload.end_ms <= payload.start_ms:
        raise HTTPException(status_code=422, detail="Clip end must be greater than start")

    clip.label = payload.label or clip.label
    clip.start_ms = payload.start_ms
    clip.end_ms = payload.end_ms
    if payload.sort_order is not None:
        clip.sort_order = payload.sort_order
    if payload.hidden is not None:
        clip.hidden = payload.hidden
    db.commit()
    db.refresh(clip)
    return clip_to_response(clip)


@router.delete("/clips/{clip_id}", status_code=204)
def delete_clip(
    clip_id: int,
    owner_external_player_id: str | None = None,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> None:
    clip = db.get(AudioClip, clip_id)
    if clip is None:
        raise HTTPException(status_code=404, detail="Clip not found")
    if not can_manage_asset(session, clip.asset):
        raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")

    db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
    if clip.normalized_path:
        storage.delete_relative_path(clip.normalized_path)
    db.delete(clip)
    db.commit()


@router.get("/clips", response_model=list[AudioClipResponse])
def list_clips(
    request: Request,
    response: Response,
    external_team_id: str,
    owner_external_player_id: str | None = None,
    include_hidden: bool = False,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> list[AudioClipResponse]:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=external_team_id,
        requested_player_id=owner_external_player_id,
    )
    query = (
        select(AudioClip)
        .join(AudioClip.asset)
        .where(AudioAsset.external_team_id == external_team_id)
        .order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc())
    )
    if owner_external_player_id:
        query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
    if not include_hidden:
        query = query.where(AudioClip.hidden.is_(False))
    clips = db.scalars(query).all()
    payload = [clip_to_response(clip) for clip in clips]
    etag, not_modified = prepare_conditional_response(request, payload)
    set_private_revalidate(response, etag=etag)
    if not_modified:
        return Response(status_code=304, headers=dict(response.headers))
    return payload


@router.post("/clips/reorder", status_code=204)
def reorder_clips(
    payload: AudioClipReorder,
    session: UserSession = Depends(require_session),
    db: Session = Depends(get_db),
) -> None:
    external_team_id, owner_external_player_id = resolve_media_scope(
        session,
        requested_team_id=payload.external_team_id,
        requested_player_id=payload.owner_external_player_id,
        require_player=True,
    )

    clips = db.scalars(
        select(AudioClip)
        .join(AudioClip.asset)
        .where(
            AudioAsset.external_team_id == external_team_id,
            AudioAsset.owner_external_player_id == owner_external_player_id,
        )
    ).all()
    clips_by_id = {clip.id: clip for clip in clips}
    if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids):
        raise HTTPException(status_code=422, detail="Clip order must include every clip for that player")

    for sort_order, clip_id in enumerate(payload.clip_ids):
        clips_by_id[clip_id].sort_order = sort_order

    db.commit()


@router.get("/files/{relative_path:path}")
def media_file(relative_path: str) -> FileResponse:
    path = storage.absolute_path(relative_path)
    if not path.exists():
        raise HTTPException(status_code=404, detail="File not found")
    stat = path.stat()
    if path.is_relative_to(storage.normalized_dir):
        etag = build_etag({"path": str(path.relative_to(storage.root)), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns})
        response = FileResponse(
            path,
            stat_result=stat,
        )
        set_public_immutable(response, etag=etag)
        return response

    response = FileResponse(path, stat_result=stat)
    set_no_store(response)
    return response
