"""
nterm server — FastAPI application.

Serves the terminal UI and handles WebSocket connections for SSH sessions.

Changes from v1:
- /api/diag endpoint — returns active sessions, stats, stream info
- Improved WebSocket error handling
- Structured logging with tab_id prefix

Changes from v2:
- /api/parse endpoint — provider-chain parsing via ParseBroker
- /api/parse/status — provider availability diagnostics
- parsers_config.yaml loaded at startup

Changes from v3:
- Refactored monolith index.html into modular JS files
- Static file serving for /static/js/ and /static/css/
- Performance instrumentation (perf.js) on client hot paths
- New Perf tab in diagnostics panel

Changes from v4:
- /api/sessions/tree — session tree for left rail (reads ntermqt sessions.db)
- /api/sessions/search — session search by name/hostname
- /api/sessions/{id}/connect — record connection, return session details
- Session tree integration with existing Quick Connect flow

Changes from v5:
- /api/vault/* — credential vault endpoints (mounted from vault.api)
- Vault-aware SSH connect — resolve_ssh_config injects credentials server-side
- Secrets never cross WebSocket — browser sends credential_name or use_vault flag
"""

import asyncio
import logging
import queue
from pathlib import Path

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional, Dict, Any, List

from nterm.ssh.config import SSHConfig
from nterm.ssh.manager import SSHSessionManager
from nterm.parsing import load_parse_config, ParseBroker, ParseOptions
from nterm.manager import SessionStore, SavedSession, SessionFolder, DEFAULT_DB_PATH
from nterm.vault.vault_api import vault_router, get_resolver
from nterm.vault.vault_connect import resolve_ssh_config

# ── Logging ─────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger("nterm")

# ── Application ─────────────────────────────────────────────
app = FastAPI(title="nterm", version="0.5.0")
ssh_manager = SSHSessionManager()

BASE_DIR = Path(__file__).parent
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"

# Mount static files for JS and CSS
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

# Mount vault API — resolver injected by nterm_desktop.py via set_vault_resolver()
# In browser-only mode (no desktop wrapper), vault endpoints return 503
app.include_router(vault_router, prefix="/api/vault")

# ── Parsing ─────────────────────────────────────────────────
parse_config = load_parse_config()
broker = ParseBroker(parse_config)

# ── Telemetry Request Queue ─────────────────────────────────
# Thread-safe queue: FastAPI endpoint puts, Qt poll timer gets.
# Shared in-process — no HTTP round-trip needed for desktop mode.
telemetry_request_queue: queue.Queue = queue.Queue()

# ── SSH Credential Cache ────────────────────────────────────
# Short-lived credential storage for LLDP neighbor SSH connect.
# The desktop wrapper injects credentials from the source telemetry
# widget before calling ntermConnectDevice in the browser. The
# WebSocket handler checks this dict as a fallback when vault
# resolution fails. Entries are consumed (popped) on first use.
import threading
_ssh_cred_cache: dict[str, dict] = {}
_ssh_cred_lock = threading.Lock()

# ── Session Store ───────────────────────────────────────────
# Reads the same sessions.db that ntermqt uses (~/.nterm/sessions.db)
session_store: Optional[SessionStore] = None

def _get_session_store() -> Optional[SessionStore]:
    """Lazy-init session store. Returns None if db doesn't exist."""
    global session_store
    if session_store is not None:
        return session_store
    if DEFAULT_DB_PATH.exists():
        try:
            session_store = SessionStore(DEFAULT_DB_PATH)
            logger.info(f"Session store loaded: {DEFAULT_DB_PATH}")
            return session_store
        except Exception as e:
            logger.error(f"Failed to load session store: {e}")
            return None
    else:
        logger.info(f"No session database at {DEFAULT_DB_PATH}")
        return None


class ParseRequest(BaseModel):
    command: str
    output: str
    vendor_hint: Optional[str] = None
    options: Optional[Dict[str, Any]] = None


class TelemetryRequest(BaseModel):
    hostname: str
    port: int = 22
    display_name: Optional[str] = None
    vendor: Optional[str] = "arista_eos"
    credential_name: Optional[str] = None
    session_id: Optional[int] = None


# ── Routes ──────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
    """Serve the terminal UI."""
    return (TEMPLATES_DIR / "index.html").read_text()


@app.get("/api/diag")
async def diagnostics():
    """
    Diagnostic endpoint — returns live session info and stream stats.
    """
    sessions = ssh_manager.get_session_info()
    return JSONResponse({
        "sessions_active": len(sessions),
        "sessions": sessions,
    })


@app.post("/api/parse")
async def parse_block(request: ParseRequest):
    """
    Parse a CLI output block using the provider chain.
    """
    options = None
    if request.options:
        options = ParseOptions(**request.options)

    result = await broker.parse(
        command=request.command,
        output=request.output,
        vendor_hint=request.vendor_hint,
        options=options,
    )
    return result.to_dict()


@app.get("/api/parse/status")
async def parse_status():
    """Provider availability — which parsing engines are loaded."""
    return JSONResponse({
        "providers": broker.get_provider_status(),
    })


# ── Session Tree API ────────────────────────────────────────

def _folder_to_dict(folder: SessionFolder) -> dict:
    """Convert SessionFolder to JSON-safe dict."""
    return {
        "id": folder.id,
        "name": folder.name,
        "parent_id": folder.parent_id,
        "position": folder.position,
        "expanded": folder.expanded,
    }

def _session_to_dict(session: SavedSession) -> dict:
    """Convert SavedSession to JSON-safe dict (no secrets)."""
    return {
        "id": session.id,
        "name": session.name,
        "description": session.description,
        "hostname": session.hostname,
        "port": session.port,
        "credential_name": session.credential_name,
        "folder_id": session.folder_id,
        "position": session.position,
        "last_connected": str(session.last_connected) if session.last_connected else None,
        "connect_count": session.connect_count,
        "extras": session.extras if isinstance(session.extras, dict) else {},
    }


@app.get("/api/sessions/tree")
async def sessions_tree():
    """
    Full session tree for the left rail.

    Returns folders and sessions as flat lists — the client builds
    the hierarchy from folder_id / parent_id references.

    Compatible with ntermqt's sessions.db schema.
    """
    store = _get_session_store()
    if not store:
        return JSONResponse({
            "available": False,
            "db_path": str(DEFAULT_DB_PATH),
            "folders": [],
            "sessions": [],
        })

    try:
        tree = store.get_tree()
        return JSONResponse({
            "available": True,
            "db_path": str(DEFAULT_DB_PATH),
            "folders": [_folder_to_dict(f) for f in tree["folders"]],
            "sessions": [_session_to_dict(s) for s in tree["sessions"]],
        })
    except Exception as e:
        logger.error(f"Failed to load session tree: {e}")
        return JSONResponse({
            "available": False,
            "error": str(e),
            "folders": [],
            "sessions": [],
        }, status_code=500)


@app.get("/api/sessions/search")
async def sessions_search(q: str = ""):
    """Search sessions by name, hostname, or description."""
    store = _get_session_store()
    if not store or not q.strip():
        return JSONResponse({"results": []})

    try:
        results = store.search_sessions(q.strip())
        return JSONResponse({
            "results": [_session_to_dict(s) for s in results],
        })
    except Exception as e:
        logger.error(f"Session search failed: {e}")
        return JSONResponse({"results": [], "error": str(e)})


@app.post("/api/sessions/{session_id}/connect")
async def session_connect(session_id: int):
    """
    Record a connection attempt and return session details.

    The client uses the returned hostname/port to initiate
    the SSH connection through the existing WebSocket flow.
    Increments connect_count and updates last_connected.
    """
    store = _get_session_store()
    if not store:
        return JSONResponse({"error": "No session database"}, status_code=404)

    try:
        session = store.get_session(session_id)
        if not session:
            return JSONResponse({"error": "Session not found"}, status_code=404)

        store.record_connect(session_id)
        return JSONResponse(_session_to_dict(session))
    except Exception as e:
        logger.error(f"Session connect failed: {e}")
        return JSONResponse({"error": str(e)}, status_code=500)


# ── Telemetry Request Queue API ─────────────────────────────

@app.post("/api/telemetry/request")
async def telemetry_request(req: TelemetryRequest):
    """
    Queue a telemetry request from the web UI.

    The desktop wrapper reads from telemetry_request_queue directly
    (same process, thread-safe). In browser-only mode this is a
    no-op — returns 202 but nobody drains the queue.
    """
    payload = req.model_dump()
    telemetry_request_queue.put(payload)
    logger.info(
        f"Telemetry queued: {req.display_name or req.hostname} "
        f"({req.hostname}:{req.port}) "
        f"[queue depth: {telemetry_request_queue.qsize()}]"
    )
    return JSONResponse(
        {"queued": True, "pending": telemetry_request_queue.qsize()},
        status_code=202,
    )


@app.get("/api/telemetry/pending")
async def telemetry_pending():
    """Diagnostic: check queue depth (desktop drains via direct import)."""
    return JSONResponse({
        "pending": telemetry_request_queue.qsize(),
    })


# ── WebSocket Terminal ──────────────────────────────────────

@app.websocket("/ws/terminal/{tab_id}")
async def websocket_terminal(websocket: WebSocket, tab_id: str):
    """
    WebSocket endpoint for a single terminal session.

    Lifecycle:
        1. Client connects WebSocket
        2. Client sends { type: "connect", ... } with SSH credentials
        3. Server establishes SSH, starts output streaming
        4. Client sends input/resize messages
        5. On disconnect, server cleans up
    """
    await websocket.accept()
    stream_task = None
    tag = tab_id[:8]

    try:
        while True:
            data = await websocket.receive_json()
            msg_type = data.get("type")

            if msg_type == "connect":
                # Inject vault credentials if available (secrets stay server-side)
                resolver = None
                try:
                    resolver = get_resolver()
                except Exception:
                    pass  # No vault configured — browser-only mode
                data = resolve_ssh_config(data, resolver)

                # ── Validate auth before attempting SSH ──────────
                has_auth = (
                    data.get("password")
                    or data.get("key_file")
                    or data.get("key_content")
                )

                # Check the credential cache (populated by LLDP neighbor SSH)
                if not has_auth:
                    cache_key = data.get("hostname", "")
                    with _ssh_cred_lock:
                        cached = _ssh_cred_cache.pop(cache_key, None)
                    if cached:
                        logger.info(
                            f"[{tag}] Using cached credentials for "
                            f"{cache_key} (user: {cached.get('username', '?')})"
                        )
                        # Inject cached creds into the connect data
                        for k in ("username", "password", "key_file",
                                  "key_content", "key_passphrase"):
                            if cached.get(k) and not data.get(k):
                                data[k] = cached[k]
                        has_auth = (
                            data.get("password")
                            or data.get("key_file")
                            or data.get("key_content")
                        )

                if not has_auth:
                    # Build a useful error message
                    if data.get("use_vault") or data.get("_vault_resolved"):
                        reason = (
                            "Vault credential has no password or key stored."
                        )
                    elif data.get("credential_name"):
                        reason = (
                            f"Vault credential "
                            f"'{data.get('credential_name')}' not found "
                            f"or has no authentication configured."
                        )
                    else:
                        reason = "No credentials provided."

                    logger.warning(
                        f"[{tag}] No auth for "
                        f"{data.get('username', '?')}@{data.get('hostname')}: "
                        f"{reason}"
                    )

                    # Send auth_required — the browser will show a
                    # credential prompt and re-send the connect message
                    # with manual credentials on the same WebSocket.
                    await websocket.send_json({
                        "type": "auth_required",
                        "message": reason,
                        "hostname": data.get("hostname", ""),
                        "port": int(data.get("port", 22)),
                        "username": data.get("username", ""),
                        "vendor": data.get("vendor_hint", ""),
                        "legacy_mode": data.get("legacy_mode", False),
                    })
                    continue

                config = SSHConfig(
                    hostname=data["hostname"],
                    port=int(data.get("port", 22)),
                    username=data.get("username", ""),
                    password=data.get("password"),
                    key_file=data.get("key_file"),
                    key_content=data.get("key_content"),
                    key_passphrase=data.get("key_passphrase"),
                    legacy_mode=data.get("legacy_mode", False),
                )

                vault_tag = ""
                if data.get("_vault_resolved"):
                    vault_tag = f" [vault:{data.get('_credential_name', '?')}]"

                logger.info(
                    f"[{tag}] Connect: {config.username}@{config.hostname}:{config.port} "
                    f"via {config.auth_method}"
                    f"{' [legacy]' if config.legacy_mode else ''}"
                    f"{vault_tag}"
                )

                success = await ssh_manager.connect(tab_id, config, websocket)

                if success:
                    stream_task = asyncio.create_task(
                        ssh_manager.stream_output(tab_id, websocket)
                    )

            elif msg_type == "input":
                await ssh_manager.send_input(tab_id, data["data"])

            elif msg_type == "resize":
                await ssh_manager.resize(tab_id, data["cols"], data["rows"])

            else:
                logger.warning(f"[{tag}] Unknown message type: {msg_type}")

    except WebSocketDisconnect:
        logger.info(f"[{tag}] WebSocket disconnected")

    except Exception as e:
        logger.error(f"[{tag}] WebSocket error: {e}")

    finally:
        if stream_task and not stream_task.done():
            stream_task.cancel()
            try:
                await stream_task
            except asyncio.CancelledError:
                pass
        await ssh_manager.disconnect(tab_id)


@app.on_event("shutdown")
async def shutdown():
    """Clean up all SSH sessions on server shutdown."""
    logger.info("Shutting down — disconnecting all sessions")
    await ssh_manager.disconnect_all()
    if session_store:
        session_store.close()