"""Technical operations dashboard for AIF services (AIF Technical Dashboard)."""
from __future__ import annotations

from pathlib import Path
from typing import Any

import anyio
import httpx
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from dashboard.clients import DashboardClients
from dashboard.config import settings

app = FastAPI(title="AIF Technical Dashboard", version="0.1.0")
clients = DashboardClients()

BASE = Path(__file__).resolve().parent
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")


@app.get("/")
async def dashboard_index():
    return FileResponse(str(BASE / "templates" / "index.html"))


@app.get("/favicon.ico")
async def favicon():
    return FileResponse(str(BASE / "static" / "favicon.ico"))


@app.get("/health")
async def health():
    return {"status": "healthy", "name": "dashboard"}


def _request_error_detail(exc: httpx.RequestError) -> dict[str, Any]:
    """Map httpx transport errors to stable ``error_kind`` + specific hints."""
    url = str(exc.request.url) if exc.request else None
    base: dict[str, Any] = {
        "upstream_url": url,
        "error_type": type(exc).__name__,
    }
    tail = str(exc).strip() or None
    if tail:
        base["upstream_error_detail"] = tail[:2000]

    if isinstance(exc, httpx.ConnectTimeout):
        return {
            **base,
            "error_kind": "connect_timeout",
            "message": "Timed out while opening a connection to the upstream service.",
            "hint": "Upstream may be overloaded or blocked; check firewall and that the service is listening.",
        }
    if isinstance(exc, httpx.ReadTimeout):
        return {
            **base,
            "error_kind": "read_timeout",
            "message": "Upstream connected but did not send a complete response in time.",
            "hint": "The operation may still be running on the server; increase client timeout or retry. Long LLM calls need a high timeout.",
        }
    if isinstance(exc, httpx.WriteTimeout):
        return {
            **base,
            "error_kind": "write_timeout",
            "message": "Timed out while sending the request body to the upstream service.",
            "hint": "Try a smaller payload or check network stability.",
        }
    if isinstance(exc, httpx.PoolTimeout):
        return {
            **base,
            "error_kind": "pool_timeout",
            "message": "No free HTTP connection available to the upstream within the pool timeout.",
            "hint": "Reduce concurrent dashboard requests or increase httpx connection pool limits.",
        }
    if isinstance(exc, httpx.ConnectError):
        return {
            **base,
            "error_kind": "connect_error",
            "message": "Could not connect to the upstream host or port (connection refused or unreachable).",
            "hint": "Confirm the target process is running and NORMBASE_BASE_URL / port match (e.g. Normbase on 8001).",
        }
    if isinstance(exc, httpx.ReadError):
        return {
            **base,
            "error_kind": "read_error",
            "message": "Connection dropped while reading the response from upstream.",
            "hint": "Upstream may have restarted (e.g. uvicorn --reload) or closed the connection.",
        }
    if isinstance(exc, httpx.WriteError):
        return {
            **base,
            "error_kind": "write_error",
            "message": "Connection dropped while sending the request to upstream.",
            "hint": "Check network stability and whether the upstream process restarted.",
        }
    if isinstance(exc, httpx.RemoteProtocolError):
        return {
            **base,
            "error_kind": "remote_protocol_error",
            "message": "Upstream violated HTTP protocol or closed the connection unexpectedly.",
            "hint": "Often seen with proxies or mid-response crashes; check upstream logs.",
        }
    if isinstance(exc, httpx.LocalProtocolError):
        return {
            **base,
            "error_kind": "local_protocol_error",
            "message": "Local HTTP client hit a protocol error talking to upstream.",
            "hint": "Rare; note the upstream_error_detail and httpx version.",
        }
    if isinstance(exc, httpx.ProxyError):
        return {
            **base,
            "error_kind": "proxy_error",
            "message": "HTTP proxy rejected or failed the request.",
            "hint": "Check HTTP(S)_PROXY / NO_PROXY and corporate proxy settings.",
        }

    return {
        **base,
        "error_kind": "request_error",
        "message": "Request to upstream failed before an HTTP status was received.",
        "hint": "See error_type and upstream_error_detail; verify URL and that the service is reachable.",
    }


def _error(exc: Exception) -> HTTPException:
    if isinstance(exc, httpx.HTTPStatusError):
        status_code = exc.response.status_code
        upstream_url = str(exc.request.url)
        detail: dict[str, Any] = {
            "message": f"Upstream service returned HTTP {status_code}",
            "upstream_status": status_code,
            "upstream_url": upstream_url,
        }
        try:
            detail["upstream_body"] = exc.response.json()
        except Exception:
            detail["upstream_body"] = exc.response.text

        if status_code == 404 and "/api/v1/keys" in upstream_url:
            detail["hint"] = (
                "Normbase keys API route is missing. "
                "Restart Normbase with the latest code and verify /api/v1/keys is available."
            )
        elif status_code == 401:
            detail["hint"] = "Check internal token / API key configuration."
        elif status_code == 503:
            detail["hint"] = "Upstream service unavailable. Verify dependent services are running."
        elif status_code == 502 and "/extraction/" in upstream_url:
            ub = detail.get("upstream_body")
            dtext = ""
            if isinstance(ub, dict):
                raw = ub.get("detail")
                dtext = raw if isinstance(raw, str) else str(raw)
            elif isinstance(ub, str):
                dtext = ub
            if any(
                x in dtext
                for x in (
                    "not valid JSON",
                    "LLM output",
                    "Unterminated string",
                    "Expecting ",
                    "JSONDecodeError",
                )
            ):
                detail["hint"] = (
                    "Model output was cut off or malformed JSON. Norm_extractor already retries "
                    "with lower per-chunk max_norms (up to 3 calls). If it still fails: keep "
                    "NORMBASE_EXTRACTION_LLM_MAX_OUTPUT_TOKENS at your provider max (Ark 32768), "
                    "lower Dashboard max_norms, shorten text, or reduce "
                    "NORMBASE_EXTRACTION_LLM_MAX_CONTENT_CHARS to split into more chunks."
                )
                detail["error_kind"] = "upstream_extraction_llm_json"
            elif "max_tokens" in dtext and (
                "maximum" in dtext.lower() or "InvalidParameter" in dtext or "BadRequest" in dtext
            ):
                detail["hint"] = (
                    "max_tokens exceeds this LLM provider limit (e.g. Volces Ark ≤32768). "
                    "Set NORMBASE_EXTRACTION_LLM_MAX_OUTPUT_TOKENS within the allowed range."
                )
                detail["error_kind"] = "upstream_extraction_max_tokens"

        return HTTPException(status_code=502, detail=detail)

    if isinstance(exc, httpx.RequestError):
        detail = _request_error_detail(exc)
        return HTTPException(status_code=502, detail=detail)

    return HTTPException(status_code=500, detail={"message": str(exc)})


async def _ontology_neo4j_overview() -> dict[str, Any]:
    """Bolt connectivity for ontology graph viewer; failures do not fail the whole overview."""
    out: dict[str, Any] = {
        "configured": False,
        "reachable": None,
        "driver_installed": None,
    }
    if (
        not settings.neo4j_bolt_uri
        or not settings.neo4j_user
        or not settings.neo4j_password
    ):
        return out

    out["configured"] = True
    try:
        import neo4j  # noqa: F401
    except ImportError:
        out["driver_installed"] = False
        out["reachable"] = False
        return out

    out["driver_installed"] = True
    try:
        sync = await anyio.to_thread.run_sync(_neo4j_connectivity_sync)
    except Exception as exc:
        out["reachable"] = False
        out["error"] = str(exc)
        return out
    out["reachable"] = bool(sync.get("reachable"))
    if sync.get("error"):
        out["error"] = sync["error"]
    return out


def _neo4j_connectivity_sync() -> dict[str, Any]:
    from neo4j import GraphDatabase  # type: ignore

    driver = GraphDatabase.driver(
        settings.neo4j_bolt_uri, auth=(settings.neo4j_user, settings.neo4j_password)
    )
    try:
        driver.verify_connectivity()
        return {"reachable": True, "error": None}
    except Exception as exc:
        return {"reachable": False, "error": str(exc)}
    finally:
        driver.close()


@app.get("/api/overview")
async def api_overview():
    try:
        normbase = await clients.normbase_health()
        eda = await clients.eda_health()
        mcp_probe = await clients.mcp_health()
        mcp = {
            "probe": "live",
            "reachable": bool(mcp_probe.get("reachable")),
            "status_code": mcp_probe.get("status_code"),
        }
        recent_audit = clients.read_mcp_audit(limit=50)
        recent_errors = [
            item
            for item in recent_audit
            if item.get("ok") is False or item.get("status") in ("error", "failed")
        ][:10]
        ontology = {"neo4j": await _ontology_neo4j_overview()}
        return {
            "normbase": normbase,
            "eda": eda,
            "mcp": mcp,
            "ontology": ontology,
            "config": {
                "normbase_base_url": settings.normbase_base_url,
                "eda_base_url": settings.eda_base_url,
                "mcp_base_url": settings.mcp_base_url,
                "mcp_audit_log_configured": bool(settings.mcp_audit_log),
                "neo4j_bolt_uri": settings.neo4j_bolt_uri,
                "neo4j_user": settings.neo4j_user,
                "neo4j_password_configured": bool(settings.neo4j_password),
            },
            "security_config": {
                "internal_service_token_configured": bool(
                    (settings.aif_internal_service_token or "").strip()
                ),
                "api_key_salt_configured": bool(
                    (settings.aif_api_key_salt or "").strip()
                ),
            },
            "recent_errors": recent_errors,
        }
    except Exception as exc:
        raise _error(exc)


@app.get("/api/norms")
async def api_list_norms(
    page_size: int = 100,
    page: int = 1,
    status: str | None = None,
    field_id: str | None = None,
):
    try:
        return await clients.list_norms(
            page_size=page_size,
            page=page,
            status=status,
            field_id=field_id,
        )
    except Exception as exc:
        raise _error(exc)


@app.get("/api/norms/{norm_id}")
async def api_get_norm(norm_id: str):
    try:
        return await clients.get_norm(norm_id)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/norms")
async def api_create_norm(payload: dict[str, Any]):
    try:
        return await clients.create_norm(payload)
    except Exception as exc:
        raise _error(exc)


@app.put("/api/norms/{norm_id}")
async def api_update_norm(norm_id: str, payload: dict[str, Any]):
    try:
        return await clients.update_norm(norm_id, payload)
    except Exception as exc:
        raise _error(exc)


@app.delete("/api/norms/{norm_id}")
async def api_delete_norm(norm_id: str):
    try:
        return await clients.delete_norm(norm_id)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/fields")
async def api_list_fields(status: str | None = None):
    try:
        return await clients.list_fields(status=status)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/fields")
async def api_create_field(payload: dict[str, Any]):
    try:
        return await clients.create_field(payload)
    except Exception as exc:
        raise _error(exc)


@app.patch("/api/fields/{field_id}")
async def api_update_field(field_id: str, payload: dict[str, Any]):
    try:
        return await clients.update_field(field_id, payload)
    except Exception as exc:
        raise _error(exc)


@app.delete("/api/fields/{field_id}")
async def api_soft_delete_field(field_id: str):
    try:
        return await clients.soft_delete_field(field_id)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/retrieve")
async def api_retrieve(payload: dict[str, Any]):
    try:
        return await clients.retrieve_norms(payload)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/compliance/check")
async def api_check_action_compliance(payload: dict[str, Any]):
    try:
        return await clients.check_action_compliance(payload)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/compliance/report")
async def api_report_norm_outcome(payload: dict[str, Any]):
    try:
        return await clients.report_norm_outcome(payload)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/extraction/from-text")
async def api_extraction_from_text(payload: dict[str, Any]):
    try:
        return await clients.extraction_from_text(payload)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/extraction/batches")
async def api_create_extraction_batch(payload: dict[str, Any]):
    try:
        return await clients.create_extraction_batch(payload)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/extraction/batches/{batch_id}")
async def api_get_extraction_batch(batch_id: str):
    try:
        return await clients.get_extraction_batch(batch_id)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/extraction/batches/{batch_id}/documents")
async def api_upload_extraction_documents(
    batch_id: str,
    files: list[UploadFile] = File(...),
):
    try:
        parts: list[tuple[str, bytes, str | None]] = []
        for uf in files:
            data = await uf.read()
            parts.append((uf.filename or "upload.bin", data, uf.content_type))
        return await clients.upload_extraction_documents(batch_id, parts)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/extraction/batches/{batch_id}/documents/{document_id}/extract")
async def api_extract_extraction_document(
    batch_id: str,
    document_id: str,
    default_lang: str = "zh-CN",
    max_norms: int = 30,
    submitter_user_id: str | None = None,
):
    try:
        return await clients.extract_extraction_document(
            batch_id,
            document_id,
            default_lang=default_lang,
            max_norms=max_norms,
            submitter_user_id=submitter_user_id,
        )
    except Exception as exc:
        raise _error(exc)


@app.get("/api/agents")
async def api_list_agents(limit: int = 100):
    try:
        return await clients.list_agents(limit=limit)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/agents")
async def api_create_agent(payload: dict[str, Any]):
    try:
        return await clients.create_agent(payload)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/agents/{agent_id}")
async def api_get_agent(agent_id: str):
    try:
        return await clients.get_agent(agent_id)
    except Exception as exc:
        raise _error(exc)


@app.patch("/api/agents/{agent_id}")
async def api_patch_agent(agent_id: str, payload: dict[str, Any]):
    try:
        return await clients.update_agent(agent_id, payload)
    except Exception as exc:
        raise _error(exc)


@app.delete("/api/agents/{agent_id}")
async def api_delete_agent(agent_id: str):
    try:
        return await clients.delete_agent(agent_id)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/agents/{agent_id}/{action}")
async def api_agent_action(agent_id: str, action: str, payload: dict[str, Any]):
    try:
        return await clients.agent_action(agent_id, action, payload)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/agents/{agent_id}/traces")
async def api_get_traces(agent_id: str, limit: int = 20):
    try:
        return await clients.get_traces(agent_id, limit=limit)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/mcp/info")
async def api_mcp_info():
    info: dict[str, Any] = {
        "tools": [],
        "mcp_base_url": settings.mcp_base_url,
        "auth_mode": "per-agent-key-only",
        "audit_log_configured": bool(settings.mcp_audit_log),
        "live": False,
    }
    try:
        info["tools"] = await clients.list_mcp_tools()
        info["live"] = True
    except Exception as exc:
        info["tools_error"] = str(exc)
    return info


def _neo4j_required() -> None:
    if not settings.neo4j_bolt_uri or not settings.neo4j_user or not settings.neo4j_password:
        raise HTTPException(
            status_code=400,
            detail={
                "message": "Neo4j is not configured for dashboard viewer.",
                "hint": "Set NEO4J_BOLT_URI, NEO4J_USER, NEO4J_PASSWORD in .env (or environment) and restart dashboard.",
            },
        )


def _fetch_neo4j_subgraph(limit: int, label: str, rel_type: str) -> dict[str, Any]:
    try:
        from neo4j import GraphDatabase  # type: ignore
    except ImportError as exc:
        raise HTTPException(
            status_code=500,
            detail={
                "message": "Neo4j driver is not installed.",
                "hint": "Install dependency `neo4j` and restart dashboard.",
            },
        ) from exc

    driver = GraphDatabase.driver(
        settings.neo4j_bolt_uri, auth=(settings.neo4j_user, settings.neo4j_password)
    )
    cypher = (
        "MATCH (n:`%s`)-[r:%s]->(m:`%s`) "
        "RETURN n, r, m "
        "LIMIT $limit"
    ) % (label, rel_type, label)

    nodes_by_id: dict[str, dict[str, Any]] = {}
    edges: list[dict[str, Any]] = []
    with driver.session() as session:
        result = session.run(cypher, limit=limit)
        for record in result:
            n = record["n"]
            m = record["m"]
            r = record["r"]

            for node in (n, m):
                nid = str(node.get("id") or node.element_id)
                if nid in nodes_by_id:
                    continue
                props = dict(node)
                props.setdefault("id", nid)
                nodes_by_id[nid] = {
                    "id": nid,
                    "labels": list(getattr(node, "labels", [])),
                    "properties": props,
                }

            edges.append(
                {
                    "id": str(r.get("id") or r.element_id),
                    # `r.type` in Neo4j driver is the relationship type token (e.g. "ARCHI_REL").
                    # The ArchiMate-specific relationship type we imported is stored as a property `type`.
                    "type": str(r.get("type") or getattr(r, "type", "") or rel_type),
                    "source": str(n.get("id") or n.element_id),
                    "target": str(m.get("id") or m.element_id),
                    "properties": dict(r),
                }
            )

    driver.close()
    return {"nodes": list(nodes_by_id.values()), "edges": edges, "cypher": cypher}


@app.get("/api/neo4j/subgraph")
async def api_neo4j_subgraph(
    limit: int = 200,
    label: str = "ArchimateElement",
    rel_type: str = "ARCHI_REL",
):
    _neo4j_required()
    safe_limit = max(1, min(int(limit), 1000))
    safe_label = (label or "").strip() or "ArchimateElement"
    safe_rel = (rel_type or "").strip() or "ARCHI_REL"

    # simple guard: allow only identifier-like tokens
    import re

    ident = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
    if not ident.match(safe_label) or not ident.match(safe_rel):
        raise HTTPException(
            status_code=400,
            detail={
                "message": "Invalid label or relationship type.",
                "hint": "Use identifier tokens like ArchimateElement / ARCHI_REL (letters, numbers, underscore).",
            },
        )

    try:
        return await anyio.to_thread.run_sync(_fetch_neo4j_subgraph, safe_limit, safe_label, safe_rel)
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(
            status_code=502,
            detail={
                "message": "Failed to query Neo4j.",
                "hint": "Verify Neo4j is running and credentials are correct.",
                "error": str(exc),
            },
        )


@app.get("/api/mcp/audit")
async def api_mcp_audit(limit: int = 100):
    return {"items": clients.read_mcp_audit(limit=limit)}


@app.post("/api/mcp/rpc")
async def api_mcp_rpc(payload: dict[str, Any]):
    try:
        return await clients.mcp_rpc(payload)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/keys/issue")
async def api_issue_key(payload: dict[str, Any]):
    try:
        return await clients.issue_key(payload)
    except Exception as exc:
        raise _error(exc)


@app.get("/api/keys")
async def api_list_keys(agent_id: str | None = None, include_inactive: bool = False):
    try:
        return await clients.list_keys(agent_id=agent_id, include_inactive=include_inactive)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/keys/{key_id}/revoke")
async def api_revoke_key(key_id: str):
    try:
        return await clients.revoke_key(key_id)
    except Exception as exc:
        raise _error(exc)


@app.post("/api/keys/{key_id}/rotate")
async def api_rotate_key(key_id: str):
    try:
        return await clients.rotate_key(key_id)
    except Exception as exc:
        raise _error(exc)


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(
        "dashboard.main:app",
        host=settings.host,
        port=settings.port,
        reload=settings.debug,
    )

