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

from __future__ import annotations

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

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

_SAFE_REPO_NAME = re.compile(r'^[a-zA-Z0-9._/-]+$')


def _validate_repo_name(name: str) -> bool:
    return bool(_SAFE_REPO_NAME.match(name)) and len(name) < 256

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


_INACTIVE_VULN_STATES = {"resolved", "dismissed"}
_SEVERITY_FILTERS = {"critical", "high", "medium", "low"}


def _vuln_is_active(v) -> bool:
    return v.get("state", "").lower() not in _INACTIVE_VULN_STATES


def _apply_severity_filter(vulns, parts):
    sev_filter = parts & _SEVERITY_FILTERS
    if sev_filter:
        return [v for v in vulns if v.get("severity") in sev_filter]
    return vulns


_48H_SECONDS = 48 * 3600


def _is_hidden_fixed(v) -> bool:
    """True if vuln should be hidden by the 'hide-fixed' filter."""
    if v.get("fixed_in_branch"):
        return True
    if v.get("report_stale"):
        # Show again if stale for >48h (report should have caught up by now)
        since = v.get("report_stale_since")
        if since:
            try:
                dt = datetime.fromisoformat(since)
                if (datetime.now() - dt).total_seconds() > _48H_SECONDS:
                    return False
            except ValueError:
                pass
        return True
    return False


def _apply_vuln_filter(vulns, filter_str):
    """Filter vulnerability dicts by active state, severity, and fixed-in-branch."""
    if not filter_str:
        return vulns
    parts = set(filter_str.split(","))
    if "active" in parts:
        vulns = [v for v in vulns if _vuln_is_active(v)]
    if "hide-fixed" in parts:
        vulns = [v for v in vulns if not _is_hidden_fixed(v)]
    return _apply_severity_filter(vulns, parts)


def create_app(
    get_state,
    get_events=None,
    get_sync_status=None,
    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,
    mcp_mode: bool = False,
    _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")

    @app.middleware("http")
    async def security_headers(request: Request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Content-Security-Policy"] = (
            "default-src 'self'; script-src 'self' 'unsafe-inline'; "
            "style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
            "connect-src 'self'; frame-ancestors 'none'"
        )
        return response

    @app.middleware("http")
    async def csrf_protection(request: Request, call_next):
        if request.method in ("POST", "PUT", "DELETE", "PATCH"):
            if request.url.path.startswith("/api/"):
                pass
            elif not request.headers.get("hx-request"):
                return JSONResponse(status_code=403, content={"detail": "Forbidden"})
        return await call_next(request)

    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
    templates.env.globals["hub_mcp"] = mcp_mode

    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

    def _forge_url(remote_url):
        """Convert a git remote URL (SSH or HTTPS) to a browsable HTTPS URL."""
        if not remote_url:
            return ""
        url = remote_url.rstrip("/")
        if url.endswith(".git"):
            url = url[:-4]
        if url.startswith("git@"):
            # git@host:path → https://host/path
            url = url[4:]  # strip "git@"
            url = "https://" + url.replace(":", "/", 1)
        elif not url.startswith("http"):
            return ""
        return url

    templates.env.filters["forge_url"] = _forge_url

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

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

    @app.get("/")
    async def dashboard(request: Request):
        state = await asyncio.to_thread(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 = await asyncio.to_thread(get_state)
        return templates.TemplateResponse(request, "mrs.html", {"state": state})

    @app.get("/issues")
    async def issues_page(request: Request):
        state = await asyncio.to_thread(get_state)
        return templates.TemplateResponse(request, "issues.html", {"state": state})

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

    @app.get("/pipelines")
    async def pipelines_page(request: Request):
        state = await asyncio.to_thread(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 = await asyncio.to_thread(get_state)
        return templates.TemplateResponse(request, "settings.html", {"state": state})

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

    @app.get("/fragment/dashboard")
    async def fragment_dashboard(request: Request):
        state = await asyncio.to_thread(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 = await asyncio.to_thread(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
                or mine_users & set(m.get("assignees", []))
                or mine_users & set(m.get("reviewers", []))
            ]
        return templates.TemplateResponse(
            request, "mrs_fragment.html", {"mrs": mrs, "state": state, "filter": filter}
        )

    @app.get("/fragment/issues")
    async def fragment_issues(request: Request, filter: str = "all"):
        state = await asyncio.to_thread(get_state)
        issues = (state or {}).get("issues", [])
        if filter == "mine":
            usernames = (state or {}).get("usernames", {})
            mine_users = set(usernames.values())
            issues = [
                i for i in issues
                if i["author"] in mine_users
                or mine_users & set(i.get("assignees", []))
                or mine_users & set(i.get("participants", []))
            ]
        return templates.TemplateResponse(
            request, "issues_fragment.html", {"issues": issues, "state": state, "filter": filter}
        )

    @app.get("/fragment/vulns")
    async def fragment_vulns(request: Request, filter: str = "active"):
        state = await asyncio.to_thread(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 = await asyncio.to_thread(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"))
            ]
        if "existing-branches" in parts:
            repo_branches = {
                r["name"]: set(r.get("remote_branches", []))
                for r in (state or {}).get("repos", [])
            }
            pipelines = [
                p for p in pipelines
                if p.get("ref") in repo_branches.get(p.get("repo_name"), set())
            ]
        pipelines.sort(key=lambda p: p.get("created_at", ""), reverse=True)
        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):
        if get_sync_status:
            status = get_sync_status()
            return templates.TemplateResponse(
                request, "sync_status_fragment.html", {"state": {"sync_status": status}}
            )
        state = await asyncio.to_thread(get_state)
        return templates.TemplateResponse(
            request, "sync_status_fragment.html", {"state": state}
        )

    @app.get("/fragment/events-panel")
    async def fragment_events_panel(request: Request):
        if get_events:
            events = await asyncio.to_thread(get_events)
        else:
            state = await asyncio.to_thread(get_state)
            events = (state or {}).get("events", [])
        return templates.TemplateResponse(
            request, "events_panel_fragment.html", {"events": events}
        )

    # --- 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 = await asyncio.to_thread(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 not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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()
            order = data.get("order", [])
            if not isinstance(order, list) or not all(isinstance(x, str) for x in order):
                return JSONResponse(status_code=400, content={"detail": "order must be a list of strings"})
            await save_repo_order(order)

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

    @app.get("/repo/{name}")
    async def repo_page(name: str, request: Request):
        if not _validate_repo_name(name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        state = await asyncio.to_thread(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):
        if not _validate_repo_name(name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        state = await asyncio.to_thread(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}/issues")
    async def fragment_repo_issues(name: str, request: Request):
        if not _validate_repo_name(name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        state = await asyncio.to_thread(get_state)
        issues = [i for i in (state or {}).get("issues", []) if i["repo_name"] == name]
        return templates.TemplateResponse(
            request,
            "repo_issues_fragment.html",
            {"issues": issues, "state": state, "repo_name": name},
        )

    @app.get("/fragment/repo/{name}/pipelines")
    async def fragment_repo_pipelines(name: str, request: Request):
        if not _validate_repo_name(name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        state = await asyncio.to_thread(get_state)
        pipelines = [
            p for p in (state or {}).get("pipelines", []) if p["repo_name"] == name
        ]
        pipelines.sort(key=lambda p: p.get("created_at", ""), reverse=True)
        repo = next((r for r in (state or {}).get("repos", []) if r["name"] == name), {})
        gitlab_url = _forge_url(repo.get("remote_url") or "")
        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"):
        if not _validate_repo_name(name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        state = await asyncio.to_thread(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 = _forge_url(repo.get("remote_url") or "")
        unsupported = repo.get("unsupported_features", [])
        return templates.TemplateResponse(
            request,
            "repo_vulns_fragment.html",
            {"vulns": vulns[:10], "total": total, "repo_name": name, "gitlab_url": gitlab_url,
             "unsupported_features": unsupported},
        )

    @app.get("/fragment/pipeline-jobs/{repo_name}/{pipeline_id}")
    async def fragment_pipeline_jobs(
        repo_name: str, pipeline_id: str, request: Request
    ):
        if not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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 not _validate_repo_name(repo_name):
            return JSONResponse(status_code=400, content={"detail": "Invalid repo name"})
        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
