"""FastAPI web application: dashboard, htmx fragments, and action endpoints."""

from __future__ import annotations

import asyncio
import importlib.metadata
import socket
from datetime import datetime, timezone
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import uvicorn

BASE_DIR = Path(__file__).resolve().parent


def _apply_vuln_filter(vulns, filter_str):
    """Filter vulnerability dicts by active state, severity, and fixed-in-branch."""
    parts = set(filter_str.split(",")) if filter_str else set()
    if "active" in parts:
        vulns = [v for v in vulns if v.get("state", "").lower() not in ("resolved", "dismissed")]
    if "hide-fixed" in parts:
        vulns = [v for v in vulns if not v.get("fixed_in_branch")]
    sev_filter = parts & {"critical", "high", "medium", "low"}
    if sev_filter:
        vulns = [v for v in vulns if v.get("severity") in sev_filter]
    return vulns


def create_app(
    get_state,
    trigger_refresh=None,
    dismiss_event=None,
    dismiss_all_events=None,
    fetch_repo=None,
    pull_repo=None,
    pull_all=None,
    checkout_default_all=None,
    checkout_default_one=None,
    get_pipeline_jobs=None,
    pin_repo=None,
    unpin_repo=None,
    save_repo_order=None,
    exclude_repo=None,
    include_repo=None,
    rescan=None,
    _template_dir: Path | None = None,
    _static_dir: Path | None = None,
) -> FastAPI:
    """Factory returning a configured FastAPI app.

    get_state: callable returning a dict with workspace data, or None if not ready.
    trigger_refresh: optional async callable -- triggers a refresh cycle.
    dismiss_event: optional async callable(event_id: int) -- persists dismissal to DB.
    dismiss_all_events: optional async callable -- marks all events as seen.
    fetch_repo: optional async callable(repo_name: str) -- fetch a single repo.
    pull_repo: optional async callable(repo_name: str) -- pull a single repo.
    get_pipeline_jobs: optional async callable(repo_name, pipeline_id) -- get jobs.
    pin_repo: optional async callable(repo_name: str) -- pin a repo.
    unpin_repo: optional async callable(repo_name: str) -- unpin a repo.
    save_repo_order: optional async callable(order: list[str]) -- save card order.
    exclude_repo: optional async callable(repo_name: str) -- exclude a repo.
    include_repo: optional async callable(repo_name: str) -- re-include a repo.
    rescan: optional async callable -- rescan workspace for repos.
    _template_dir / _static_dir: override paths (used in tests).
    """
    app = FastAPI(title="project-hub")

    template_dir = _template_dir or BASE_DIR / "templates"
    static_dir = _static_dir or BASE_DIR / "static"

    templates = Jinja2Templates(directory=str(template_dir))
    templates.env.globals["hub_version"] = importlib.metadata.version("project-hub")
    templates.env.globals["hub_cwd"] = Path.cwd().name

    def _timeago(value):
        """Format an ISO-8601 string or datetime as relative time ('2h ago')."""
        if not value:
            return ""
        if isinstance(value, str):
            raw = value.replace("Z", "+00:00")
            try:
                dt = datetime.fromisoformat(raw)
            except ValueError:
                return value
        elif isinstance(value, datetime):
            dt = value
        else:
            return str(value)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        delta = datetime.now(timezone.utc) - dt
        secs = int(delta.total_seconds())
        if secs < 0:
            return "just now"
        if secs < 60:
            return "just now"
        mins = secs // 60
        if mins < 60:
            return f"{mins}m ago"
        hours = mins // 60
        if hours < 24:
            return f"{hours}h ago"
        days = hours // 24
        if days < 30:
            return f"{days}d ago"
        months = days // 30
        return f"{months}mo ago"

    templates.env.filters["timeago"] = _timeago

    app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")

    # --- full pages -----------------------------------------------------------

    @app.get("/")
    async def dashboard(request: Request):
        state = get_state()
        if state is None:
            return templates.TemplateResponse(request, "loading.html")
        return templates.TemplateResponse(request, "dashboard.html", {"state": state})

    @app.get("/mrs")
    async def mrs_page(request: Request):
        state = get_state()
        return templates.TemplateResponse(request, "mrs.html", {"state": state})

    @app.get("/vulns")
    async def vulns_page(request: Request):
        state = get_state()
        return templates.TemplateResponse(request, "vulns.html", {"state": state})

    @app.get("/pipelines")
    async def pipelines_page(request: Request):
        state = get_state()
        return templates.TemplateResponse(request, "pipelines.html", {"state": state})

    @app.get("/events", status_code=301)
    async def events_page(request: Request):
        return RedirectResponse(url="/", status_code=301)

    @app.get("/settings")
    async def settings_page(request: Request):
        state = get_state()
        return templates.TemplateResponse(request, "settings.html", {"state": state})

    # --- htmx partials --------------------------------------------------------

    @app.get("/fragment/dashboard")
    async def fragment_dashboard(request: Request):
        state = get_state()
        return templates.TemplateResponse(
            request, "dashboard_fragment.html", {"state": state}
        )

    @app.get("/fragment/mrs")
    async def fragment_mrs(request: Request, filter: str = "all"):
        state = get_state()
        mrs = (state or {}).get("mrs", [])
        if filter == "mine":
            usernames = (state or {}).get("usernames", {})
            mine_users = set(usernames.values())
            mrs = [m for m in mrs if m["author"] in mine_users]
        return templates.TemplateResponse(
            request, "mrs_fragment.html", {"mrs": mrs, "state": state, "filter": filter}
        )

    @app.get("/fragment/vulns")
    async def fragment_vulns(request: Request, filter: str = "active"):
        state = get_state()
        vulns = _apply_vuln_filter((state or {}).get("vulns", []), filter)
        return templates.TemplateResponse(
            request, "vulns_fragment.html", {"vulns": vulns, "state": state, "filter": filter}
        )

    @app.get("/fragment/pipelines")
    async def fragment_pipelines(request: Request, filter: str = "all"):
        state = get_state()
        pipelines = (state or {}).get("pipelines", [])
        parts = set(filter.split(",")) if filter else set()
        if "failed" in parts:
            pipelines = [p for p in pipelines if p.get("status") == "failed"]
        if "current-branches" in parts:
            current_branches = {
                r["name"]: r.get("branch") for r in (state or {}).get("repos", [])
            }
            pipelines = [
                p for p in pipelines
                if p.get("ref") == current_branches.get(p.get("repo_name"))
            ]
        return templates.TemplateResponse(
            request, "pipelines_fragment.html", {"pipelines": pipelines, "state": state, "filter": filter}
        )

    @app.get("/fragment/sync-status")
    async def fragment_sync_status(request: Request):
        state = get_state()
        return templates.TemplateResponse(
            request, "sync_status_fragment.html", {"state": state}
        )

    @app.get("/fragment/events-panel")
    async def fragment_events_panel(request: Request):
        state = get_state()
        return templates.TemplateResponse(
            request, "events_panel_fragment.html", {"state": state}
        )

    # --- actions --------------------------------------------------------------

    @app.post("/action/sync")
    async def action_sync(request: Request):
        if trigger_refresh is not None:
            await trigger_refresh()
        return Response(status_code=204)

    @app.post("/action/dismiss-event/{event_id}")
    async def action_dismiss_event(event_id: int, request: Request):
        if dismiss_event:
            await dismiss_event(event_id)
        return HTMLResponse("")

    @app.post("/action/dismiss-all-events")
    async def action_dismiss_all_events(request: Request):
        if dismiss_all_events:
            await dismiss_all_events()
        return templates.TemplateResponse(
            request, "toast.html",
            {"level": "info", "message": "All events marked as seen"},
            headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
        )

    @app.post("/api/refresh")
    async def api_refresh(request: Request):
        """Trigger a refresh and wait for completion."""
        if trigger_refresh:
            await trigger_refresh()

        # Re-fetch state after refresh completes
        state = get_state()
        return {
            "mrs": len(state.get("mrs", [])),
            "pipelines": len(state.get("pipelines", [])),
            "vulns": len(state.get("vulns", [])),
            "events": len(state.get("events", [])),
        }

    # --- pin / unpin / reorder ------------------------------------------------

    @app.post("/action/pin/{repo_name}", status_code=204)
    async def action_pin(repo_name: str):
        if pin_repo:
            await pin_repo(repo_name)

    @app.post("/action/unpin/{repo_name}", status_code=204)
    async def action_unpin(repo_name: str):
        if unpin_repo:
            await unpin_repo(repo_name)

    @app.post("/action/reorder", status_code=204)
    async def action_reorder(request: Request):
        if save_repo_order:
            data = await request.json()
            await save_repo_order(data.get("order", []))

    # --- repo detail ----------------------------------------------------------

    @app.get("/repo/{name}")
    async def repo_page(name: str, request: Request):
        state = get_state()
        repo = next(
            (r for r in (state or {}).get("repos", []) if r["name"] == name), None
        )
        return templates.TemplateResponse(
            request, "repo.html", {"state": state, "repo": repo, "repo_name": name}
        )

    @app.get("/fragment/repo/{name}/mrs")
    async def fragment_repo_mrs(name: str, request: Request):
        state = get_state()
        mrs = [m for m in (state or {}).get("mrs", []) if m["repo_name"] == name]
        return templates.TemplateResponse(
            request,
            "repo_mrs_fragment.html",
            {"mrs": mrs, "state": state, "repo_name": name},
        )

    @app.get("/fragment/repo/{name}/pipelines")
    async def fragment_repo_pipelines(name: str, request: Request):
        state = get_state()
        pipelines = [
            p for p in (state or {}).get("pipelines", []) if p["repo_name"] == name
        ]
        repo = next((r for r in (state or {}).get("repos", []) if r["name"] == name), {})
        gitlab_url = (repo.get("remote_url") or "").replace(".git", "")
        return templates.TemplateResponse(
            request,
            "repo_pipelines_fragment.html",
            {"pipelines": pipelines[:10], "total": len(pipelines), "repo_name": name, "gitlab_url": gitlab_url},
        )

    @app.get("/fragment/repo/{name}/vulns")
    async def fragment_repo_vulns(name: str, request: Request, filter: str = "active"):
        state = get_state()
        vulns = [v for v in (state or {}).get("vulns", []) if v["repo_name"] == name]
        total = len(vulns)
        vulns = _apply_vuln_filter(vulns, filter)
        repo = next((r for r in (state or {}).get("repos", []) if r["name"] == name), {})
        gitlab_url = (repo.get("remote_url") or "").replace(".git", "")
        return templates.TemplateResponse(
            request,
            "repo_vulns_fragment.html",
            {"vulns": vulns[:10], "total": total, "repo_name": name, "gitlab_url": gitlab_url},
        )

    @app.get("/fragment/pipeline-jobs/{repo_name}/{pipeline_id}")
    async def fragment_pipeline_jobs(
        repo_name: str, pipeline_id: int, request: Request
    ):
        jobs = []
        if get_pipeline_jobs:
            jobs = await get_pipeline_jobs(repo_name, pipeline_id)
        return templates.TemplateResponse(
            request, "pipeline_jobs_fragment.html", {"jobs": jobs}
        )

    @app.post("/action/fetch/{repo_name}", status_code=204)
    async def action_fetch_repo(repo_name: str):
        if fetch_repo:
            await fetch_repo(repo_name)

    @app.post("/action/pull/{repo_name}")
    async def action_pull_repo(request: Request, repo_name: str):
        if not pull_repo:
            return templates.TemplateResponse(
                request, "toast.html",
                {"level": "error", "message": "Pull not available"},
                headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
            )
        result = await pull_repo(repo_name)
        status = result.get("status", "error")
        msg_map = {
            "updated": ("success", f"{repo_name}: pulled successfully"),
            "already_up_to_date": ("info", f"{repo_name}: already up to date"),
            "skipped_dirty": ("error", f"{repo_name}: skipped (dirty working tree)"),
            "skipped_conflict": ("error", f"{repo_name}: skipped (conflict detected)"),
            "skipped_ahead_only": ("info", f"{repo_name}: skipped (ahead only)"),
        }
        level, message = msg_map.get(status, ("error", f"{repo_name}: {result.get('message') or status}"))
        return templates.TemplateResponse(
            request, "toast.html",
            {"level": level, "message": message},
            headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
        )

    @app.post("/action/pull-all")
    async def action_pull_all(request: Request):
        if not pull_all:
            return templates.TemplateResponse(
                request, "toast.html",
                {"level": "error", "message": "Pull not available"},
                headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
            )
        results = await pull_all()
        updated = sum(1 for r in results if r["status"] == "updated")
        skipped = sum(1 for r in results if r["status"].startswith("skipped") or r["status"] == "already_up_to_date")
        errors = sum(1 for r in results if r["status"] == "error")
        parts = []
        if updated:
            parts.append(f"{updated} pulled")
        if skipped:
            parts.append(f"{skipped} skipped")
        if errors:
            parts.append(f"{errors} errors")
        level = "success" if not errors else "error" if not updated else "info"
        return templates.TemplateResponse(
            request, "toast.html",
            {"level": level, "message": f"Pull all: {', '.join(parts) or 'nothing to do'}"},
            headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
        )

    @app.post("/action/checkout-default")
    async def action_checkout_default(request: Request):
        if not checkout_default_all:
            return templates.TemplateResponse(
                request, "toast.html",
                {"level": "error", "message": "Not available"},
                headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
            )
        results = await checkout_default_all()
        if not results:
            msg = "No stale branches to clean up"
            level = "info"
        else:
            ok = [r for r in results if r["status"] == "ok"]
            errs = [r for r in results if r["status"] == "error"]
            parts = []
            if ok:
                parts.append(f"{len(ok)} switched")
            if errs:
                parts.append(f"{len(errs)} failed")
            if ok and not errs:
                parts.append("— you may pull them")
            msg = " ".join(parts)
            level = "success" if not errs else "error" if not ok else "info"
        return templates.TemplateResponse(
            request, "toast.html",
            {"level": level, "message": msg},
            headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
        )

    @app.post("/action/checkout-default/{repo_name}")
    async def action_checkout_default_one(request: Request, repo_name: str):
        if not checkout_default_one:
            return templates.TemplateResponse(
                request, "toast.html",
                {"level": "error", "message": "Not available"},
                headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
            )
        result = await checkout_default_one(repo_name)
        if result.get("status") == "ok":
            level, msg = "success", f"Switched to {result['branch']}"
        else:
            level, msg = "error", result.get("message") or result.get("error", "unknown error")
        return templates.TemplateResponse(
            request, "toast.html",
            {"level": level, "message": msg},
            headers={"HX-Retarget": "#toast-zone", "HX-Reswap": "beforeend"},
        )

    # --- settings / config ----------------------------------------------------

    @app.post("/action/exclude/{repo_name}", status_code=204)
    async def action_exclude(repo_name: str):
        if exclude_repo:
            await exclude_repo(repo_name)

    @app.post("/action/include/{repo_name}", status_code=204)
    async def action_include(repo_name: str):
        if include_repo:
            await include_repo(repo_name)

    @app.post("/action/rescan", status_code=204)
    async def action_rescan():
        if rescan:
            await rescan()

    return app


def _find_free_port(start: int) -> int:
    """Return the first available TCP port starting from the given number."""
    for port in range(start, start + 100):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            if s.connect_ex(("127.0.0.1", port)) != 0:
                return port
    return start


async def start_webui(app: FastAPI, port: int) -> tuple[uvicorn.Server, int]:
    """Start uvicorn in the background and return (server, actual_port)."""
    actual_port = _find_free_port(port)
    config = uvicorn.Config(
        app,
        host="127.0.0.1",
        port=actual_port,
        log_level="warning",
        log_config=None,
    )
    server = uvicorn.Server(config)
    server.install_signal_handlers = lambda: None
    asyncio.create_task(server.serve())
    return server, actual_port
