"""FastAPI server for the Harbor Viewer."""

import json
import math
import shutil
from pathlib import Path
from typing import Any, TypedDict

from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from harbor.models.job.config import (
    JobConfig,
    LocalDatasetConfig,
    RegistryDatasetConfig,
)
from harbor.models.trial.result import TrialResult
from harbor.viewer.models import (
    ComparisonAgentModel,
    ComparisonCell,
    ComparisonGridData,
    ComparisonTask,
    EvalSummary,
    FileInfo,
    FilterOption,
    JobFilters,
    JobSummary,
    PaginatedResponse,
    TaskFilters,
    TaskSummary,
    TrialSummary,
)
from harbor.viewer.scanner import JobScanner


class SummarizeRequest(BaseModel):
    """Request body for job summarization."""

    model: str = "haiku"
    n_concurrent: int = 32
    only_failed: bool = True
    overwrite: bool = False


class TrialSummarizeRequest(BaseModel):
    """Request body for single trial summarization."""

    model: str = "haiku"


class TaskGroupStats(TypedDict):
    """Stats accumulated for a task group."""

    n_trials: int
    n_completed: int
    n_errors: int
    exception_types: set[str]
    total_reward: float
    reward_count: int
    total_duration_ms: float
    duration_count: int


# Maximum file size to serve (1MB)
MAX_FILE_SIZE = 1024 * 1024


def create_app(jobs_dir: Path, static_dir: Path | None = None) -> FastAPI:
    """Create the FastAPI application with routes configured for the given jobs directory.

    Args:
        jobs_dir: Directory containing job/trial data
        static_dir: Optional directory containing static viewer files (index.html, assets/)
    """
    app = FastAPI(
        title="Harbor Viewer",
        description="API for browsing Harbor jobs and trials",
        version="0.1.0",
    )

    # Allow CORS for local development
    app.add_middleware(
        CORSMiddleware,  # type: ignore[arg-type]
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    scanner = JobScanner(jobs_dir)

    def _get_all_job_summaries() -> list[JobSummary]:
        """Get all job summaries (used by both list_jobs and get_job_filters)."""
        job_names = scanner.list_jobs()
        summaries = []

        for name in job_names:
            result = scanner.get_job_result(name)
            config = scanner.get_job_config(name)

            # Extract unique agents, providers, models, datasets, and environment type from config
            agents: list[str] = []
            providers: list[str] = []
            models: list[str] = []
            datasets: list[str] = []
            environment_type: str | None = None
            if config:
                agents = sorted(
                    set(agent.name for agent in config.agents if agent.name is not None)
                )
                # Extract dataset names
                for ds in config.datasets:
                    if isinstance(ds, RegistryDatasetConfig):
                        datasets.append(ds.name)
                    elif isinstance(ds, LocalDatasetConfig):
                        datasets.append(ds.path.name)
                datasets = sorted(set(datasets))
                # Extract provider from model_name (format: "provider/model")
                for agent in config.agents:
                    if agent.model_name:
                        parts = agent.model_name.split("/", 1)
                        if len(parts) == 2:
                            providers.append(parts[0])
                            models.append(parts[1])
                        else:
                            models.append(agent.model_name)
                providers = sorted(set(providers))
                models = sorted(set(models))
                if config.environment.type:
                    environment_type = config.environment.type.value

            if result:
                # Extract evals from stats
                evals = {
                    key: EvalSummary(metrics=eval_stats.metrics)
                    for key, eval_stats in result.stats.evals.items()
                    if eval_stats.metrics
                }
                summaries.append(
                    JobSummary(
                        name=name,
                        id=result.id,
                        started_at=result.started_at,
                        finished_at=result.finished_at,
                        n_total_trials=result.n_total_trials,
                        n_completed_trials=result.stats.n_trials,
                        n_errors=result.stats.n_errors,
                        datasets=datasets,
                        agents=agents,
                        providers=providers,
                        models=models,
                        environment_type=environment_type,
                        evals=evals,
                    )
                )
            else:
                summaries.append(
                    JobSummary(
                        name=name,
                        datasets=datasets,
                        agents=agents,
                        providers=providers,
                        models=models,
                        environment_type=environment_type,
                    )
                )

        # Sort by started_at descending (most recent first), jobs without started_at go last
        summaries.sort(
            key=lambda s: (s.started_at is not None, s.started_at),
            reverse=True,
        )
        return summaries

    @app.get("/api/jobs/filters", response_model=JobFilters)
    def get_job_filters() -> JobFilters:
        """Get available filter options for jobs list."""
        from collections import Counter

        summaries = _get_all_job_summaries()

        # Count occurrences of agents, providers, and models
        agent_counts: Counter[str] = Counter()
        provider_counts: Counter[str] = Counter()
        model_counts: Counter[str] = Counter()

        for summary in summaries:
            for agent in summary.agents:
                agent_counts[agent] += 1
            for provider in summary.providers:
                provider_counts[provider] += 1
            for model in summary.models:
                model_counts[model] += 1

        return JobFilters(
            agents=[
                FilterOption(value=v, count=c) for v, c in sorted(agent_counts.items())
            ],
            providers=[
                FilterOption(value=v, count=c)
                for v, c in sorted(provider_counts.items())
            ],
            models=[
                FilterOption(value=v, count=c) for v, c in sorted(model_counts.items())
            ],
        )

    @app.get("/api/jobs", response_model=PaginatedResponse[JobSummary])
    def list_jobs(
        page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
        page_size: int = Query(
            default=100, ge=1, le=100, description="Number of items per page"
        ),
        q: str | None = Query(default=None, description="Search query"),
        agent: list[str] = Query(default=[], description="Filter by agent names"),
        provider: list[str] = Query(default=[], description="Filter by provider names"),
        model: list[str] = Query(default=[], description="Filter by model names"),
        date: list[str] = Query(
            default=[],
            description="Filter by date ranges (today, week, month)",
        ),
    ) -> PaginatedResponse[JobSummary]:
        """List all jobs with summary information."""
        from datetime import datetime, timedelta

        summaries = _get_all_job_summaries()

        # Filter by search query
        if q:
            query = q.lower()
            summaries = [
                s
                for s in summaries
                if query in s.name.lower()
                or any(query in agent_name.lower() for agent_name in s.agents)
                or any(query in provider_name.lower() for provider_name in s.providers)
                or any(query in model_name.lower() for model_name in s.models)
            ]

        # Filter by agents (OR within agents)
        if agent:
            summaries = [s for s in summaries if any(a in s.agents for a in agent)]

        # Filter by providers (OR within providers)
        if provider:
            summaries = [
                s for s in summaries if any(p in s.providers for p in provider)
            ]

        # Filter by models (OR within models)
        if model:
            summaries = [s for s in summaries if any(m in s.models for m in model)]

        # Filter by date (OR within dates - use the most permissive)
        if date:
            now = datetime.now()
            cutoffs = []
            for d in date:
                if d == "today":
                    cutoffs.append(now - timedelta(days=1))
                elif d == "week":
                    cutoffs.append(now - timedelta(weeks=1))
                elif d == "month":
                    cutoffs.append(now - timedelta(days=30))

            if cutoffs:
                # Use the earliest cutoff (most permissive)
                cutoff = min(cutoffs)
                summaries = [
                    s
                    for s in summaries
                    if s.started_at is not None
                    and s.started_at.replace(tzinfo=None) >= cutoff
                ]

        # Paginate
        total = len(summaries)
        total_pages = math.ceil(total / page_size) if total > 0 else 0
        start_idx = (page - 1) * page_size
        end_idx = start_idx + page_size
        page_summaries = summaries[start_idx:end_idx]

        return PaginatedResponse(
            items=page_summaries,
            total=total,
            page=page,
            page_size=page_size,
            total_pages=total_pages,
        )

    @app.get("/api/jobs/{job_name}")
    def get_job(job_name: str) -> dict[str, Any]:
        """Get full job result details."""
        job_dir = jobs_dir / job_name
        if not job_dir.exists():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        result = scanner.get_job_result(job_name)
        if result is None:
            # Return minimal info for jobs without result.json (incomplete jobs)
            # Count trials from subdirectories
            n_trials = sum(1 for d in job_dir.iterdir() if d.is_dir())
            return {
                "id": job_name,
                "started_at": None,
                "finished_at": None,
                "n_total_trials": n_trials,
                "stats": {"n_trials": 0, "n_errors": 0},
                "job_uri": job_dir.resolve().as_uri(),
            }

        # Convert to dict and add job_uri
        result_dict = result.model_dump(mode="json")
        result_dict["job_uri"] = job_dir.resolve().as_uri()
        return result_dict

    @app.get("/api/jobs/{job_name}/summary")
    def get_job_summary(job_name: str) -> dict[str, str | None]:
        """Get job summary (summary.md file at job root)."""
        job_dir = jobs_dir / job_name
        if not job_dir.exists():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        summary_path = job_dir / "summary.md"
        if summary_path.exists():
            try:
                return {"summary": summary_path.read_text()}
            except Exception:
                return {"summary": "[Error reading file]"}
        return {"summary": None}

    @app.post("/api/jobs/{job_name}/summarize")
    async def summarize_job(
        job_name: str, request: SummarizeRequest
    ) -> dict[str, str | int | bool | None]:
        """Generate a summary for a job using Claude."""
        job_dir = jobs_dir / job_name
        if not job_dir.exists():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        # Import here to avoid loading heavy dependencies at startup
        from harbor.cli.summarize.summarizer import Summarizer

        summarizer = Summarizer(
            job_dir=job_dir,
            n_concurrent=request.n_concurrent,
            model=request.model,
            only_failed=request.only_failed,
            overwrite=request.overwrite,
        )

        result = await summarizer.summarize_async()

        # Read and return the generated summary with counts
        summary_path = job_dir / "summary.md"
        summary_content: str | None = None
        if summary_path.exists():
            try:
                summary_content = summary_path.read_text()
            except Exception:
                summary_content = "[Error reading file]"

        return {
            "summary": summary_content,
            "n_trials_summarized": result.n_trials_summarized,
            "job_summary_created": result.job_summary_created,
        }

    @app.delete("/api/jobs/{job_name}")
    def delete_job(job_name: str) -> dict[str, str]:
        """Delete a job and all its trials."""
        job_dir = jobs_dir / job_name
        if not job_dir.exists():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        try:
            shutil.rmtree(job_dir)
            return {"status": "ok", "message": f"Job '{job_name}' deleted"}
        except Exception as e:
            raise HTTPException(
                status_code=500, detail=f"Failed to delete job: {str(e)}"
            )

    @app.get("/api/compare", response_model=ComparisonGridData)
    def get_comparison_data(
        job: list[str] = Query(..., description="Job names to compare"),
    ) -> ComparisonGridData:
        """Get comparison grid data for multiple jobs."""
        # Validate all jobs exist
        existing_jobs = scanner.list_jobs()
        for job_name in job:
            if job_name not in existing_jobs:
                raise HTTPException(
                    status_code=404, detail=f"Job '{job_name}' not found"
                )

        # Collect all task summaries from all jobs
        # Group by (source, task_name) for tasks and (job_name, agent_name, model_provider, model_name) for agent_models
        tasks_set: set[tuple[str | None, str]] = set()
        agent_models_set: set[tuple[str, str | None, str | None, str | None]] = set()
        # cells[task_key][am_key] = ComparisonCell
        cells: dict[str, dict[str, ComparisonCell]] = {}

        for job_name in job:
            summaries = _get_all_task_summaries(job_name)
            for summary in summaries:
                task_key = f"{summary.source or ''}::{summary.task_name}"
                am_key = f"{job_name}::{summary.agent_name or ''}::{summary.model_provider or ''}::{summary.model_name or ''}"

                tasks_set.add((summary.source, summary.task_name))
                agent_models_set.add(
                    (
                        job_name,
                        summary.agent_name,
                        summary.model_provider,
                        summary.model_name,
                    )
                )

                if task_key not in cells:
                    cells[task_key] = {}

                cells[task_key][am_key] = ComparisonCell(
                    job_name=job_name,
                    avg_reward=summary.avg_reward,
                    avg_duration_ms=summary.avg_duration_ms,
                    n_trials=summary.n_trials,
                    n_completed=summary.n_completed,
                )

        # Calculate average reward per task (across all agent_models)
        task_avg_rewards: dict[str, float] = {}
        for source, task_name in tasks_set:
            task_key = f"{source or ''}::{task_name}"
            task_cells = cells.get(task_key, {})
            if task_cells:
                rewards = [cell.avg_reward or 0.0 for cell in task_cells.values()]
                task_avg_rewards[task_key] = sum(rewards) / len(rewards)
            else:
                task_avg_rewards[task_key] = 0.0

        # Build task list sorted by average reward (high to low), then alphabetically
        tasks = sorted(
            [
                ComparisonTask(
                    source=source,
                    task_name=task_name,
                    key=f"{source or ''}::{task_name}",
                )
                for source, task_name in tasks_set
            ],
            key=lambda t: (
                -task_avg_rewards.get(t.key, 0.0),
                t.source or "",
                t.task_name,
            ),
        )

        # Calculate average reward per agent_model (across all tasks)
        am_avg_rewards: dict[str, float] = {}
        for job_name, agent_name, model_provider, model_name in agent_models_set:
            am_key = f"{job_name}::{agent_name or ''}::{model_provider or ''}::{model_name or ''}"
            rewards = []
            for task_key, task_cells in cells.items():
                if am_key in task_cells:
                    rewards.append(task_cells[am_key].avg_reward or 0.0)
            am_avg_rewards[am_key] = sum(rewards) / len(rewards) if rewards else 0.0

        # Build agent_model list sorted by average reward (high to low), then alphabetically
        agent_models = sorted(
            [
                ComparisonAgentModel(
                    job_name=job_name,
                    agent_name=agent_name,
                    model_provider=model_provider,
                    model_name=model_name,
                    key=f"{job_name}::{agent_name or ''}::{model_provider or ''}::{model_name or ''}",
                )
                for job_name, agent_name, model_provider, model_name in agent_models_set
            ],
            key=lambda am: (
                -am_avg_rewards.get(am.key, 0.0),
                am.job_name,
                am.agent_name or "",
                am.model_provider or "",
                am.model_name or "",
            ),
        )

        return ComparisonGridData(
            tasks=tasks,
            agent_models=agent_models,
            cells=cells,
        )

    @app.get("/api/jobs/{job_name}/config", response_model=JobConfig)
    def get_job_config(job_name: str) -> JobConfig:
        """Get job configuration."""
        config = scanner.get_job_config(job_name)
        if not config:
            raise HTTPException(
                status_code=404, detail=f"Config for job '{job_name}' not found"
            )
        return config

    def _get_all_task_summaries(job_name: str) -> list[TaskSummary]:
        """Get all task summaries for a job (used by list_tasks and get_task_filters)."""
        trial_names = scanner.list_trials(job_name)
        if not trial_names:
            return []

        # Group trials by (agent_name, model_provider, model_name, source, task_name)
        groups: dict[
            tuple[str | None, str | None, str | None, str | None, str],
            TaskGroupStats,
        ] = {}

        for name in trial_names:
            result = scanner.get_trial_result(job_name, name)
            if not result:
                continue

            agent_name = result.agent_info.name
            model_info = result.agent_info.model_info
            model_name = model_info.name if model_info else None
            model_provider = model_info.provider if model_info else None
            source = result.source
            task_name = result.task_name

            key = (
                agent_name,
                model_provider,
                model_name,
                source,
                task_name,
            )

            if key not in groups:
                groups[key] = {
                    "n_trials": 0,
                    "n_completed": 0,
                    "n_errors": 0,
                    "exception_types": set(),
                    "total_reward": 0.0,
                    "reward_count": 0,
                    "total_duration_ms": 0.0,
                    "duration_count": 0,
                }

            groups[key]["n_trials"] += 1

            if result.finished_at:
                groups[key]["n_completed"] += 1
                if result.started_at:
                    duration_ms = (
                        result.finished_at - result.started_at
                    ).total_seconds() * 1000
                    groups[key]["total_duration_ms"] += duration_ms
                    groups[key]["duration_count"] += 1

            if result.exception_info:
                groups[key]["n_errors"] += 1
                groups[key]["exception_types"].add(result.exception_info.exception_type)

            # Get reward, defaulting to 0 if missing (evaluated but no reward)
            reward = (
                result.verifier_result.rewards.get("reward", 0)
                if result.verifier_result and result.verifier_result.rewards
                else 0
            )
            groups[key]["total_reward"] += reward
            groups[key]["reward_count"] += 1

        # Convert to TaskSummary list
        summaries = []
        for (
            agent_name,
            model_provider,
            model_name,
            source,
            task_name,
        ), stats in groups.items():
            avg_reward = (
                stats["total_reward"] / stats["reward_count"]
                if stats["reward_count"] > 0
                else 0.0
            )
            avg_duration_ms = (
                stats["total_duration_ms"] / stats["duration_count"]
                if stats["duration_count"] > 0
                else None
            )

            summaries.append(
                TaskSummary(
                    task_name=task_name,
                    source=source,
                    agent_name=agent_name,
                    model_provider=model_provider,
                    model_name=model_name,
                    n_trials=int(stats["n_trials"]),
                    n_completed=int(stats["n_completed"]),
                    n_errors=int(stats["n_errors"]),
                    exception_types=sorted(stats["exception_types"]),
                    avg_reward=avg_reward,
                    avg_duration_ms=avg_duration_ms,
                )
            )

        return summaries

    @app.get("/api/jobs/{job_name}/tasks/filters", response_model=TaskFilters)
    def get_task_filters(job_name: str) -> TaskFilters:
        """Get available filter options for tasks list within a job."""
        from collections import Counter

        if job_name not in scanner.list_jobs():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        summaries = _get_all_task_summaries(job_name)

        # Count occurrences of each filter value
        agent_counts: Counter[str] = Counter()
        provider_counts: Counter[str] = Counter()
        model_counts: Counter[str] = Counter()
        task_counts: Counter[str] = Counter()

        for summary in summaries:
            if summary.agent_name:
                agent_counts[summary.agent_name] += 1
            if summary.model_provider:
                provider_counts[summary.model_provider] += 1
            if summary.model_name:
                model_counts[summary.model_name] += 1
            if summary.task_name:
                task_counts[summary.task_name] += 1

        return TaskFilters(
            agents=[
                FilterOption(value=v, count=c) for v, c in sorted(agent_counts.items())
            ],
            providers=[
                FilterOption(value=v, count=c)
                for v, c in sorted(provider_counts.items())
            ],
            models=[
                FilterOption(value=v, count=c) for v, c in sorted(model_counts.items())
            ],
            tasks=[
                FilterOption(value=v, count=c) for v, c in sorted(task_counts.items())
            ],
        )

    @app.get(
        "/api/jobs/{job_name}/tasks", response_model=PaginatedResponse[TaskSummary]
    )
    def list_tasks(
        job_name: str,
        page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
        page_size: int = Query(
            default=100, ge=1, le=100, description="Number of items per page"
        ),
        q: str | None = Query(default=None, description="Search query"),
        agent: list[str] = Query(default=[], description="Filter by agent names"),
        provider: list[str] = Query(default=[], description="Filter by provider names"),
        model: list[str] = Query(default=[], description="Filter by model names"),
        task: list[str] = Query(default=[], description="Filter by task names"),
        sort_by: str | None = Query(
            default=None,
            description="Field to sort by (task_name, agent_name, model_provider, model_name, source, n_trials, n_errors, avg_duration_ms, avg_reward)",
        ),
        sort_order: str = Query(default="asc", description="Sort order (asc or desc)"),
    ) -> PaginatedResponse[TaskSummary]:
        """List tasks in a job, grouped by agent + model + source + task_name."""
        if job_name not in scanner.list_jobs():
            raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")

        summaries = _get_all_task_summaries(job_name)

        # Filter by search query (searches task, agent, provider, model, dataset)
        if q:
            query = q.lower()
            summaries = [
                s
                for s in summaries
                if query in s.task_name.lower()
                or (s.agent_name and query in s.agent_name.lower())
                or (s.model_provider and query in s.model_provider.lower())
                or (s.model_name and query in s.model_name.lower())
                or (s.source and query in s.source.lower())
            ]

        # Filter by agents
        if agent:
            summaries = [s for s in summaries if s.agent_name in agent]

        # Filter by providers
        if provider:
            summaries = [s for s in summaries if s.model_provider in provider]

        # Filter by models
        if model:
            summaries = [s for s in summaries if s.model_name in model]

        # Filter by task names
        if task:
            summaries = [s for s in summaries if s.task_name in task]

        # Sort
        if sort_by:
            reverse = sort_order == "desc"
            if sort_by == "task_name":
                summaries.sort(key=lambda s: s.task_name or "", reverse=reverse)
            elif sort_by == "agent_name":
                summaries.sort(key=lambda s: s.agent_name or "", reverse=reverse)
            elif sort_by == "model_provider":
                summaries.sort(key=lambda s: s.model_provider or "", reverse=reverse)
            elif sort_by == "model_name":
                summaries.sort(key=lambda s: s.model_name or "", reverse=reverse)
            elif sort_by == "source":
                summaries.sort(key=lambda s: s.source or "", reverse=reverse)
            elif sort_by == "n_trials":
                summaries.sort(key=lambda s: s.n_trials, reverse=reverse)
            elif sort_by == "n_errors":
                summaries.sort(key=lambda s: s.n_errors, reverse=reverse)
            elif sort_by == "avg_duration_ms":
                # Put None values at the end
                summaries.sort(
                    key=lambda s: (
                        s.avg_duration_ms is None,
                        s.avg_duration_ms or 0,
                    ),
                    reverse=reverse,
                )
            elif sort_by == "avg_reward":
                summaries.sort(key=lambda s: s.avg_reward or 0, reverse=reverse)
            elif sort_by == "exception_types":
                summaries.sort(
                    key=lambda s: s.exception_types[0] if s.exception_types else "",
                    reverse=reverse,
                )

        # Paginate
        total = len(summaries)
        total_pages = math.ceil(total / page_size) if total > 0 else 0
        start_idx = (page - 1) * page_size
        end_idx = start_idx + page_size
        page_summaries = summaries[start_idx:end_idx]

        return PaginatedResponse(
            items=page_summaries,
            total=total,
            page=page,
            page_size=page_size,
            total_pages=total_pages,
        )

    @app.get(
        "/api/jobs/{job_name}/trials",
        response_model=PaginatedResponse[TrialSummary],
    )
    def list_trials(
        job_name: str,
        task_name: str | None = Query(default=None, description="Filter by task name"),
        source: str | None = Query(
            default=None, description="Filter by source/dataset"
        ),
        agent_name: str | None = Query(
            default=None, description="Filter by agent name"
        ),
        model_name: str | None = Query(
            default=None, description="Filter by model name"
        ),
        page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
        page_size: int = Query(
            default=100, ge=1, le=100, description="Number of items per page"
        ),
    ) -> PaginatedResponse[TrialSummary]:
        """List trials in a job with pagination and optional filtering."""
        trial_names = scanner.list_trials(job_name)
        if not trial_names:
            if job_name not in scanner.list_jobs():
                raise HTTPException(
                    status_code=404, detail=f"Job '{job_name}' not found"
                )
            return PaginatedResponse(
                items=[], total=0, page=page, page_size=page_size, total_pages=0
            )

        # Build list of trial summaries with filtering
        all_summaries = []
        for name in trial_names:
            result = scanner.get_trial_result(job_name, name)
            if not result:
                continue

            # Apply filters
            if task_name is not None and result.task_name != task_name:
                continue
            if source is not None and result.source != source:
                continue
            if agent_name is not None and result.agent_info.name != agent_name:
                continue
            model_info = result.agent_info.model_info
            # Build full model name (provider/name) to match frontend format
            if model_info and model_info.provider:
                result_full_model_name = f"{model_info.provider}/{model_info.name}"
            elif model_info:
                result_full_model_name = model_info.name
            else:
                result_full_model_name = None
            if model_name is not None and result_full_model_name != model_name:
                continue

            # Extract primary reward if available
            reward = None
            if result.verifier_result and result.verifier_result.rewards:
                reward = result.verifier_result.rewards.get("reward")

            result_model_provider = model_info.provider if model_info else None
            result_model_name = model_info.name if model_info else None

            all_summaries.append(
                TrialSummary(
                    name=name,
                    task_name=result.task_name,
                    id=result.id,
                    source=result.source,
                    agent_name=result.agent_info.name,
                    model_provider=result_model_provider,
                    model_name=result_model_name,
                    reward=reward,
                    error_type=(
                        result.exception_info.exception_type
                        if result.exception_info
                        else None
                    ),
                    started_at=result.started_at,
                    finished_at=result.finished_at,
                )
            )

        # Paginate
        total = len(all_summaries)
        total_pages = math.ceil(total / page_size) if total > 0 else 0
        start_idx = (page - 1) * page_size
        end_idx = start_idx + page_size
        page_summaries = all_summaries[start_idx:end_idx]

        return PaginatedResponse(
            items=page_summaries,
            total=total,
            page=page,
            page_size=page_size,
            total_pages=total_pages,
        )

    @app.get("/api/jobs/{job_name}/trials/{trial_name}", response_model=TrialResult)
    def get_trial(job_name: str, trial_name: str) -> TrialResult:
        """Get full trial result details."""
        result = scanner.get_trial_result(job_name, trial_name)
        if not result:
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )
        return result

    @app.post("/api/jobs/{job_name}/trials/{trial_name}/summarize")
    async def summarize_trial(
        job_name: str, trial_name: str, request: TrialSummarizeRequest
    ) -> dict[str, str | None]:
        """Generate a summary for a single trial using Claude."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        # Import here to avoid loading heavy dependencies at startup
        from harbor.cli.summarize.summarizer import Summarizer

        summarizer = Summarizer(
            job_dir=jobs_dir / job_name,
            model=request.model,
        )

        await summarizer.summarize_trial(trial_dir)

        # Read and return the generated summary
        summary_path = trial_dir / "summary.md"
        if summary_path.exists():
            try:
                return {"summary": summary_path.read_text()}
            except Exception:
                return {"summary": "[Error reading file]"}
        return {"summary": None}

    @app.get("/api/jobs/{job_name}/trials/{trial_name}/trajectory")
    def get_trajectory(job_name: str, trial_name: str) -> dict[str, Any] | None:
        """Get trajectory.json content for a trial."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        trajectory_path = trial_dir / "agent" / "trajectory.json"
        if not trajectory_path.exists():
            return None

        try:
            return json.loads(trajectory_path.read_text())
        except json.JSONDecodeError:
            raise HTTPException(
                status_code=500, detail="Failed to parse trajectory.json"
            )

    @app.get("/api/jobs/{job_name}/trials/{trial_name}/verifier-output")
    def get_verifier_output(job_name: str, trial_name: str) -> dict[str, str | None]:
        """Get verifier output (test-stdout.txt, test-stderr.txt, and ctrf.json)."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        verifier_dir = trial_dir / "verifier"
        stdout_path = verifier_dir / "test-stdout.txt"
        stderr_path = verifier_dir / "test-stderr.txt"
        ctrf_path = verifier_dir / "ctrf.json"

        stdout = None
        stderr = None
        ctrf = None

        if stdout_path.exists():
            try:
                stdout = stdout_path.read_text()
            except Exception:
                stdout = "[Error reading file]"

        if stderr_path.exists():
            try:
                stderr = stderr_path.read_text()
            except Exception:
                stderr = "[Error reading file]"

        if ctrf_path.exists():
            try:
                ctrf = ctrf_path.read_text()
            except Exception:
                ctrf = "[Error reading file]"

        return {"stdout": stdout, "stderr": stderr, "ctrf": ctrf}

    @app.get("/api/jobs/{job_name}/trials/{trial_name}/files")
    def list_trial_files(job_name: str, trial_name: str) -> list[FileInfo]:
        """List all files in a trial directory."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        files: list[FileInfo] = []

        def scan_dir(dir_path: Path, relative_base: str = "") -> None:
            try:
                for item in sorted(dir_path.iterdir()):
                    relative_path = (
                        f"{relative_base}/{item.name}" if relative_base else item.name
                    )
                    if item.is_dir():
                        files.append(
                            FileInfo(
                                path=relative_path,
                                name=item.name,
                                is_dir=True,
                                size=None,
                            )
                        )
                        scan_dir(item, relative_path)
                    else:
                        files.append(
                            FileInfo(
                                path=relative_path,
                                name=item.name,
                                is_dir=False,
                                size=item.stat().st_size,
                            )
                        )
            except PermissionError:
                pass

        scan_dir(trial_dir)
        return files

    @app.get(
        "/api/jobs/{job_name}/trials/{trial_name}/files/{file_path:path}",
        response_model=None,
    )
    def get_trial_file(
        job_name: str, trial_name: str, file_path: str
    ) -> PlainTextResponse | FileResponse:
        """Get content of a file in a trial directory.

        For text files, returns PlainTextResponse with the content.
        For image files (png, jpg, gif, webp), returns FileResponse with appropriate media type.
        """
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        # Resolve the path and ensure it's within the trial directory (prevent traversal)
        try:
            full_path = (trial_dir / file_path).resolve()
            if not str(full_path).startswith(str(trial_dir.resolve())):
                raise HTTPException(status_code=403, detail="Access denied")
        except Exception:
            raise HTTPException(status_code=400, detail="Invalid file path")

        if not full_path.exists():
            raise HTTPException(status_code=404, detail="File not found")

        if full_path.is_dir():
            raise HTTPException(status_code=400, detail="Cannot read directory")

        def _format_size(size_bytes: int) -> str:
            """Format bytes as human-readable string."""
            if size_bytes < 1024:
                return f"{size_bytes} bytes"
            elif size_bytes < 1024 * 1024:
                return f"{size_bytes / 1024:.1f} KB"
            else:
                return f"{size_bytes / (1024 * 1024):.1f} MB"

        # Check file size
        file_size = full_path.stat().st_size
        if file_size > MAX_FILE_SIZE:
            raise HTTPException(
                status_code=413,
                detail=f"File too large: {_format_size(file_size)} (max {_format_size(MAX_FILE_SIZE)})",
            )

        # Handle image files - serve as binary with correct media type
        image_extensions = {
            ".png": "image/png",
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".gif": "image/gif",
            ".webp": "image/webp",
            ".svg": "image/svg+xml",
        }
        suffix = full_path.suffix.lower()
        if suffix in image_extensions:
            return FileResponse(
                path=full_path,
                media_type=image_extensions[suffix],
                filename=full_path.name,
            )

        # For text files, read and return as plain text
        try:
            content = full_path.read_text()
            return PlainTextResponse(content)
        except UnicodeDecodeError:
            raise HTTPException(
                status_code=415, detail="File is binary and cannot be displayed"
            )

    @app.get("/api/jobs/{job_name}/trials/{trial_name}/artifacts")
    def get_artifacts(job_name: str, trial_name: str) -> dict[str, Any]:
        """Get artifacts collected from the trial sandbox."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        artifacts_dir = trial_dir / "artifacts"
        if not artifacts_dir.exists():
            return {"files": [], "manifest": None}

        # Parse manifest.json if present
        manifest = None
        manifest_path = artifacts_dir / "manifest.json"
        if manifest_path.exists():
            try:
                manifest = json.loads(manifest_path.read_text())
            except (json.JSONDecodeError, OSError):
                manifest = None

        # Scan artifacts directory for files, excluding manifest.json
        files: list[FileInfo] = []

        def scan_dir(dir_path: Path, relative_base: str = "") -> None:
            try:
                for item in sorted(dir_path.iterdir()):
                    relative_path = (
                        f"{relative_base}/{item.name}" if relative_base else item.name
                    )
                    if item.name == "manifest.json" and not relative_base:
                        continue
                    if item.is_dir():
                        scan_dir(item, relative_path)
                    else:
                        files.append(
                            FileInfo(
                                path=relative_path,
                                name=item.name,
                                is_dir=False,
                                size=item.stat().st_size,
                            )
                        )
            except PermissionError:
                pass

        scan_dir(artifacts_dir)
        return {"files": files, "manifest": manifest}

    @app.get("/api/jobs/{job_name}/trials/{trial_name}/agent-logs")
    def get_agent_logs(job_name: str, trial_name: str) -> dict[str, Any]:
        """Get agent log files (oracle.txt, setup/stdout.txt, command-*/stdout.txt)."""
        trial_dir = jobs_dir / job_name / trial_name
        if not trial_dir.exists():
            raise HTTPException(
                status_code=404,
                detail=f"Trial '{trial_name}' not found in job '{job_name}'",
            )

        agent_dir = trial_dir / "agent"
        logs: dict[str, Any] = {
            "oracle": None,
            "setup": None,
            "commands": [],
            "summary": None,
        }

        # Read summary.md if it exists
        summary_path = trial_dir / "summary.md"
        if summary_path.exists():
            try:
                logs["summary"] = summary_path.read_text()
            except Exception:
                logs["summary"] = "[Error reading file]"

        # Read oracle.txt if it exists
        oracle_path = agent_dir / "oracle.txt"
        if oracle_path.exists():
            try:
                logs["oracle"] = oracle_path.read_text()
            except Exception:
                logs["oracle"] = "[Error reading file]"

        # Read setup/stdout.txt if it exists
        setup_stdout_path = agent_dir / "setup" / "stdout.txt"
        if setup_stdout_path.exists():
            try:
                logs["setup"] = setup_stdout_path.read_text()
            except Exception:
                logs["setup"] = "[Error reading file]"

        # Read command-*/stdout.txt files
        i = 0
        while True:
            command_dir = agent_dir / f"command-{i}"
            if not command_dir.exists():
                break
            stdout_path = command_dir / "stdout.txt"
            if stdout_path.exists():
                try:
                    logs["commands"].append(
                        {"index": i, "content": stdout_path.read_text()}
                    )
                except Exception:
                    logs["commands"].append(
                        {"index": i, "content": "[Error reading file]"}
                    )
            i += 1

        return logs

    @app.get("/api/health")
    def health_check() -> dict[str, str]:
        """Health check endpoint."""
        return {"status": "ok"}

    @app.get("/api/config")
    def get_config() -> dict[str, str]:
        """Get viewer configuration."""
        return {"jobs_dir": str(jobs_dir)}

    # Serve static viewer files if provided
    if static_dir and static_dir.exists():
        assets_dir = static_dir / "assets"
        if assets_dir.exists():
            app.mount(
                "/assets", StaticFiles(directory=assets_dir), name="static_assets"
            )

        fonts_dir = static_dir / "fonts"
        if fonts_dir.exists():
            app.mount("/fonts", StaticFiles(directory=fonts_dir), name="static_fonts")

        @app.get("/favicon.ico")
        def favicon() -> FileResponse:
            """Serve favicon."""
            return FileResponse(static_dir / "favicon.ico")

        @app.get("/{path:path}")
        def serve_spa(path: str) -> FileResponse:
            """Serve the SPA for all non-API routes."""
            return FileResponse(static_dir / "index.html")

    return app
