"""
BIBIVIDI — Core Proxy
Intercepts HTTP requests destined for AI APIs,
runs the matching engine, and forwards (possibly modified) requests.
Supports both buffered and streaming (SSE) responses.

Event bus
─────────
  proxy() ──push──► EventBus ──► N asyncio.Queue subscribers
                                    │
                              GET /bibividi/events (SSE)
                              bibividi tail
                              /bibividi/ui live feed
"""
from __future__ import annotations

import asyncio
import json
import logging
import time
import uuid
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from typing import Any

import httpx
from fastapi import FastAPI, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles

from bvd.config import settings
from bvd.engine.generator import generator
from bvd.engine.matcher import AssemblyResult, MatchResult, matcher
from bvd.engine.runner import run_chain, run_script
from bvd.engine.stats import stats_tracker

logger = logging.getLogger(__name__)


# ── Event bus ─────────────────────────────────────────────────────────────────

@dataclass
class RequestEvent:
    ts: str
    host: str
    path: str
    matched: bool
    skill_id: str | None
    score: float | None
    tokens_before: int
    tokens_after: int
    saving_pct: float
    latency_ms: float
    status: int
    request_id: str = ""


class EventBus:
    """
    Fan-out pub/sub: proxy() pushes RequestEvents, SSE subscribers each get
    their own asyncio.Queue.  Max 50 subscribers; max 100 queued events each.
    """
    MAX_SUBS = 50
    MAX_QUEUE = 100

    def __init__(self) -> None:
        self._queues: list[asyncio.Queue[RequestEvent]] = []

    def subscribe(self) -> asyncio.Queue[RequestEvent] | None:
        if len(self._queues) >= self.MAX_SUBS:
            return None
        q: asyncio.Queue[RequestEvent] = asyncio.Queue(maxsize=self.MAX_QUEUE)
        self._queues.append(q)
        return q

    def unsubscribe(self, q: asyncio.Queue[RequestEvent]) -> None:
        try:
            self._queues.remove(q)
        except ValueError:
            pass

    def push(self, event: RequestEvent) -> None:
        for q in self._queues:
            try:
                q.put_nowait(event)
            except asyncio.QueueFull:
                pass  # slow subscriber — drop rather than block


event_bus = EventBus()

# Cached tiktoken encoder (cl100k_base ≈ GPT-4 / Claude tokenizer)
_encoder = None


def _estimate_tokens(text: str) -> int:
    """Token count using tiktoken (cl100k_base). Falls back to len//4."""
    global _encoder
    try:
        if _encoder is None:
            import tiktoken
            _encoder = tiktoken.get_encoding("cl100k_base")
        return max(1, len(_encoder.encode(text)))
    except Exception:
        return max(1, len(text) // 4)


# ── Lifespan ──────────────────────────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):  # type: ignore[type-arg]
    logger.info("BIBIVIDI proxy starting on %s:%d", settings.proxy_host, settings.proxy_port)
    from pathlib import Path

    from bvd.engine.library import library
    from bvd.notifier import digest_scheduler
    await library.init()
    ui_dist = Path(__file__).parent.parent / "ui" / "dist"
    if ui_dist.exists():
        app.mount("/bibividi/ui", StaticFiles(directory=str(ui_dist), html=True), name="ui")
        logger.info("Web UI mounted at /bibividi/ui")
    else:
        logger.warning("Web UI dist not found — run: cd bvd/ui && npm run build")
    _digest_task = asyncio.create_task(digest_scheduler())
    yield
    _digest_task.cancel()
    global _client
    if _client and not _client.is_closed:
        await _client.aclose()
    logger.info("BIBIVIDI proxy stopped")


app = FastAPI(
    title="BIBIVIDI Proxy",
    version="0.1.0",
    docs_url="/bibividi/docs",
    redoc_url=None,
    lifespan=lifespan,
)

from bvd.core import api as _api  # noqa: E402

app.include_router(_api.router)

# Shared async HTTP client (reused across requests)
_client: httpx.AsyncClient | None = None


async def get_client() -> httpx.AsyncClient:
    global _client
    if _client is None or _client.is_closed:
        _client = httpx.AsyncClient(
            timeout=httpx.Timeout(60.0),
            follow_redirects=True,
        )
    return _client


# ── Health check ──────────────────────────────────────────────────────────────

@app.get("/bibividi/health")
async def health() -> dict[str, Any]:
    return {
        "status": "ok",
        "proxy": f"{settings.proxy_host}:{settings.proxy_port}",
        "targets": settings.target_apis,
        "savings_total_tokens": await stats_tracker.total_saved(),
    }


# ── Live event stream (SSE) ───────────────────────────────────────────────────

@app.get("/bibividi/events")
async def events() -> StreamingResponse:
    """
    Server-Sent Events stream of proxy request events.
    Used by 'bibividi tail' and the /bibividi/ui live feed.
    """
    q = event_bus.subscribe()
    if q is None:
        return StreamingResponse(
            iter([b"data: {\"error\": \"too many subscribers\"}\n\n"]),
            status_code=503,
            media_type="text/event-stream",
        )

    async def _stream():
        try:
            yield b"data: {\"type\": \"connected\"}\n\n"
            while True:
                event = await q.get()
                payload = json.dumps(asdict(event))
                yield f"data: {payload}\n\n".encode()
        finally:
            event_bus.unsubscribe(q)

    return StreamingResponse(
        _stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",   # disable nginx buffering
        },
    )


# ── Main catch-all proxy handler ──────────────────────────────────────────────

@app.api_route(
    "/{path:path}",
    methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy(request: Request, path: str) -> Response:
    """
    Intercept every request:
    1. Check host against BIBIVIDI_TARGET_APIS allowlist — 403 if not matched (SSRF guard)
    2. Try to match a script from the library
    3. If match → run script, inject condensed context
    4. Forward (modified or original) request to the real API
       - streaming=True  → StreamingResponse (SSE proxied chunk-by-chunk)
       - streaming=False → buffered Response
    5. Record input-token savings
    """
    t_start = time.monotonic()
    request_id = str(uuid.uuid4())

    host = request.headers.get("host", "")
    is_ai_target = any(t in host for t in settings.target_apis)

    # SSRF guard: only forward to known AI API hosts.
    # Reject anything else — prevents forwarding to internal services
    # (e.g. 169.254.169.254, localhost:5432) via a crafted Host header.
    # Exception: allow_local_apis=true lets Ollama users point to localhost.
    if not is_ai_target and settings.allow_local_apis:
        is_ai_target = any(h in host for h in ("localhost", "127.0.0.1", "::1"))
    if not is_ai_target:
        logger.warning("Blocked request to non-allowlisted host: %s", host)
        return Response(
            content=b"BIBIVIDI: host not in BIBIVIDI_TARGET_APIS allowlist",
            status_code=403,
        )

    body_bytes = await request.body()

    # ── Parse JSON body ───────────────────────────────────────
    try:
        body = json.loads(body_bytes)
    except (json.JSONDecodeError, ValueError):
        return await _forward(request, path, body_bytes)

    is_streaming = body.get("stream") is True

    model_name: str = body.get("model", "")

    prompt_text = _extract_prompt(body)
    if not prompt_text:
        return await _forward(request, path, body_bytes, streaming=is_streaming)

    if settings.log_requests:
        logger.debug("Intercepted prompt (%d chars): %s…", len(prompt_text), prompt_text[:120])

    # ── Match ─────────────────────────────────────────────────
    match: MatchResult | None = await matcher.match(prompt_text)

    tokens_before = _estimate_tokens(prompt_text)
    tokens_after = tokens_before

    if match:
        # Pending skills are embedded in the index but must not execute.
        # Treat as no-match so the original request passes through cleanly and
        # stats are not recorded against the pending skill's ID.
        if match.script.status == "pending":
            logger.info(
                "Skill %s matched but is pending approval — passing through. "
                "Run 'bibividi review' to inspect and approve.",
                match.script_id,
            )
            match = None
        else:
            logger.info("Match found: %s (score=%.3f)", match.script_id, match.score)
            try:
                if match.script.is_chain and settings.enable_chains:
                    from bvd.engine.library import library as _library
                    step_scripts = await _library.get_many(match.script.chain_ids)
                    condensed = (await run_chain(match.script, prompt_text, step_scripts)).output
                else:
                    condensed = (await run_script(match.script, prompt_text)).output
                tokens_after = _estimate_tokens(condensed)
                negative_saving = tokens_after >= tokens_before
                if negative_saving:
                    logger.warning(
                        "Skill %s produced no saving (%d → %d tokens) — passing through unchanged",
                        match.script_id,
                        tokens_before,
                        tokens_after,
                    )
                    tokens_after = tokens_before  # record as neutral, not negative
                elif settings.dry_run:
                    logger.info(
                        "[DRY RUN] Would have condensed: %d → %d tokens (%.0f%% saving) via %s",
                        tokens_before,
                        tokens_after,
                        (1 - tokens_after / tokens_before) * 100,
                        match.script_id,
                    )
                else:
                    body = _inject_result(body, condensed)
                    body_bytes = json.dumps(body).encode()
                    logger.info(
                        "Tokens: %d → %d (saved %.0f%%)",
                        tokens_before,
                        tokens_after,
                        (1 - tokens_after / tokens_before) * 100,
                    )
            except Exception as exc:
                logger.warning("Script execution failed (%s), passing through", exc)
    else:
        # ── BSS 2.0: auto-assembled type pipeline ─────────────────────────
        assembly: AssemblyResult | None = None
        if settings.enable_auto_assembly:
            assembly = await matcher.assemble_pipeline(prompt_text)

        if assembly:
            logger.info(
                "Auto-assembled %d-skill pipeline: %s → plain_text (%s)",
                len(assembly.pipeline),
                assembly.input_type,
                " → ".join(s.id for s in assembly.pipeline),
            )
            try:
                from bvd.engine.library import Script as _Script
                _chain = _Script(id="_auto", name="Auto-assembled", description="", code="")
                condensed = (
                    await run_chain(
                        _chain, prompt_text, assembly.pipeline,  # type: ignore[arg-type]
                        bypass_feature_flag=True,
                    )
                ).output
                tokens_after = _estimate_tokens(condensed)
                if tokens_after < tokens_before:
                    if settings.dry_run:
                        logger.info(
                            "[DRY RUN] Auto-assembled %s pipeline: %d → %d tokens (%.0f%% saving)",
                            assembly.input_type,
                            tokens_before,
                            tokens_after,
                            (1 - tokens_after / tokens_before) * 100,
                        )
                    else:
                        body = _inject_result(body, condensed)
                        body_bytes = json.dumps(body).encode()
                        logger.info(
                            "Auto-assembly tokens: %d → %d (saved %.0f%%) via %s pipeline",
                            tokens_before,
                            tokens_after,
                            (1 - tokens_after / tokens_before) * 100,
                            assembly.input_type,
                        )
                else:
                    tokens_after = tokens_before
            except Exception as exc:
                logger.warning("Auto-assembly execution failed (%s), passing through", exc)
        else:
            logger.debug("No match for prompt, passing through")
            if settings.generator_enabled:
                asyncio.create_task(generator.maybe_generate(prompt_text))

    response = await _forward(request, path, body_bytes, streaming=is_streaming)

    # ── Record stats + push live event ───────────────────────────────────────
    elapsed_ms = (time.monotonic() - t_start) * 1000
    if settings.track_savings:
        from bvd.billing import model_price_eur
        price = model_price_eur(model_name) if model_name else None
        try:
            execution_id = await stats_tracker.record(
                script_id=match.script_id if match else None,
                tokens_before=tokens_before,
                tokens_after=tokens_after,
                latency_ms=elapsed_ms,
                request_id=request_id,
                model_name=model_name or None,
                model_price_per_token_eur=price,
            )
        except Exception as exc:
            logger.warning("Failed to record stats (DB error?): %s", exc)
            execution_id = None
        # Fire marketplace payout rows for paid chain executions
        if (
            match
            and execution_id is not None
            and match.script.is_chain
            and match.script.is_paid
        ):
            asyncio.create_task(
                _record_chain_payouts(match.script, execution_id)
            )

    saving_pct = (1 - tokens_after / tokens_before) * 100 if tokens_before else 0.0
    event_bus.push(RequestEvent(
        ts=datetime.now(UTC).isoformat(),
        host=host,
        path=f"/{path}",
        matched=match is not None,
        skill_id=match.script_id if match else None,
        score=round(match.score, 3) if match else None,
        tokens_before=tokens_before,
        tokens_after=tokens_after,
        saving_pct=round(saving_pct, 1),
        latency_ms=round(elapsed_ms, 1),
        status=response.status_code,
        request_id=request_id,
    ))

    response.headers["X-BIBIVIDI-Request-ID"] = request_id
    return response


# ── Forward helpers ───────────────────────────────────────────────────────────

_HOP_BY_HOP_REQUEST = {
    "host", "content-length", "transfer-encoding",
    "connection", "proxy-connection", "keep-alive",
}
_HOP_BY_HOP_RESPONSE = {"transfer-encoding", "connection"}


_LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")


def _build_upstream_url(request: Request, path: str) -> str:
    host = request.headers.get("host", "")
    if request.url.hostname and request.url.hostname not in (
        settings.proxy_host, "localhost", "127.0.0.1"
    ):
        host = request.url.hostname
    # Use plain HTTP for local targets (Ollama etc.) to avoid TLS errors
    scheme = (
        "http"
        if settings.allow_local_apis and any(h in host for h in _LOCAL_HOSTS)
        else "https"
    )
    url = f"{scheme}://{host}/{path}"
    if request.url.query:
        url += f"?{request.url.query}"
    return url


def _strip_request_headers(request: Request) -> dict[str, str]:
    return {
        k: v for k, v in request.headers.items()
        if k.lower() not in _HOP_BY_HOP_REQUEST
    }


async def _forward(
    request: Request,
    path: str,
    body: bytes,
    streaming: bool = False,
) -> Response:
    """Forward to the real API — buffered or streaming."""
    client = await get_client()
    target_url = _build_upstream_url(request, path)
    headers = _strip_request_headers(request)

    logger.debug("Forwarding → %s (streaming=%s)", target_url, streaming)

    if streaming:
        return await _forward_streaming(client, request.method, target_url, headers, body)

    try:
        resp = await client.request(
            method=request.method,
            url=target_url,
            headers=headers,
            content=body,
        )
    except httpx.RequestError as exc:
        logger.error("Upstream request failed: %s", exc)
        return Response(content=f"BIBIVIDI proxy error: {exc}".encode(), status_code=502)

    resp_headers = {
        k: v for k, v in resp.headers.items()
        if k.lower() not in _HOP_BY_HOP_RESPONSE
    }
    return Response(content=resp.content, status_code=resp.status_code, headers=resp_headers)


async def _forward_streaming(
    client: httpx.AsyncClient,
    method: str,
    url: str,
    headers: dict[str, str],
    body: bytes,
) -> StreamingResponse:
    """Forward a streaming request; proxies SSE chunks in real-time."""
    try:
        req = client.build_request(method, url, headers=headers, content=body)
        resp = await client.send(req, stream=True)
    except httpx.RequestError as exc:
        logger.error("Upstream streaming request failed: %s", exc)
        err_msg = f"BIBIVIDI proxy error: {exc}".encode()

        async def _error():
            yield err_msg

        return StreamingResponse(_error(), status_code=502)

    resp_headers = {
        k: v for k, v in resp.headers.items()
        # Also strip content-encoding — we don't re-compress chunks
        if k.lower() not in _HOP_BY_HOP_RESPONSE | {"content-encoding"}
    }

    async def _stream_body():
        try:
            async for chunk in resp.aiter_bytes():
                yield chunk
        except Exception as exc:
            logger.warning("Streaming body error: %s", exc)
        finally:
            await resp.aclose()

    return StreamingResponse(
        _stream_body(),
        status_code=resp.status_code,
        headers=resp_headers,
        media_type=resp.headers.get("content-type", "text/event-stream"),
    )


# ── Prompt helpers ────────────────────────────────────────────────────────────

def _extract_prompt(body: dict) -> str:
    """Extract the user's prompt text from an AI API request body.

    Supports:
    - Gemini:   contents[].parts[].text  (role: "user" or absent)
    - OpenAI:   messages[].content       (role: "user", string or list)
    - Anthropic: messages[].content      (role: "user", string or list of blocks)
    - Legacy:   prompt field
    """
    # Gemini format: contents[].parts[].text
    contents = body.get("contents", [])
    if contents:
        for item in reversed(contents):
            if item.get("role") in ("user", None):
                texts = [p.get("text", "") for p in item.get("parts", []) if "text" in p]
                joined = " ".join(texts).strip()
                if joined:
                    return joined

    # OpenAI / Anthropic format: messages[].content
    messages = body.get("messages", [])
    if messages:
        for msg in reversed(messages):
            if msg.get("role") == "user":
                content = msg.get("content", "")
                if isinstance(content, str):
                    return content
                if isinstance(content, list):
                    return " ".join(
                        block.get("text", "")
                        for block in content
                        if block.get("type") == "text"
                    )
    return body.get("prompt", "")


async def _record_chain_payouts(chain_script: object, execution_id: int) -> None:
    """Background task: compute and persist marketplace payout rows."""
    try:
        from bvd.engine.library import library as _library
        from bvd.marketplace.payouts import compute_author_split, record_execution_payouts

        dep_ids = getattr(chain_script, "chain_ids", [])
        dep_scripts = await _library.get_many(dep_ids)
        split = compute_author_split(chain_script, dep_scripts)
        await record_execution_payouts(execution_id, split)
    except Exception as exc:
        logger.warning("Marketplace payout recording failed: %s", exc)


def _inject_result(body: dict, condensed: str) -> dict:
    """Replace the last user message content with the condensed result."""
    wrapped = (
        "[BIBIVIDI: pre-processed]\n"
        + condensed
        + "\n[Original request condensed by BIBIVIDI]"
    )

    # Gemini format: contents[].parts[].text
    contents = body.get("contents", [])
    if contents:
        for i in range(len(contents) - 1, -1, -1):
            if contents[i].get("role") in ("user", None):
                contents[i]["parts"] = [{"text": wrapped}]
                break
        body["contents"] = contents
        return body

    # OpenAI / Anthropic format: messages[].content
    messages = body.get("messages", [])
    if not messages:
        return body
    for i in range(len(messages) - 1, -1, -1):
        if messages[i].get("role") == "user":
            messages[i]["content"] = wrapped
            break
    body["messages"] = messages
    return body
