import http
import itertools
import json
import logging
import os
import pathlib
import queue
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any, AsyncIterator, cast

from anyio import to_thread
from httpx import HTTPStatusError
from pydantic import ValidationError
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Mount, Route

import fbnconfig
from fbnconfig import log as deployment_log
from fbnconfig import schemagen
from fbnconfig.deploy import run as deploy_run
from fbnconfig.deploy import undump_deployment
from fbnconfig.exceptions import ApiError, ApiStatusError, NotFoundError, QueryError
from fbnconfig.load_module import load_module
from server.api import get_fetch, get_list, get_metadata
from server.deploy_stream import DeployStreamRequest, make_event, redact_event, to_jsonl_line

logger = logging.getLogger("fbnconfig.server")  # creates logger for the server


def _get_request_ids(request: Request) -> tuple[str, str | None]:
    """Return (request_id, correlation_id).

    Uses LUSID Web API header convention: Correlationid.
    """
    correlation_id = request.headers.get("Correlationid")
    # Mimic LUSID's pageInstance style: 128-bit ID as 32 hex chars with hyphens.
    request_id = f"page:fbnconfig-api,pageInstance:{uuid.uuid4()}"
    return request_id, correlation_id


def _configure_logging(level_name: str = "INFO") -> None:
    level = logging.getLevelNamesMapping().get(level_name.upper().strip(), logging.INFO)
    root_logger = logging.getLogger()
    # Checking if handlers is none, to avoid adding duplicate logging handlers
    if not root_logger.handlers:
        logging.basicConfig(level=level)
    else:
        root_logger.setLevel(level)


# Based on environment/need, LOG_LEVEL may be different.
_configure_logging(os.getenv("LOG_LEVEL", "INFO"))
# Disable redundant uvicorn access logs (Structured json logs do it anyway)
logging.getLogger("uvicorn.access").disabled = True


def _json_log(level: int, event: str, **fields):
    log_data = {"timestamp": datetime.now(timezone.utc).isoformat(), "event": event, **fields}
    logger.log(level, json.dumps(log_data))


def get_examples_path():
    return pathlib.Path(__file__).parent.parent / "public_examples" / "examples"


async def homepage(_: Request):
    return JSONResponse({"message": "Hello, Banana!", "status": "success"})


async def health_check(_: Request):
    return JSONResponse({"status": "healthy", "service": "fbnconfig-api"})


async def ready_check(_: Request):
    # Returns 503 until startup event completes, then 200
    started_at = getattr(app.state, "started_at", None)
    if started_at is None:
        return JSONResponse({"status": "not_ready", "service": "fbnconfig-api"}, status_code=503)
    return JSONResponse({
        "status": "ready", "service": "fbnconfig-api", "started_at": started_at.isoformat()
    })


async def get_schema(_):
    return JSONResponse(SCHEMA)


async def invoke(request: Request) -> JSONResponse:
    # Minimal callable endpoint to validate POST routing, JSON parsing, Middleware and Error logging.
    try:
        payload = await request.json()
    except Exception as exc:
        _json_log(
            logging.WARNING,
            "json_parse_failed",
            request_id=getattr(request.state, "request_id", None),
            correlation_id=getattr(request.state, "correlation_id", None),
            error=str(exc),
        )
        return JSONResponse(
            {"status": "error", "detail": "Invalid JSON"},
            status_code=400
        )
    return JSONResponse({"status": "ok", "received": payload})


async def list_examples(request: Request) -> JSONResponse:
    example_folder = get_examples_path()
    files = example_folder.glob("*.py")
    res = [
        {"name": str(f.stem), "path": request.url_for("get_example", example_name=str(f.stem)).path}
        for f in files
    ]
    return JSONResponse(res)


async def get_example(request: Request):
    example_folder = get_examples_path()
    example_name = request.path_params["example_name"] + ".py"
    script_path = example_folder / example_name
    module = load_module(script_path, str(example_folder))
    if getattr(module, "configure", None) is None:
        raise HTTPException(status_code=400, detail="No configure found in " + example_name)
    deployment = module.configure({})
    dump = fbnconfig.dump_deployment(deployment)
    return JSONResponse(dump)


def create_client(lusid_env, token):
    if lusid_env is None or token is None:
        raise HTTPException(status_code=401, detail="No auth header or no X-LUSID-Host")
    client = fbnconfig.create_client(lusid_env, token)
    return client


async def get_log(request: Request):
    client = create_client(request.state.lusid_env, request.state.token)
    deployment_name = request.path_params["deployment_name"]
    log = []
    for line in deployment_log.list_resources_for_deployment(client, deployment_id=deployment_name):
        d = line._asdict()
        d["state"] = vars(d["state"])
        log.append(d)
    return JSONResponse(log)


# ---------------------------------------------------------------------------
# POST /deploy/stream — JSONL streaming deployment endpoint
# ---------------------------------------------------------------------------

# Sentinel value placed on the queue to signal "the worker is done."
# Using None as a sentinel is a common pattern — when the reader sees
# None it knows no more items are coming.
_STREAM_SENTINEL = None

_active_workers: list[threading.Thread] = []
_active_workers_lock = threading.Lock()
_WORKER_DRAIN_TIMEOUT_S = 30


class _DeployCancelledError(Exception):
    """Raised inside the worker when the client disconnects mid-stream."""


def _map_exception(exc: Exception) -> dict:
    """Map an exception to a structured error payload for the JSONL stream.

    We classify known exceptions and return safe, informative messages.
    Unknown exceptions get a generic message — never leak stack traces.
    """
    if isinstance(exc, HTTPStatusError):
        return {
            "errorType": "HTTPStatusError",
            "message": str(exc),
            "httpStatus": exc.response.status_code,
        }
    if isinstance(exc, ApiStatusError):
        return {
            "errorType": "ApiStatusError",
            "message": str(exc),
            "httpStatus": exc.http_exception.response.status_code,
        }
    if isinstance(exc, QueryError):
        return {"errorType": "QueryError", "message": str(exc)}
    if isinstance(exc, NotFoundError):
        return {"errorType": "NotFoundError", "message": exc.message}
    if isinstance(exc, ApiError):
        return {"errorType": "ApiError", "message": str(exc)}
    if isinstance(exc, RuntimeError):
        return {"errorType": "RuntimeError", "message": str(exc)}
    if isinstance(exc, ValidationError):
        return {"errorType": "ValidationError", "message": str(exc)}
    if isinstance(exc, KeyError):
        return {"errorType": "KeyError", "message": str(exc)}
    # Generic fallback — don't expose internals
    return {"errorType": "InternalError", "message": "An internal error occurred"}


async def deploy_stream(request: Request):
    """Stream a deployment as JSONL events.

    Flow:
    1. Validate auth + request body (errors → normal HTTP status codes)
    2. Start a worker thread that runs the synchronous deploy.run()
    3. Return a StreamingResponse that yields JSONL lines from a queue
    """

    # --- Auth check (same pattern as other endpoints) ---
    if request.state.token is None or request.state.lusid_env is None:
        return JSONResponse(
            {"detail": "No auth header or no X-LUSID-Host"},
            status_code=401,
        )

    # --- Build the set of secrets to redact from streamed output ---
    secrets: set[str] = set()
    if request.state.token:
        secrets.add(request.state.token)
    vso_header = request.headers.get("x-fbn-verified-service-origin")
    if vso_header:
        secrets.add(vso_header)

    # --- Parse & validate request body ---
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"detail": "Invalid JSON"}, status_code=400)

    try:
        req = DeployStreamRequest.model_validate(body)
    except ValidationError as exc:
        return JSONResponse({"detail": exc.errors()}, status_code=422)

    # --- Extract values we'll use throughout the stream ---
    deployment_id = req.deployment.deployment_id
    request_id = request.state.request_id
    correlation_id = request.state.correlation_id

    _json_log(
        logging.INFO,
        "deploy_stream_started",
        request_id=request_id,
        correlation_id=correlation_id,
        deployment_id=deployment_id,
    )
    stream_start = time.perf_counter()

    # --- Build the Deployment object from the request payload ---
    try:
        deployment_payload = {
            "deploymentId": req.deployment.deployment_id,
            "resources": req.deployment.resources,
        }
        deployment_obj = undump_deployment(deployment_payload)
    except (KeyError, ValidationError) as exc:
        return JSONResponse(
            {"detail": f"Failed to parse deployment: {exc}"},
            status_code=422,
        )
    except Exception:
        return JSONResponse(
            {"detail": "Failed to parse deployment payload"},
            status_code=422,
        )

    # --- Set up the queue, cancellation event, and sequence counter ---
    # A bounded queue.Queue is thread-safe — the worker thread puts events
    # on it, and the async generator reads them off.  The maxsize applies
    # backpressure: if the consumer falls behind, the worker blocks on put()
    # rather than letting memory grow without limit.
    event_queue: queue.Queue[dict | None] = queue.Queue(maxsize=256)
    # The cancelled event is set by the async generator when the client
    # disconnects.  The worker checks it between resource actions so it
    # can stop early instead of finishing a deployment nobody is listening to.
    cancelled = threading.Event()
    seq = itertools.count(1)  # Thread-safe monotonic counter starting at 1

    def next_seq() -> int:
        """Return the next sequence number (monotonically increasing from 1)."""
        return next(seq)

    def enqueue_event(*, event_type: str, status: str, data: dict | None = None):
        """Build an event, redact it, and put it on the queue.

        If the client has disconnected (cancelled is set), silently drops
        the event instead of blocking on a full queue that nobody reads.
        """
        if cancelled.is_set():
            return
        event = make_event(
            deployment_id=deployment_id,
            request_id=request_id,
            correlation_id=correlation_id,
            event_type=event_type,
            status=status,
            sequence=next_seq(),
            data=data,
        )
        event_queue.put(redact_event(event, secrets))

    # --- Worker function (runs in a daemon thread) ---
    def worker():
        """Run the deployment and emit events.

        This runs in a separate thread because deploy.run() is synchronous
        and would block the async event loop if called directly.
        """
        try:
            # Create a fresh httpx client for this deployment
            client = fbnconfig.create_client(request.state.lusid_env, request.state.token)

            enqueue_event(event_type="deployment_status", status="pending")
            enqueue_event(event_type="deployment_status", status="running")

            worker_start = time.perf_counter()

            # The event_cb is called by deploy.run() for each resource action.
            # It checks the cancelled event — if the client has disconnected
            # we raise to abort the deployment at the next resource boundary.
            def event_cb(evt: dict):
                if cancelled.is_set():
                    raise _DeployCancelledError()
                enqueue_event(
                    event_type=evt["eventType"],
                    status=evt["status"],
                    data=evt["data"],
                )

            actions = deploy_run(client, deployment_obj, event_cb=event_cb)

            # Build summary from the actions list
            duration_ms = round(
                (time.perf_counter() - worker_start) * 1000, 3
            )
            action_counts: dict[str, int] = {}
            for action in actions:
                action_counts[action.change] = action_counts.get(action.change, 0) + 1

            enqueue_event(
                event_type="summary",
                status="success",
                data={"actions": action_counts, "durationMs": duration_ms},
            )
            enqueue_event(event_type="deployment_status", status="success")

        except _DeployCancelledError:
            _json_log(
                logging.INFO,
                "deploy_stream_cancelled",
                request_id=request_id,
                correlation_id=correlation_id,
                deployment_id=deployment_id,
            )

        except Exception as exc:
            error_data = _map_exception(exc)
            enqueue_event(event_type="error", status="failure", data=error_data)
            enqueue_event(event_type="deployment_status", status="failure")

        finally:
            # Signal "no more events" to the reader
            event_queue.put(_STREAM_SENTINEL)
            with _active_workers_lock:
                if thread in _active_workers:
                    _active_workers.remove(thread)

    # --- Async generator that reads from the queue ---
    async def event_generator() -> AsyncIterator[str]:
        """Yield JSONL lines until the worker signals completion.

        anyio.to_thread.run_sync(event_queue.get) is the bridge between
        the sync queue and the async world — it runs queue.get() in a
        thread pool so it doesn't block the event loop.

        The finally block sets the cancelled event so the worker thread
        knows to stop if the client disconnects mid-stream.
        """
        try:
            while True:
                event = await to_thread.run_sync(event_queue.get)
                if event is None:
                    break
                yield to_jsonl_line(event)
        finally:
            cancelled.set()
            duration_ms = round(
                (time.perf_counter() - stream_start) * 1000, 3
            )
            _json_log(
                logging.INFO,
                "deploy_stream_finished",
                request_id=request_id,
                correlation_id=correlation_id,
                deployment_id=deployment_id,
                duration_ms=duration_ms,
            )

    # --- Start the worker thread and return the streaming response ---
    thread = threading.Thread(target=worker)
    thread.start()
    with _active_workers_lock:
        _active_workers.append(thread)

    return StreamingResponse(
        event_generator(),
        media_type="application/x-ndjson",
        headers={
            "Cache-Control": "no-store",
            "X-Accel-Buffering": "no",
        },
    )


SCHEMA = schemagen.cmd_deployment_schema()


def not_found_error_handler(request, exc: NotFoundError):
    return JSONResponse(
        status_code=http.HTTPStatus.NOT_FOUND,
        content={"detail": exc.message},
    )


def validation_error_handler(request, exc: ValidationError):
    return JSONResponse(
        status_code=http.HTTPStatus.UNPROCESSABLE_ENTITY,
        content={"detail": exc.errors()},
    )


def http_status_exception_handler(request, exc: HTTPStatusError):
    return JSONResponse(
        status_code=exc.response.status_code,
        content={"detail": exc.response.text} if exc.response.status_code not in [
            http.HTTPStatus.NOT_FOUND, http.HTTPStatus.UNAUTHORIZED
        ] else None,
    )


# Wrapping every request to capture timing, add tracing IDs, and log structured data.
class RequestLogger(BaseHTTPMiddleware):

    # Constants for duration calculation
    MS_IN_SECOND = 1000
    DURATION_PRECISION = 3

    async def dispatch(self, request: Request, call_next):

        # Extract existing IDs from headers or generate new ones for traceability
        request_id, correlation_id = _get_request_ids(request)

        # Store IDs in request state so downstream handlers can access them
        request.state.request_id = request_id
        request.state.correlation_id = correlation_id

        # Record start time for duration calculation
        start_time = time.perf_counter()

        try:
            response = await call_next(request)
        except Exception as exc:
            # Log the error before re-raising so we capture failed requests
            self._log_request(
                level=logging.ERROR,
                event="request_error",
                request=request,
                request_id=request_id,
                correlation_id=correlation_id,
                error=str(exc),
                start_time=start_time
            )
            raise

        # Add correlation ID to response headers for client-side tracing
        if correlation_id:
            response.headers["Correlationid"] = correlation_id

        # Log successful request completions
        self._log_request(
            level=logging.INFO,
            event="request_complete",
            request=request,
            request_id=request_id,
            correlation_id=correlation_id,
            status=response.status_code,
            start_time=start_time
        )
        return response

    def _log_request(self, *, level, event, request, request_id, correlation_id, start_time, **extra):
        """Log request details in structured JSON for observability."""
        duration_ms = round(
            (time.perf_counter() - start_time) * self.MS_IN_SECOND, self.DURATION_PRECISION
        )
        _json_log(
            level,
            event,
            request_id=request_id,
            correlation_id=correlation_id,
            method=request.method,
            path=request.url.path,
            duration_ms=duration_ms,
            **extra
        )


class PassThruAuth(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        auth = request.headers.get("Authorization")
        if auth and auth.startswith("Bearer "):
            request.state.token = auth.split(" ")[1]
        else:
            request.state.token = None
        host = request.headers.get("X-LUSID-Host")
        request.state.lusid_env = host if host else None
        return await call_next(request)


middleware = [Middleware(RequestLogger), Middleware(PassThruAuth)]
routes = [
    Route("/", homepage),
    Route("/health", health_check),
    Route("/ready", ready_check),
    Route("/invoke", invoke, methods=["POST"]),
    Route("/examples/", list_examples),
    Route("/examples/{example_name}", get_example),
    Route("/log/{deployment_name}", get_log),
    Route("/schema", get_schema, methods=["GET"]),

    Route("/resources/metadata", get_metadata, methods=["GET"]),
    Route("/resources/{resource}/list", get_list, methods=["POST"]),
    Route("/resources/{resource}/fetch", get_fetch, methods=["POST"]),
    Route("/deploy/stream", deploy_stream, methods=["POST"]),
]

# the fbnconfig application
cfg_app = Starlette(
    routes=routes,
    middleware=middleware,
    exception_handlers=cast(Any, {
        NotFoundError: not_found_error_handler,
        ValidationError: validation_error_handler,
        HTTPStatusError: http_status_exception_handler,
    }),
)

# the app that will be run to mount the cfg app
app = Starlette(routes=[
    Mount("/api/fbnconfig", cfg_app)
])


@app.router.on_event("startup")
async def startup():
    """Record startup time and log structured event for observability."""
    app.state.started_at = datetime.now(timezone.utc)
    try:
        _json_log(
            logging.INFO,
            "app_startup",
            timestamp=app.state.started_at.isoformat(),
            version=getattr(fbnconfig, "__version__", "unknown")
        )
    except Exception as exc:
        logger.error(f"Startup logging failed: {exc}")


@app.router.on_event("shutdown")
async def shutdown():
    """Drain active deployment workers before shutting down."""
    with _active_workers_lock:
        threads = list(_active_workers)

    if threads:
        _json_log(
            logging.INFO,
            "app_shutdown_draining",
            active_workers=len(threads),
            timeout_s=_WORKER_DRAIN_TIMEOUT_S,
        )
        for t in threads:
            t.join(timeout=_WORKER_DRAIN_TIMEOUT_S)

    try:
        _json_log(
            logging.INFO,
            "app_shutdown",
            timestamp=datetime.now(timezone.utc).isoformat(),
        )
    except Exception as exc:
        logger.error(f"Shutdown logging failed: {exc}")
