import asyncio
import base64
import json
import logging
import os
import uuid
from contextlib import suppress
from datetime import datetime
from typing import Optional

from fastapi import FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel

from audit_store import get_audit_store
from docs_store import get_document_store
from geoip import get_ip_location_text
from job_handlers import (
    _sanitize_converted_markdown,
    _infer_convert_suffix,
    sanitize_inline_completion_content,
    ALLOWED_CONVERT_EXTENSIONS,
    asr_handler,
    completion_handler,
    compress_handler,
    convert_handler,
    ocr_handler,
    pro_completion_handler,
    tts_handler,
    web_search_handler,
)
from job_system import (
    InMemoryJobManager,
    JobSystemError,
    JOB_TYPES,
    QueueFullError,
    RedisJobManager,
    get_job_manager,
    persist_temp_input,
)
from llm_policy import resolve_llm_policy
from models import UserPreferences
from risk_config import load_risk_config
from risk_control import RiskDecision, RiskIdentity, RiskRejected, estimate_tokens, get_risk_controller, stable_hash
from session_store import get_session_store

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("api")
config = load_risk_config()

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=list(config.cors_allow_origins),
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
)

API_KEY = os.getenv("API_KEY", "your-secret-key-here")
DOC_COMPRESS_CONTEXT_LIMIT = int(os.getenv("DOC_COMPRESS_CONTEXT_LIMIT", "128000"))
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
_handlers_registered = False


class CompletionRequest(BaseModel):
    prefix: str
    suffix: str
    languageId: str = "markdown"
    model_thinking: str = "low"
    privacy_mode: bool = False
    user_preferences: Optional[UserPreferences] = None
    model: Optional[str] = None
    temperature: float = 0.7


class ProCompletionRequest(BaseModel):
    prefix: str
    suffix: str
    languageId: str = "markdown"
    instruction: str = ""
    pro_thinking: str = "medium"
    privacy_mode: bool = False
    user_preferences: Optional[UserPreferences] = None


class WebSearchRequest(BaseModel):
    prefix: str
    suffix: str
    languageId: str = "markdown"
    privacy_mode: bool = False
    user_preferences: Optional[UserPreferences] = None


class CancelCompletionRequest(BaseModel):
    request_id: str
    reason: str = "abort"


class OCRRequest(BaseModel):
    image: str
    filename: str = "image.jpg"
    language: str = "auto"
    media_type: str = "image"
    mime_type: str | None = None


class ConvertRequest(BaseModel):
    file: str
    filename: str = "document.pdf"


class CompressRequest(BaseModel):
    content: str
    docType: str = "txt"


class TTSJobRequest(BaseModel):
    text: str
    instruct: str = ""
    speaker: str = "Vivian"
    format: str = "wav"


class ASRJobRequest(BaseModel):
    audio_base64: str
    language: Optional[str] = "zh-CN"


class CreateFolderRequest(BaseModel):
    name: str
    parentId: Optional[str] = None


class CreateTextFileRequest(BaseModel):
    name: str
    parentId: Optional[str] = None
    content: str = ""


class UpdateNodeRequest(BaseModel):
    name: Optional[str] = None
    parentId: Optional[str] = None
    content: Optional[str] = None


def _preview(text: str, limit: int = 80) -> str:
    value = (text or "").replace("\n", "\\n")
    if len(value) <= limit:
        return value
    return value[:limit] + "..."


def get_client_ip(request: Request) -> str:
    if request.client:
        return request.headers.get("X-Client-IP") or request.client.host
    return request.headers.get("X-Client-IP") or "unknown"


def _clamp_temperature(value: float, default: float = 0.7) -> float:
    try:
        numeric = float(value)
    except (TypeError, ValueError):
        return default
    return max(0.0, min(numeric, 1.2))


async def get_api_key(api_key: str = Security(api_key_header)):  # pragma: no cover
    if api_key is not None and api_key != API_KEY:
        raise HTTPException(status_code=403, detail="Could not validate credentials")
    return api_key


@app.middleware("http")
async def attach_anonymous_session(request: Request, call_next):
    client_ip_hash = stable_hash(get_client_ip(request))
    user_agent_hash = stable_hash(request.headers.get("user-agent", ""))
    store = get_session_store(os.getenv("DATABASE_URL", "").strip() or None)
    session_id = request.cookies.get(config.session_cookie_name)
    session = await asyncio.to_thread(
        store.get_or_create,
        session_id,
        client_ip_hash=client_ip_hash,
        user_agent_hash=user_agent_hash,
    )
    request.state.session = session
    request.state.client_ip_hash = client_ip_hash
    request.state.user_agent_hash = user_agent_hash
    response = await call_next(request)
    response.set_cookie(
        key=config.session_cookie_name,
        value=session.session_id,
        max_age=config.session_cookie_max_age,
        httponly=True,
        secure=config.session_cookie_secure,
        samesite=config.session_cookie_samesite,
        domain=config.session_cookie_domain,
        path=config.session_cookie_path,
    )
    return response


def _serialize_preferences(preferences: UserPreferences | None) -> dict | None:
    if preferences is None:
        return None
    if hasattr(preferences, "model_dump"):
        return preferences.model_dump()
    if hasattr(preferences, "dict"):
        return preferences.dict()
    return dict(preferences)


def _request_id(request: Request) -> str:
    return request.headers.get("X-Request-Id") or str(uuid.uuid4())


def _request_identity(request: Request) -> RiskIdentity:
    session = getattr(request.state, "session")
    return RiskIdentity(
        request_id=_request_id(request),
        session_hash=session.session_hash,
        ip_hash=request.state.client_ip_hash,
        route=request.url.path,
        method=request.method,
    )


async def _record_api_audit(
    identity: RiskIdentity,
    *,
    decision: str,
    status_code: int,
    delay_ms: int = 0,
    error_code: str = "",
    metadata: dict | None = None,
) -> None:
    store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
    await asyncio.to_thread(
        store.record_api_request,
        {
            "request_id": identity.request_id,
            "session_hash": identity.session_hash,
            "ip_hash": identity.ip_hash,
            "route": identity.route,
            "method": identity.method,
            "status_code": status_code,
            "decision": decision,
            "delay_ms": delay_ms,
            "error_code": error_code,
            "metadata": metadata or {},
        },
    )


def _risk_json_response(identity: RiskIdentity, decision: RiskDecision) -> JSONResponse:
    payload = {
        "request_id": identity.request_id,
        "error_code": decision.error_code or "request_rejected",
        "message": decision.reason or "request rejected",
    }
    if decision.retry_after_seconds > 0:
        payload["retry_after_seconds"] = decision.retry_after_seconds
    return JSONResponse(payload, status_code=decision.status_code)


async def _authorize_request(
    request: Request,
    api_key: str | None = Security(api_key_header),
) -> dict:
    del request
    if api_key is not None and api_key != API_KEY:
        raise HTTPException(status_code=403, detail="Could not validate credentials")
    return {"api_key_authenticated": bool(api_key == API_KEY)}


def _register_handlers() -> None:
    """注册所有任务处理器。每次调用都会重新获取当前 manager 实例并强制注册，
    确保 Redis 重连或实例重建后处理器不会丢失。"""
    global _handlers_registered
    manager = get_job_manager()
    # 强制清空旧 handlers，避免重复注册累积；测试替身只暴露 register_handler。
    handlers = getattr(manager, "handlers", None)
    if handlers is not None:
        handlers.clear()
    manager.register_handler("completion", completion_handler)
    manager.register_handler("pro_completion", pro_completion_handler)
    manager.register_handler("web_search", web_search_handler)
    manager.register_handler("compress", compress_handler)
    manager.register_handler("ocr", ocr_handler)
    manager.register_handler("convert", convert_handler)
    manager.register_handler("tts", tts_handler)
    manager.register_handler("asr", asr_handler)
    _handlers_registered = True


def _sse(event: str, data: dict) -> str:
    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"


async def _stream_job(job_id: str):
    _register_handlers()
    manager = get_job_manager()

    async def event_stream():
        event_iterator = manager.stream_events(job_id).__aiter__()
        next_event_task = asyncio.create_task(anext(event_iterator))
        try:
            while True:
                try:
                    event = await asyncio.wait_for(asyncio.shield(next_event_task), timeout=STREAM_HEARTBEAT_SECONDS)
                except asyncio.TimeoutError:
                    yield ": keepalive\n\n"
                    continue
                except StopAsyncIteration:
                    break
                except Exception as exc:
                    logger.exception("job stream failed job_id=%s", job_id)
                    yield _sse("error", {"job_id": job_id, "error": str(exc)})
                    break
                event_name = event.get("event", "message")
                payload = {k: v for k, v in event.items() if k != "event"}
                yield _sse(event_name, payload)
                if event_name in {"done", "error", "cancelled"}:
                    break
                next_event_task = asyncio.create_task(anext(event_iterator))
        finally:
            if not next_event_task.done():
                next_event_task.cancel()

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream; charset=utf-8",
        headers={
            "Cache-Control": "no-cache, no-transform",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


async def _queue_job(job_type: str, payload: dict, request_id: str) -> str:
    _register_handlers()
    manager = get_job_manager()
    return await manager.submit(job_type, payload, request_id=request_id)


async def _cancel_job(request_id: str, reason: str) -> dict:
    _register_handlers()
    manager = get_job_manager()
    return await manager.cancel(request_id, reason)


async def _job_status(job_id: str) -> dict | None:
    _register_handlers()
    manager = get_job_manager()
    return await manager.get_status(job_id)


async def _queue_load_snapshot() -> dict:
    manager = get_job_manager()
    if isinstance(manager, InMemoryJobManager):
        return {job_type: manager._metrics(job_type) for job_type in manager.queues}
    if isinstance(manager, RedisJobManager):
        return {job_type: await manager._metrics(job_type) for job_type in JOB_TYPES}
    return {}


async def _docs_store_call(method_name: str, *args, **kwargs):
    store = get_document_store()
    method = getattr(store, method_name)
    return await asyncio.to_thread(method, *args, **kwargs)


async def _guard_api_request(request: Request, *, scope: str) -> tuple[RiskIdentity, RiskDecision]:
    identity = _request_identity(request)
    controller = get_risk_controller(config)
    decision = await controller.check_api(identity, scope=scope)
    if not decision.allowed:
        await _record_api_audit(
            identity,
            decision="rejected",
            status_code=decision.status_code,
            delay_ms=decision.delay_ms,
            error_code=decision.error_code,
            metadata={"scope": scope},
        )
    return identity, decision


def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest | WebSearchRequest) -> int:
    return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")


def _estimate_job_cost(policy, raw_size: int, estimated_input_tokens: int) -> float:
    if policy.profile == "speech_tts":
        return round((raw_size / 1000.0) * config.speech_tts_input_cost_per_1k_chars, 8)
    if policy.profile == "speech_asr":
        return round((raw_size / (1024.0 * 1024.0)) * config.speech_asr_input_cost_per_mb, 8)

    pricing_in = {
        "completion": config.completion_input_cost_per_1k,
        "pro": config.pro_input_cost_per_1k,
        "vision": config.vision_input_cost_per_1k,
    }[policy.profile]
    pricing_out = {
        "completion": config.completion_output_cost_per_1k,
        "pro": config.pro_output_cost_per_1k,
        "vision": config.vision_output_cost_per_1k,
    }[policy.profile]
    return round(
        (estimated_input_tokens / 1000.0) * pricing_in
        + (policy.max_output_tokens / 1000.0) * pricing_out,
        8,
    )


async def _prepare_llm_payload(
    request: Request,
    *,
    job_type: str,
    request_body: dict,
    raw_size: int,
    token_source_text: str | None = None,
    extra_payload: dict | None = None,
) -> tuple[RiskIdentity, dict]:
    identity, api_decision = await _guard_api_request(request, scope=job_type)
    if not api_decision.allowed:
        raise RiskRejected(api_decision)
    policy = resolve_llm_policy(job_type, request_body, config)
    if raw_size > policy.max_input_chars:
        await _record_api_audit(
            identity,
            decision="rejected",
            status_code=400,
            error_code="input_too_large",
            metadata={"job_type": job_type, "raw_size": raw_size},
        )
        raise HTTPException(status_code=400, detail=f"输入过长，超过限制 {policy.max_input_chars}")
    estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
    estimated_cost = _estimate_job_cost(policy, raw_size, estimated_input_tokens)
    controller = get_risk_controller(config)
    llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
    if not llm_decision.allowed:
        await _record_api_audit(
            identity,
            decision="rejected",
            status_code=llm_decision.status_code,
            delay_ms=llm_decision.delay_ms,
            error_code=llm_decision.error_code,
            metadata={"job_type": job_type, "estimated_cost": estimated_cost},
        )
        raise RiskRejected(llm_decision)
    await controller.reserve_budget(identity, estimated_cost)
    await _record_api_audit(
        identity,
        decision="accepted",
        status_code=202,
        delay_ms=max(api_decision.delay_ms, llm_decision.delay_ms),
        metadata={"job_type": job_type, "estimated_cost": estimated_cost},
    )
    payload = {
        "request_id": identity.request_id,
        "risk": {
            "request_id": identity.request_id,
            "session_hash": identity.session_hash,
            "ip_hash": identity.ip_hash,
            "delay_ms": max(api_decision.delay_ms, llm_decision.delay_ms),
            "estimated_input_tokens": estimated_input_tokens,
            "estimated_cost": estimated_cost,
            "policy": {
                "job_type": policy.job_type,
                "model": policy.model,
                "profile": policy.profile,
                "max_input_chars": policy.max_input_chars,
                "max_output_tokens": policy.max_output_tokens,
                "temperature": policy.temperature,
                "thinking": policy.thinking,
            },
        },
        "request": request_body,
    }
    if extra_payload:
        payload.update(extra_payload)
    return identity, payload


@app.post("/v1/completions")
async def create_completion(
    request: Request,
    req: CompletionRequest,
    auth: dict = Security(_authorize_request),
):
    del auth
    location = ""
    if not req.privacy_mode:  # pragma: no cover
        location = get_ip_location_text(get_client_ip(request))
    body = {
        "prefix": req.prefix,
        "suffix": req.suffix,
        "languageId": req.languageId,
        "model_thinking": req.model_thinking,
        "privacy_mode": req.privacy_mode,
        "user_preferences": _serialize_preferences(req.user_preferences),
        "temperature": _clamp_temperature(req.temperature, 0.7),
    }
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="completion",
            request_body=body,
            raw_size=_estimate_completion_chars(req),
            token_source_text=f"{req.prefix}\n{req.suffix}",
            extra_payload={"location": location},
        )
        job_id = await _queue_job("completion", payload, identity.request_id)
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)
    except QueueFullError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
    except JobSystemError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
    return await _stream_job(job_id)


@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
    del auth
    return await _cancel_job(req.request_id or "", req.reason)


@app.post("/v1/pro/completions")
async def create_pro_completion(
    request: Request,
    req: ProCompletionRequest,
    auth: dict = Security(_authorize_request),
):
    del auth
    location = ""
    if not req.privacy_mode:  # pragma: no cover
        location = get_ip_location_text(get_client_ip(request))
    body = {
        "prefix": req.prefix,
        "suffix": req.suffix,
        "languageId": req.languageId,
        "instruction": req.instruction,
        "pro_thinking": req.pro_thinking,
        "privacy_mode": req.privacy_mode,
        "user_preferences": _serialize_preferences(req.user_preferences),
    }
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="pro_completion",
            request_body=body,
            raw_size=_estimate_completion_chars(req),
            token_source_text=f"{req.prefix}\n{req.suffix}\n{req.instruction}",
            extra_payload={"location": location},
        )
        job_id = await _queue_job("pro_completion", payload, identity.request_id)
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)
    except QueueFullError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
    except JobSystemError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
    return await _stream_job(job_id)


@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
    del auth
    return await _cancel_job(req.request_id or "", req.reason)


@app.post("/v1/web-search")
async def create_web_search(
    request: Request,
    req: WebSearchRequest,
    auth: dict = Security(_authorize_request),
):
    del auth
    body = {
        "prefix": req.prefix,
        "suffix": req.suffix,
        "languageId": req.languageId,
        "privacy_mode": req.privacy_mode,
        "user_preferences": _serialize_preferences(req.user_preferences),
    }
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="web_search",
            request_body=body,
            raw_size=_estimate_completion_chars(req),
            token_source_text=f"{req.prefix}\n{req.suffix}",
        )
        payload["created_at"] = datetime.utcnow().isoformat()
        job_id = await _queue_job("web_search", payload, identity.request_id)
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)
    except QueueFullError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
    except JobSystemError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
    return await _stream_job(job_id)


@app.post("/v1/web-search/cancel")
async def cancel_web_search(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
    del auth
    return await _cancel_job(req.request_id or "", req.reason)


@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, auth: dict = Security(_authorize_request)):
    del auth
    state = await _job_status(request_id)
    if state is None:
        raise HTTPException(status_code=404, detail="PRO request not found")
    return state


@app.post("/v1/ocr")
async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_authorize_request)):
    del auth
    try:
        image_bytes = base64.b64decode(req.image)
    except Exception as exc:
        return JSONResponse({"error": str(exc)}, status_code=500)
    if len(image_bytes) > config.ocr_max_input_bytes:
        return JSONResponse({"error": "文件过大，无法执行 OCR/视频解析"}, status_code=400)
    input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="ocr",
            request_body={
                "filename": req.filename,
                "language": req.language,
                "media_type": req.media_type,
                "mime_type": req.mime_type,
                "image_bytes": len(image_bytes),
            },
            raw_size=len(image_bytes),
            token_source_text=f"{req.filename}:{req.media_type}:{req.mime_type}:{len(image_bytes)}:{req.language}",
            extra_payload={
                "input_path": input_path,
                "filename": req.filename,
                "language": req.language,
                "media_type": req.media_type,
                "mime_type": req.mime_type,
            },
        )
        job_id = await _queue_job("ocr", payload, identity.request_id)
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)
    except Exception:
        if os.path.exists(input_path):
            os.unlink(input_path)
        raise
    return await _stream_job(job_id)


@app.post("/v1/convert")
async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="convert")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    request_id = identity.request_id
    ext = os.path.splitext(req.filename)[1].lower()
    if ext not in ALLOWED_CONVERT_EXTENSIONS:
        return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
    try:
        file_bytes = base64.b64decode(req.file)
    except Exception as exc:
        return JSONResponse({"error": str(exc)}, status_code=500)
    temp_suffix = _infer_convert_suffix(file_bytes, req.filename)
    if not temp_suffix:
        return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
    ext = os.path.splitext(req.filename)[1].lower()
    if ext != temp_suffix:
        return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
    input_path = persist_temp_input(file_bytes, temp_suffix)
    try:
        job_id = await _queue_job("convert", {
            "request_id": request_id,
            "input_path": input_path,
            "filename": req.filename,
        }, request_id)
    except Exception:
        if os.path.exists(input_path):
            os.unlink(input_path)
        raise
    return await _stream_job(job_id)


@app.post("/v1/compress/submit")
async def submit_compress(request: Request, req: CompressRequest, auth: dict = Security(_authorize_request)):
    del auth
    content = req.content or ""
    if not content.strip():
        raise HTTPException(status_code=400, detail="文档内容为空，无法压缩")
    if len(content) > DOC_COMPRESS_CONTEXT_LIMIT:
        raise HTTPException(
            status_code=400,
            detail=f"文档内容过长（{len(content)} 字符），超过限制 {DOC_COMPRESS_CONTEXT_LIMIT}，无法压缩",
        )
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="compress",
            request_body={"content_length": len(content), "docType": req.docType or "txt"},
            raw_size=len(content),
            token_source_text=content,
            extra_payload={"content": content, "docType": req.docType or "txt"},
        )
        await _queue_job("compress", payload, identity.request_id)
        return {"task_id": identity.request_id, "status": "queued"}
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)


@app.get("/v1/compress/status")
async def get_compress_status(task_id: str, auth: dict = Security(_authorize_request)):
    del auth
    if not task_id:
        raise HTTPException(status_code=400, detail="缺少 task_id 参数")
    state = await _job_status(task_id)
    if state is None:
        raise HTTPException(status_code=404, detail="任务不存在或已过期")
    if state["status"] == "completed":
        result = state.get("result") or {}
        return {"task_id": task_id, "status": "completed", "content": result.get("content", "")}
    if state["status"] == "failed":
        return {"task_id": task_id, "status": "error", "message": state.get("error") or ""}
    if state["status"] == "cancelled":
        return {"task_id": task_id, "status": "cancelled"}
    return {
        "task_id": task_id,
        "status": "processing" if state["status"] == "running" else "queued",
        "busy_level": state.get("busy_level"),
        "queued_count": state.get("queued_count"),
        "running_count": state.get("running_count"),
    }


@app.post("/v1/tts-asr/tts")
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
    del auth
    body = {
        "text_chars": len((req.text or "").strip()),
        "speaker": req.speaker or "Vivian",
        "format": req.format or "wav",
    }
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="tts",
            request_body=body,
            raw_size=len((req.text or "").strip()),
            token_source_text=req.text or "",
            extra_payload={
                "text": req.text,
                "instruct": req.instruct,
                "speaker": req.speaker,
                "format": req.format,
            },
        )
        job_id = await _queue_job("tts", payload, identity.request_id)
    except RiskRejected as exc:
        return _risk_json_response(_request_identity(request), exc.decision)
    except QueueFullError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
    except JobSystemError as exc:
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
    return await _stream_job(job_id)


@app.post("/v1/tts-asr/asr")
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
    del auth
    try:
        audio_bytes = base64.b64decode(req.audio_base64)
    except Exception as exc:
        return JSONResponse({"error": str(exc)}, status_code=400)
    input_path = persist_temp_input(audio_bytes, ".wav")
    try:
        identity, payload = await _prepare_llm_payload(
            request,
            job_type="asr",
            request_body={
                "audio_bytes": len(audio_bytes),
                "language": req.language or "zh-CN",
            },
            raw_size=len(audio_bytes),
            token_source_text=f"audio-bytes:{len(audio_bytes)} language:{req.language or 'zh-CN'}",
            extra_payload={
                "input_path": input_path,
                "language": req.language or "zh-CN",
                "audio_bytes": len(audio_bytes),
            },
        )
        job_id = await _queue_job("asr", payload, identity.request_id)
    except RiskRejected as exc:
        if os.path.exists(input_path):
            os.unlink(input_path)
        return _risk_json_response(_request_identity(request), exc.decision)
    except QueueFullError as exc:
        if os.path.exists(input_path):
            os.unlink(input_path)
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
    except JobSystemError as exc:
        if os.path.exists(input_path):
            os.unlink(input_path)
        return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
    except Exception:
        if os.path.exists(input_path):
            os.unlink(input_path)
        raise
    return await _stream_job(job_id)


@app.post("/v1/jobs/{job_id}/cancel")
async def cancel_job(job_id: str, req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
    del auth
    return await _cancel_job(req.request_id or job_id, req.reason)


@app.get("/v1/jobs/{job_id}/status")
async def get_job_status(job_id: str, auth: dict = Security(_authorize_request)):
    del auth
    state = await _job_status(job_id)
    if state is None:
        raise HTTPException(status_code=404, detail="job not found")
    return state


@app.get("/v1/jobs/load")
async def get_job_load(auth: dict = Security(_authorize_request)):
    del auth
    return {"queues": await _queue_load_snapshot()}


@app.get("/v1/docs/nodes")
async def list_docs_nodes(request: Request, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_list")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    try:
        return {"nodes": await _docs_store_call("list_nodes")}
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc


@app.post("/v1/docs/folders")
async def create_docs_folder(request: Request, req: CreateFolderRequest, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    if not (req.name or "").strip():
        raise HTTPException(status_code=400, detail="文件夹名称不能为空")
    try:
        node = await _docs_store_call("create_folder", req.name.strip(), req.parentId)
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"node": node}


@app.post("/v1/docs/files/text")
async def create_docs_text_file(request: Request, req: CreateTextFileRequest, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    if not (req.name or "").strip():
        raise HTTPException(status_code=400, detail="文件名称不能为空")
    try:
        node = await _docs_store_call("create_text_file", req.name.strip(), req.parentId, req.content or "")
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"node": node}


@app.post("/v1/docs/files/upload")
async def upload_docs_file(
    request: Request,
    file: UploadFile = File(...),
    parent_id: Optional[str] = Form(default=None),
    auth: dict = Security(_authorize_request),
):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    filename = (file.filename or "").strip()
    if not filename:
        raise HTTPException(status_code=400, detail="文件名称不能为空")
    raw_bytes = await file.read()
    try:
        node = await _docs_store_call("upload_file", filename, parent_id, raw_bytes, file.content_type or "")
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"node": node}


@app.patch("/v1/docs/nodes/{node_id}")
async def update_docs_node(request: Request, node_id: str, req: UpdateNodeRequest, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    fields_set = req.model_fields_set if hasattr(req, "model_fields_set") else getattr(req, "__fields_set__", set())
    if not fields_set:
        raise HTTPException(status_code=400, detail="缺少更新内容")
    update_kwargs = {}
    if "name" in fields_set:
        next_name = req.name.strip() if isinstance(req.name, str) else ""
        if not next_name:
            raise HTTPException(status_code=400, detail="名称不能为空")
        update_kwargs["name"] = next_name
    if "parentId" in fields_set:
        update_kwargs["parent_id"] = req.parentId
    if "content" in fields_set:
        update_kwargs["content"] = req.content or ""
    try:
        node = await _docs_store_call("update_node", node_id, **update_kwargs)
    except KeyError as exc:
        raise HTTPException(status_code=404, detail="节点不存在") from exc
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"node": node}


@app.put("/v1/docs/files/{node_id}/blob")
async def replace_docs_blob(
    request: Request,
    node_id: str,
    file: UploadFile = File(...),
    auth: dict = Security(_authorize_request),
):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    filename = (file.filename or "").strip()
    if not filename:
        raise HTTPException(status_code=400, detail="文件名称不能为空")
    raw_bytes = await file.read()
    try:
        node = await _docs_store_call("replace_blob", node_id, filename, raw_bytes, file.content_type or "")
    except KeyError as exc:
        raise HTTPException(status_code=404, detail="节点不存在") from exc
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"node": node}


@app.delete("/v1/docs/nodes/{node_id}")
async def delete_docs_node(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_write")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    try:
        await _docs_store_call("delete_node", node_id)
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return {"ok": True}


@app.get("/v1/docs/files/{node_id}/blob")
async def download_docs_blob(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
    del auth
    identity, decision = await _guard_api_request(request, scope="docs_blob")
    if not decision.allowed:
        return _risk_json_response(identity, decision)
    try:
        payload = await _docs_store_call("get_blob", node_id)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail="文件不存在") from exc
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    headers = {
        "Content-Disposition": f'inline; filename="{payload.filename}"',
    }
    return Response(content=payload.content, media_type=payload.mime_type, headers=headers)


def _register_tts_asr_routes() -> None:
    from tts_asr import LLM_BASE_URL, register_tts_asr_routes as _register_fn

    logger.info("TTS/ASR routes registered with shared LLM speech backend")
    _register_fn(app)


_register_tts_asr_routes()


@app.on_event("shutdown")
async def _shutdown_job_manager():  # pragma: no cover
    from tts_asr import close_speech_client

    manager = get_job_manager()
    close = getattr(manager, "close", None)
    if close is not None:
        await close()
    await close_speech_client()

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8001)
