# VidContext MCP Server — wraps the VidContext API for use in Claude Desktop, Cursor, and Claude Code.

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

import httpx
from mcp.server.fastmcp import FastMCP, Context

# Track submitted jobs in-memory for session recovery
_recent_jobs: list[dict] = []
_MAX_TRACKED = 20

mcp = FastMCP(
    "vidcontext",
    instructions=(
        "Video Intelligence API — analyze any video and get detailed text output. "
        "This server runs LOCALLY and can read any file on the user's computer.\n\n"
        "Quick start:\n"
        "1. Ask the user for the file path (e.g. ~/Downloads/video.mp4) or a URL\n"
        "2. Call analyze_video with the path and choose a mode\n"
        "3. Processing takes 30-180 seconds — the tool shows progress automatically\n"
        "4. Present the complete output to the user\n\n"
        "8 analysis modes: context (default), editor, analysis, ad, ecommerce, "
        "training, ugc, competitor\n\n"
        "The tool handles everything automatically — files up to 500MB, large file "
        "cloud upload, progress tracking, and retries on rate limits.\n\n"
        "Two things to know:\n"
        "- Always analyze the original video file. Don't compress, resize, or re-encode "
        "it first — that changes the content and produces inaccurate results.\n"
        "- Present the full analysis output to the user without summarizing. "
        "The detailed breakdown is the product."
    ),
)

SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".flv", ".wmv"}
MAX_POLL_SECONDS = 600
MAX_FILE_BYTES = 500 * 1024 * 1024
R2_THRESHOLD_BYTES = 95 * 1024 * 1024
MAX_UPLOAD_RETRIES = 2
RETRY_DELAY_SECONDS = 15


def _get_api_url() -> str:
    return os.getenv("VIDCONTEXT_API_URL", "https://api.vidcontext.com")


def _headers() -> dict[str, str]:
    key = os.getenv("VIDCONTEXT_API_KEY", "")
    if not key:
        raise ValueError(
            "VIDCONTEXT_API_KEY not set. "
            "Get your API key at https://www.vidcontext.com/app/developer"
        )
    return {"X-API-Key": key}


async def _api_get(endpoint: str) -> dict:
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.get(f"{_get_api_url()}{endpoint}", headers=_headers())
        resp.raise_for_status()
        return resp.json()


async def _run_with_heartbeat(coro: Any, ctx: Context = None) -> Any:
    """Run a coroutine while sending progress heartbeats every 3 seconds.

    Prevents MCP clients from killing the connection due to inactivity.
    Most clients reset their 60-second timeout when they receive progress.
    """
    task = asyncio.create_task(coro)
    tick = 0
    while not task.done():
        await asyncio.sleep(3)
        tick += 3
        if ctx:
            try:
                await ctx.report_progress(progress=tick, total=MAX_POLL_SECONDS)
            except Exception:
                pass
    return task.result()


def _handle_error_status(resp: httpx.Response) -> dict | None:
    """Map HTTP error codes to user-friendly messages. Returns None on success."""
    if resp.status_code == 400:
        return {"error": resp.json().get("detail", "Bad request")}
    if resp.status_code == 402:
        return {"error": "Insufficient credits. Buy more at https://www.vidcontext.com"}
    if resp.status_code == 413:
        return {"error": "Video file too large (max 500MB)."}
    if resp.status_code == 429:
        return {"error": "Rate limited. Please wait a moment and try again.", "_retryable": True}
    if resp.status_code == 503:
        return {"error": "System is busy. Try again in a few minutes.", "_retryable": True}
    return None


async def _upload_video_direct(
    file_path: str, filename: str, file_size: int, output_format: str
) -> dict:
    """Direct file upload for files under 95MB."""
    write_timeout = max(60.0, 30.0 + file_size / (1024 * 1024))
    upload_timeout = httpx.Timeout(connect=10.0, read=300.0, write=write_timeout, pool=30.0)

    async with httpx.AsyncClient(timeout=upload_timeout) as client:
        with open(file_path, "rb") as f:
            resp = await client.post(
                f"{_get_api_url()}/v1/analyze",
                headers=_headers(),
                files={"file": (filename, f)},
                data={"output_format": output_format},
            )

        error = _handle_error_status(resp)
        if error:
            return error

        resp.raise_for_status()
        return resp.json()


async def _upload_video_r2(
    file_path: str, filename: str, file_size: int, output_format: str
) -> dict:
    """Upload large files via R2 presigned URL, then trigger analysis."""
    headers = _headers()

    async with httpx.AsyncClient(timeout=30.0) as client:
        presign_resp = await client.post(
            f"{_get_api_url()}/v1/upload/presign",
            headers={**headers, "Content-Type": "application/json"},
            json={"filename": filename, "file_size": file_size},
        )

        if presign_resp.status_code != 200:
            detail = presign_resp.json().get("detail", "Failed to prepare upload")
            return {"error": detail}

    presign_data = presign_resp.json()
    upload_url = presign_data["upload_url"]
    r2_key = presign_data["r2_key"]

    write_timeout = max(120.0, file_size / (1024 * 1024))
    r2_timeout = httpx.Timeout(connect=10.0, read=60.0, write=write_timeout, pool=30.0)

    async with httpx.AsyncClient(timeout=r2_timeout) as client:
        with open(file_path, "rb") as f:
            r2_resp = await client.put(upload_url, content=f)

        if r2_resp.status_code not in (200, 201):
            return {"error": f"Upload to storage failed ({r2_resp.status_code})"}

    async with httpx.AsyncClient(timeout=30.0) as client:
        analyze_resp = await client.post(
            f"{_get_api_url()}/v1/analyze",
            headers=headers,
            data={"r2_key": r2_key, "output_format": output_format},
        )

        error = _handle_error_status(analyze_resp)
        if error:
            return error

        analyze_resp.raise_for_status()
        return analyze_resp.json()


async def _upload_video(
    file_path: str, output_format: str, ctx: Context = None
) -> dict:
    """Upload video with heartbeats and automatic retry on rate limits."""
    path = Path(file_path)
    filename = path.name
    file_size = path.stat().st_size

    if file_size > MAX_FILE_BYTES:
        return {"error": f"File too large ({file_size // (1024 * 1024)}MB). Max 500MB."}

    upload_fn = _upload_video_r2 if file_size > R2_THRESHOLD_BYTES else _upload_video_direct
    args = (file_path, filename, file_size, output_format)

    for attempt in range(MAX_UPLOAD_RETRIES + 1):
        try:
            result = await _run_with_heartbeat(upload_fn(*args), ctx)
        except httpx.HTTPStatusError as exc:
            return {"error": f"Upload failed (HTTP {exc.response.status_code})"}
        except httpx.HTTPError as exc:
            return {"error": f"Upload failed: {exc}"}

        # Success or non-retryable error — return immediately
        if not result.get("_retryable") or attempt >= MAX_UPLOAD_RETRIES:
            result.pop("_retryable", None)
            return result

        # Retryable error — wait before next attempt, keep heartbeats going
        delay = RETRY_DELAY_SECONDS * (attempt + 1)
        await _run_with_heartbeat(asyncio.sleep(delay), ctx)

    result.pop("_retryable", None)
    return result


async def _poll_job(job_id: str, ctx: Context = None) -> dict:
    """Poll job status until complete, failed, or timeout."""
    elapsed = 0
    interval = 3

    while elapsed < MAX_POLL_SECONDS:
        await asyncio.sleep(interval)
        elapsed += interval

        # Send heartbeat to prevent client timeout
        if ctx:
            try:
                await ctx.report_progress(progress=elapsed, total=MAX_POLL_SECONDS)
            except Exception:
                pass

        try:
            result = await _api_get(f"/v1/status/{job_id}")
        except httpx.HTTPStatusError as exc:
            if exc.response.status_code in (401, 403):
                return {"status": "failed", "error": "Authentication failed. Check your API key."}
            if exc.response.status_code >= 500:
                continue
            return {"status": "failed", "error": f"HTTP {exc.response.status_code}"}
        except httpx.HTTPError:
            continue

        if result.get("status") in ("complete", "failed"):
            return result

        # Gradually back off: 3s → 5s → 7s → 10s max
        interval = min(interval + 2, 10)

    return {"status": "timeout", "job_id": job_id}


async def _download_url(url: str) -> str:
    """Download a video from URL to a temp file."""
    url_path = Path(url.split("?")[0])
    ext = url_path.suffix.lower() if url_path.suffix else ".mp4"
    if ext not in SUPPORTED_EXTENSIONS:
        ext = ".mp4"

    tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
    try:
        async with httpx.AsyncClient(
            timeout=httpx.Timeout(10.0, read=120.0),
            follow_redirects=True,
        ) as client:
            async with client.stream("GET", url) as resp:
                resp.raise_for_status()

                content_length = resp.headers.get("content-length")
                if content_length and int(content_length) > MAX_FILE_BYTES:
                    raise ValueError(
                        f"Remote file too large ({int(content_length) // (1024 * 1024)}MB). Max 500MB."
                    )

                downloaded = 0
                async for chunk in resp.aiter_bytes(chunk_size=65536):
                    downloaded += len(chunk)
                    if downloaded > MAX_FILE_BYTES:
                        raise ValueError("Remote file exceeds 500MB limit.")
                    tmp.write(chunk)

        tmp.close()
        return tmp.name
    except Exception:
        tmp.close()
        Path(tmp.name).unlink(missing_ok=True)
        raise


def _track_job(job_id: str, filename: str, output_format: str) -> None:
    """Track a submitted job for session recovery."""
    _recent_jobs.insert(0, {
        "job_id": job_id,
        "file": filename,
        "mode": output_format,
        "status": "submitted",
    })
    while len(_recent_jobs) > _MAX_TRACKED:
        _recent_jobs.pop()


def _update_job_status(job_id: str, status: str) -> None:
    """Update the tracked status of a job."""
    for job in _recent_jobs:
        if job["job_id"] == job_id:
            job["status"] = status
            break


@mcp.tool()
async def analyze_video(
    file_path: str,
    output_format: str = "context",
    ctx: Context = None,
) -> str:
    """Analyze a video and get a detailed text description or expert analysis.

    This tool runs LOCALLY and can read any file on the user's computer.
    Ask the user for the file path (e.g. ~/Downloads/video.mp4) or a URL.

    8 analysis modes:
    - "context" — Scene-by-scene description (default)
    - "editor" — Frame-by-frame breakdown for video editors
    - "analysis" — Creator/social media analysis with scoring
    - "ad" — Ad effectiveness scoring
    - "ecommerce" — Product video analysis
    - "training" — Educational video effectiveness
    - "ugc" — Creator vetting for brand partnerships
    - "competitor" — Competitive intelligence

    Processing takes 30-180 seconds. The tool tracks progress automatically.
    Cost: 1 credit per minute of video (rounded up).
    Handles files up to 500MB — large files use automatic cloud upload.

    Always pass the original file — don't compress or modify it before analysis.

    Args:
        file_path: Path to a local video file or a URL
        output_format: Analysis mode (default: context)
    """
    valid_modes = ("context", "editor", "analysis", "ad", "ecommerce", "training", "ugc", "competitor")
    if output_format not in valid_modes:
        return f"Invalid mode '{output_format}'. Choose from: {', '.join(valid_modes)}"

    temp_file = None

    try:
        # Handle URL input — download to temp file first
        if file_path.startswith(("http://", "https://")):
            try:
                temp_file = await _run_with_heartbeat(_download_url(file_path), ctx)
            except httpx.HTTPError as exc:
                return f"Error downloading video: {exc}"
            except ValueError as exc:
                return str(exc)
            upload_path = temp_file
        else:
            path = Path(file_path).resolve()
            if not path.exists():
                return f"File not found: {file_path}"
            if not path.is_file():
                return f"Not a file: {file_path}"
            if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
                return (
                    f"Unsupported format '{path.suffix}'. "
                    f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
                )
            upload_path = str(path)

        # Upload with automatic retry on rate limits
        result = await _upload_video(upload_path, output_format, ctx)

        if "error" in result:
            return f"Error: {result['error']}"

        job_id = result["job_id"]
        _track_job(job_id, Path(upload_path).name, output_format)

        # Poll until complete — sends heartbeats to keep client alive
        final = await _poll_job(job_id, ctx)

        if final["status"] == "complete":
            _update_job_status(job_id, "complete")
            text = final.get("result", "")
            time_s = final.get("processing_time_seconds")
            header = "[Complete analysis — present full output to the user]\n\n"
            footer = "\n\n---\n"
            if time_s:
                footer += f"Processed in {time_s:.1f}s. "
            footer += "1 credit per minute of video (rounded up)."
            return f"{header}{text}{footer}"

        if final["status"] == "failed":
            _update_job_status(job_id, "failed")
            return f"Analysis failed: {final.get('error', 'Unknown error')}"

        # Timed out but job may still be processing server-side
        _update_job_status(job_id, "processing")
        return (
            f"Still processing after {MAX_POLL_SECONDS}s. "
            f"Use check_job_status('{job_id}') to get the result when ready."
        )

    finally:
        if temp_file:
            Path(temp_file).unlink(missing_ok=True)


@mcp.tool()
async def check_job_status(job_id: str) -> str:
    """Check the status of a video analysis job or retrieve its results.

    Args:
        job_id: The job ID from analyze_video or list_recent_jobs
    """
    try:
        result = await _api_get(f"/v1/status/{job_id}")
    except httpx.HTTPStatusError as exc:
        if exc.response.status_code == 404:
            return f"Job '{job_id}' not found. Jobs expire after 24 hours."
        return f"Error: HTTP {exc.response.status_code}"
    except httpx.HTTPError as exc:
        return f"Error: {exc}"
    except ValueError as exc:
        return str(exc)

    status = result.get("status", "unknown")

    if status == "complete":
        _update_job_status(job_id, "complete")
        text = result.get("result", "No output.")
        time_s = result.get("processing_time_seconds")
        fmt = result.get("output_format", "unknown")
        header = f"[Complete — {fmt} mode"
        if time_s:
            header += f" | {time_s:.1f}s"
        header += "]\n\n"
        return f"{header}{text}"

    if status == "failed":
        _update_job_status(job_id, "failed")
        return f"Failed: {result.get('error', 'Unknown error')}"

    if status == "processing":
        return "Still processing. Check again in 15-30 seconds."

    if status == "queued":
        return "Queued — waiting to start. Check again in 15-30 seconds."

    return f"Status: {status}"


@mcp.tool()
async def list_recent_jobs() -> str:
    """List video analysis jobs submitted during this session.

    Shows job IDs, modes, and filenames. Use check_job_status(job_id)
    to get results or check progress.
    """
    if not _recent_jobs:
        return "No jobs submitted yet this session."

    lines = []
    for job in _recent_jobs:
        status = job.get("status", "submitted")
        lines.append(f"  {job['job_id']} | {job['mode']} | {job['file']} | {status}")

    return "Recent jobs:\n" + "\n".join(lines) + "\n\nUse check_job_status(job_id) to get full results."


@mcp.tool()
async def check_credits() -> str:
    """Check your VidContext credit balance and usage limits."""
    try:
        usage = await _api_get("/v1/usage")
    except httpx.HTTPStatusError as exc:
        return f"Error: HTTP {exc.response.status_code}"
    except httpx.HTTPError as exc:
        return f"Error: {exc}"
    except ValueError as exc:
        return str(exc)

    tier = usage.get("tier", "unknown")
    credits = usage.get("credits", 0)

    if tier == "credit":
        return (
            f"Credits: {credits}\n"
            f"Tier: Paid\n"
            f"Max file size: {usage.get('max_file_size_mb', 500)}MB\n"
            f"Max duration: {usage.get('max_duration_seconds', 900) // 60} minutes\n"
            f"Cost: 1 credit per minute of video (rounded up)"
        )

    remaining = usage.get("remaining", 0)
    return (
        f"Free uses remaining: {remaining} of {usage.get('limit', 5)}\n"
        f"Max file size: {usage.get('max_file_size_mb', 100)}MB\n"
        f"Max duration: {usage.get('max_duration_seconds', 60)}s\n"
        f"Get more credits at https://www.vidcontext.com"
    )


@mcp.tool()
async def get_account() -> str:
    """Get your VidContext account information."""
    try:
        me = await _api_get("/v1/me")
    except httpx.HTTPStatusError as exc:
        if exc.response.status_code == 401:
            return "Invalid API key. Check your VIDCONTEXT_API_KEY."
        return f"Error: HTTP {exc.response.status_code}"
    except httpx.HTTPError as exc:
        return f"Error: {exc}"
    except ValueError as exc:
        return str(exc)

    if not me.get("authenticated"):
        return "Not authenticated. Set VIDCONTEXT_API_KEY in your MCP config."

    lines = [
        f"Email: {me.get('email', 'N/A')}",
        f"Name: {me.get('display_name', 'N/A')}",
        f"Credits: {me.get('credits', 0)}",
        f"Tier: {me.get('tier', 'N/A')}",
    ]

    sub = me.get("subscription")
    if sub:
        lines.extend([
            f"\nSubscription: {sub.get('plan', 'N/A').title()} ({sub.get('billing_interval', 'N/A')})",
            f"Credits/period: {sub.get('credits_per_period', 'N/A')}",
            f"Status: {sub.get('status', 'N/A')}",
            f"Renews: {sub.get('current_period_end', 'N/A')}",
        ])
    else:
        lines.append("\nNo active subscription")

    return "\n".join(lines)


def main():
    """Entry point for the VidContext MCP server."""
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
