"""REST API routes for the bitable companion service.

All endpoints are prefixed ``/api/v1/bitable``. Auth via ``require_bitable_auth``
which accepts either a user JWT (``Authorization: Bearer``) or an internal
service token (``X-Internal-Token``) per KTD11. Service is obtained from
``app.state.bitable_service`` (503 if not initialized).
"""

from __future__ import annotations

import hmac
import logging
import os
import uuid
from pathlib import Path
from typing import Any

from fastapi import (
    APIRouter,
    Depends,
    File,
    HTTPException,
    Query,
    Request,
    UploadFile,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field

from agentkit.bitable.models import FieldOwner, FieldType, ViewType
from agentkit.bitable.service import BitableService, FieldDependencyError
from agentkit.server.auth.dependencies import get_current_user

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/bitable", tags=["bitable"])

# ── Upload config (U6) ───────────────────────────────────
MAX_UPLOAD_SIZE = 10 * 1024 * 1024  # 10 MB
BITABLE_UPLOAD_DIR = Path(os.environ.get("AGENTKIT_BITABLE_UPLOAD_DIR", "data/uploads/bitable"))
_IMAGE_MIME_PREFIXES = ("image/",)


def _get_service(request: Request) -> BitableService:
    """Get bitable service from app.state, 503 if not initialized."""
    service = getattr(request.app.state, "bitable_service", None)
    if service is None:
        raise HTTPException(
            status_code=503,
            detail="Bitable service not available. Server may not have initialized it.",
        )
    return service


async def require_bitable_auth(request: Request) -> dict[str, Any]:
    """Bitable-specific auth: accept JWT (via middleware) OR X-Internal-Token (KTD11).

    The internal token is compared in constant time (hmac.compare_digest).
    On success with the internal token, a synthetic service user is returned.
    """
    # 1. Check internal service token (KTD11)
    internal_token = getattr(request.app.state, "bitable_internal_token", None)
    if internal_token:
        provided = request.headers.get("X-Internal-Token", "")
        if provided and hmac.compare_digest(provided, internal_token):
            return {
                "user_id": "__bitable_internal__",
                "username": "bitable-internal",
                "role": "admin",
                "internal": True,
            }

    # 2. Fall back to JWT auth
    user = await get_current_user(request)
    if user is None:
        raise HTTPException(
            status_code=401,
            detail="Authentication required (JWT or X-Internal-Token)",
        )
    return user


async def _check_table_ownership(
    service: BitableService, table_id: str, user: dict[str, Any]
) -> None:
    """Verify the user owns the table. Internal service users bypass check.

    Pattern 4 (IDOR ownership): 404 before 403 — never leak existence of a
    table the user doesn't own. Internal token bypasses (KTD11).
    """
    table = await service.get_table(table_id)
    if table is None:
        raise HTTPException(status_code=404, detail="Table not found")
    if user.get("internal"):
        return  # Internal service token (KTD11) bypasses ownership
    if table.owner_user_id and table.owner_user_id != user.get("user_id"):
        raise HTTPException(status_code=404, detail="Table not found")


async def _check_file_ownership(
    service: BitableService, file_id: str, user: dict[str, Any]
) -> None:
    """Verify the user owns the file. Internal service users bypass check.

    Pattern 4 (IDOR ownership): 404 before 403 — never leak existence of a
    file the user doesn't own. Internal token bypasses (KTD11).
    """
    file = await service.get_file(file_id)
    if file is None:
        raise HTTPException(status_code=404, detail="File not found")
    if user.get("internal"):
        return  # Internal service token (KTD11) bypasses ownership
    if file.owner_user_id and file.owner_user_id != user.get("user_id"):
        raise HTTPException(status_code=404, detail="File not found")


# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------


class CreateFileRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    icon: str = Field("📋", max_length=20)
    description: str = Field("", max_length=2000)


class UpdateFileRequest(BaseModel):
    name: str | None = Field(None, min_length=1, max_length=100)
    icon: str | None = Field(None, max_length=20)
    description: str | None = Field(None, max_length=2000)


class CreateTableRequest(BaseModel):
    name: str
    description: str = ""
    primary_key_field_id: str | None = None


class UpdateTableRequest(BaseModel):
    name: str | None = None
    description: str | None = None
    primary_key_field_id: str | None = None


class CreateFieldRequest(BaseModel):
    name: str
    field_type: FieldType
    config: dict[str, Any] = Field(default_factory=dict)
    owner: FieldOwner = FieldOwner.user


class UpdateFieldRequest(BaseModel):
    name: str | None = None
    config: dict[str, Any] | None = None


class CreateRecordRequest(BaseModel):
    values: dict[str, Any] = Field(default_factory=dict)


class BatchCreateRecordsRequest(BaseModel):
    records: list[dict[str, Any]] = Field(default_factory=list, max_length=500)


class UpdateRecordRequest(BaseModel):
    values: dict[str, Any] = Field(default_factory=dict)


class UpsertRequest(BaseModel):
    records: list[dict[str, Any]]
    primary_key_field_id: str


class CreateViewRequest(BaseModel):
    name: str
    view_type: ViewType = ViewType.grid
    config: dict[str, Any] = Field(default_factory=dict)


class UpdateViewRequest(BaseModel):
    name: str | None = None
    config: dict[str, Any] | None = None


# ---------------------------------------------------------------------------
# Table endpoints
# ---------------------------------------------------------------------------


@router.post("/tables", status_code=201)
async def create_table(
    body: CreateTableRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Deprecated: use POST /files/{file_id}/tables instead.

    Creates a table without a parent file (file_id = NULL). Kept for
    backward compatibility — new clients should create tables under a file.
    """
    service = _get_service(request)
    table = await service.create_table(
        name=body.name,
        description=body.description,
        primary_key_field_id=body.primary_key_field_id,
        owner_user_id=user.get("user_id"),
    )
    return {"success": True, "table": table.model_dump(mode="json")}


@router.get("/tables")
async def list_tables(
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Deprecated: use GET /files/{file_id}/tables instead.

    Lists all tables owned by the user regardless of parent file.
    """
    service = _get_service(request)
    tables = await service.list_tables(owner_user_id=user.get("user_id"))
    return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}


# ---------------------------------------------------------------------------
# File endpoints (R1)
# ---------------------------------------------------------------------------


@router.post("/files", status_code=201)
async def create_file(
    body: CreateFileRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Create a new bitable file (top-level container)."""
    service = _get_service(request)
    file = await service.create_file(
        name=body.name,
        icon=body.icon,
        description=body.description,
        owner_user_id=user.get("user_id"),
    )
    return {"success": True, "file": file.model_dump(mode="json")}


@router.get("/files")
async def list_files(
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """List all bitable files owned by the current user."""
    service = _get_service(request)
    files = await service.list_files(owner_user_id=user.get("user_id"))
    return {"success": True, "files": [f.model_dump(mode="json") for f in files]}


@router.get("/files/{file_id}")
async def get_file(
    file_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Get a single bitable file by ID (with ownership check)."""
    service = _get_service(request)
    await _check_file_ownership(service, file_id, user)
    file = await service.get_file(file_id)
    return {"success": True, "file": file.model_dump(mode="json")}


@router.patch("/files/{file_id}")
async def update_file(
    file_id: str,
    body: UpdateFileRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Update a bitable file's metadata (name/icon/description)."""
    service = _get_service(request)
    await _check_file_ownership(service, file_id, user)
    kwargs = body.model_dump(exclude_none=True)
    file = await service.update_file(file_id, **kwargs)
    if file is None:
        raise HTTPException(status_code=404, detail="File not found")
    return {"success": True, "file": file.model_dump(mode="json")}


@router.delete("/files/{file_id}")
async def delete_file(
    file_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Delete a bitable file. Cascades to all tables under the file."""
    service = _get_service(request)
    await _check_file_ownership(service, file_id, user)
    deleted = await service.delete_file(file_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="File not found")
    return {"success": True}


@router.get("/files/{file_id}/tables")
async def list_tables_in_file(
    file_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """List all tables in a file (with file ownership check)."""
    service = _get_service(request)
    await _check_file_ownership(service, file_id, user)
    tables = await service.list_tables_by_file(file_id)
    return {"success": True, "tables": [t.model_dump(mode="json") for t in tables]}


@router.post("/files/{file_id}/tables", status_code=201)
async def create_table_in_file(
    file_id: str,
    body: CreateTableRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Create a new table under a file. Auto-creates 5 default fields (R2)."""
    service = _get_service(request)
    await _check_file_ownership(service, file_id, user)
    table = await service.create_table(
        name=body.name,
        description=body.description,
        primary_key_field_id=body.primary_key_field_id,
        owner_user_id=user.get("user_id"),
        file_id=file_id,
    )
    return {"success": True, "table": table.model_dump(mode="json")}


@router.get("/tables/{table_id}")
async def get_table(
    table_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    table = await service.get_table(table_id)
    return {"success": True, "table": table.model_dump(mode="json")}


@router.patch("/tables/{table_id}")
async def update_table(
    table_id: str,
    body: UpdateTableRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    kwargs = body.model_dump(exclude_none=True)
    table = await service.update_table(table_id, **kwargs)
    if table is None:
        raise HTTPException(status_code=404, detail="Table not found")
    return {"success": True, "table": table.model_dump(mode="json")}


@router.delete("/tables/{table_id}")
async def delete_table(
    table_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    deleted = await service.delete_table(table_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Table not found")
    return {"success": True}


# ---------------------------------------------------------------------------
# Field endpoints
# ---------------------------------------------------------------------------


@router.post("/tables/{table_id}/fields", status_code=201)
async def create_field(
    table_id: str,
    body: CreateFieldRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    field = await service.create_field(
        table_id=table_id,
        name=body.name,
        field_type=body.field_type,
        config=body.config,
        owner=body.owner,
    )
    return {"success": True, "field": field.model_dump(mode="json")}


@router.get("/tables/{table_id}/fields")
async def list_fields(
    table_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    fields = await service.list_fields(table_id)
    return {"success": True, "fields": [f.model_dump(mode="json") for f in fields]}


@router.patch("/fields/{field_id}")
async def update_field(
    field_id: str,
    body: UpdateFieldRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    existing = await service.get_field(field_id)
    if existing is None:
        raise HTTPException(status_code=404, detail="Field not found")
    await _check_table_ownership(service, existing.table_id, user)
    kwargs = body.model_dump(exclude_none=True)
    field = await service.update_field(field_id, **kwargs)
    if field is None:
        raise HTTPException(status_code=404, detail="Field not found")
    return {"success": True, "field": field.model_dump(mode="json")}


@router.delete("/fields/{field_id}")
async def delete_field(
    field_id: str,
    request: Request,
    force: bool = Query(False, description="Force delete with cascade cleanup"),
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    existing = await service.get_field(field_id)
    if existing is None:
        raise HTTPException(status_code=404, detail="Field not found")
    await _check_table_ownership(service, existing.table_id, user)
    try:
        deleted = await service.delete_field(field_id, force=force)
    except FieldDependencyError as e:
        raise HTTPException(
            status_code=409,
            detail={"message": str(e), "dependencies": e.dependencies},
        )
    if not deleted:
        raise HTTPException(status_code=404, detail="Field not found")
    return {"success": True}


# ---------------------------------------------------------------------------
# Formula validation endpoint (U5b)
# ---------------------------------------------------------------------------


class ValidateFormulaRequest(BaseModel):
    """Request body for formula syntax validation."""

    formula: str = Field(..., min_length=1, max_length=2000)


@router.post("/fields/validate-formula")
async def validate_formula(
    body: ValidateFormulaRequest,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Validate formula syntax without saving. Returns valid flag + error detail."""
    from agentkit.bitable.formula import (
        FormulaParseError,
        FormulaSecurityError,
        UnknownFunctionError,
        parse_formula,
    )

    try:
        parse_formula(body.formula)
    except (FormulaParseError, FormulaSecurityError, UnknownFunctionError) as e:
        return {"valid": False, "error": str(e)}
    except Exception as e:  # pragma: no cover — defensive
        return {"valid": False, "error": f"Unexpected error: {e}"}
    return {"valid": True}


# ---------------------------------------------------------------------------
# Record endpoints
# ---------------------------------------------------------------------------


@router.post("/tables/{table_id}/records", status_code=201)
async def create_records(
    table_id: str,
    body: BatchCreateRecordsRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    created = []
    for rec_values in body.records:
        record = await service.create_record(
            table_id, values=rec_values, actor_user_id=user.get("user_id")
        )
        created.append(record.model_dump(mode="json"))
    return {"success": True, "count": len(created), "records": created}


@router.get("/tables/{table_id}/records")
async def list_records(
    table_id: str,
    request: Request,
    cursor: str | None = Query(None),
    limit: int = Query(50, ge=1, le=200),
    filters: str | None = Query(None, description="JSON-encoded filter list"),
    sorts: str | None = Query(None, description="JSON-encoded sort list"),
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    import json

    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)

    try:
        parsed_filters = json.loads(filters) if filters else None
        parsed_sorts = json.loads(sorts) if sorts else None
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON in filters/sorts: {e}")

    if parsed_filters or parsed_sorts:
        records, next_cursor = await service.list_records_filtered(
            table_id,
            filters=parsed_filters,
            sorts=parsed_sorts,
            cursor=cursor,
            limit=limit,
        )
    else:
        records, next_cursor = await service.list_records(table_id, cursor=cursor, limit=limit)

    return {
        "success": True,
        "records": [r.model_dump(mode="json") for r in records],
        "next_cursor": next_cursor,
    }


@router.patch("/records/{record_id}")
async def update_record(
    record_id: str,
    body: UpdateRecordRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    existing = await service.get_record(record_id)
    if existing is None:
        raise HTTPException(status_code=404, detail="Record not found")
    await _check_table_ownership(service, existing.table_id, user)
    record = await service.update_record_values(record_id, body.values)
    if record is None:
        raise HTTPException(status_code=404, detail="Record not found")
    return {"success": True, "record": record.model_dump(mode="json")}


@router.delete("/tables/{table_id}/records")
async def delete_records(
    table_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    count = await service.delete_records_by_table(table_id)
    return {"success": True, "deleted": count}


@router.delete("/records/{record_id}")
async def delete_single_record(
    record_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    existing = await service.get_record(record_id)
    if existing is None:
        raise HTTPException(status_code=404, detail="Record not found")
    await _check_table_ownership(service, existing.table_id, user)
    deleted = await service.delete_record(record_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Record not found")
    return {"success": True}


# ---------------------------------------------------------------------------
# Upsert endpoint (KTD8)
# ---------------------------------------------------------------------------


@router.post("/tables/{table_id}/upsert", status_code=201)
async def upsert_records(
    table_id: str,
    body: UpsertRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    if not body.primary_key_field_id:
        raise HTTPException(status_code=400, detail="primary_key_field_id is required")
    result = await service.upsert_records(table_id, body.records, body.primary_key_field_id)
    return {"success": True, **result}


# ---------------------------------------------------------------------------
# View endpoints
# ---------------------------------------------------------------------------


@router.post("/tables/{table_id}/views", status_code=201)
async def create_view(
    table_id: str,
    body: CreateViewRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    view = await service.create_view(
        table_id=table_id,
        name=body.name,
        view_type=body.view_type,
        config=body.config,
    )
    return {"success": True, "view": view.model_dump(mode="json")}


@router.get("/tables/{table_id}/views")
async def list_views(
    table_id: str,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)
    views = await service.list_views(table_id)
    return {"success": True, "views": [v.model_dump(mode="json") for v in views]}


@router.patch("/views/{view_id}")
async def update_view(
    view_id: str,
    body: UpdateViewRequest,
    request: Request,
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    service = _get_service(request)
    existing = await service.get_view(view_id)
    if existing is None:
        raise HTTPException(status_code=404, detail="View not found")
    await _check_table_ownership(service, existing.table_id, user)
    kwargs = body.model_dump(exclude_none=True)
    view = await service.update_view(view_id, **kwargs)
    if view is None:
        raise HTTPException(status_code=404, detail="View not found")
    return {"success": True, "view": view.model_dump(mode="json")}


# ---------------------------------------------------------------------------
# File upload / download (U6: attachment & image fields)
# ---------------------------------------------------------------------------


def _sanitize_filename(name: str) -> str:
    """Remove path separators and keep only safe characters."""
    name = name.replace("\\", "_").replace("/", "_")
    return "".join(c for c in name if c.isalnum() or c in "._-").strip(".")


def _ensure_upload_dir() -> Path:
    BITABLE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    return BITABLE_UPLOAD_DIR


@router.post("/tables/{table_id}/upload")
async def upload_file(
    table_id: str,
    request: Request,
    file: UploadFile = File(...),
    field_id: str = Query(..., description="Target field ID (determines type validation)"),
    user: dict = Depends(require_bitable_auth),
) -> dict[str, Any]:
    """Upload a file for an attachment or image field.

    Returns file metadata. The frontend then writes this metadata array
    to the record's field value via the normal update-record endpoint.
    """
    service = _get_service(request)
    await _check_table_ownership(service, table_id, user)

    # Validate field exists and is attachment/image type
    field = await service.get_field(field_id)
    if field is None:
        raise HTTPException(status_code=404, detail="Field not found")
    if field.table_id != table_id:
        raise HTTPException(status_code=400, detail="Field does not belong to this table")
    if field.field_type not in (FieldType.attachment, FieldType.image):
        raise HTTPException(
            status_code=400,
            detail=f"Field '{field.name}' is not an attachment or image field",
        )

    # Size check (Content-Length header, fast reject)
    if file.size is not None and file.size > MAX_UPLOAD_SIZE:
        raise HTTPException(status_code=413, detail="File exceeds 10 MB limit")

    # Image type check
    mime = file.content_type or "application/octet-stream"
    if field.field_type == FieldType.image and not mime.startswith(_IMAGE_MIME_PREFIXES):
        raise HTTPException(
            status_code=400,
            detail=f"Image field requires an image file, got '{mime}'",
        )

    original_name = file.filename or "unnamed"
    safe_name = _sanitize_filename(original_name) or "unnamed"
    ext = Path(safe_name).suffix
    stored_name = f"{uuid.uuid4().hex}{ext}"
    upload_dir = _ensure_upload_dir()
    file_path = upload_dir / stored_name

    # Stream-read with size check to prevent OOM (P1 #12)
    total_size = 0
    try:
        with open(file_path, "wb") as f:
            while True:
                chunk = await file.read(64 * 1024)  # 64KB chunks
                if not chunk:
                    break
                total_size += len(chunk)
                if total_size > MAX_UPLOAD_SIZE:
                    f.close()
                    file_path.unlink(missing_ok=True)
                    raise HTTPException(status_code=413, detail="File exceeds 10 MB limit")
                f.write(chunk)
    except HTTPException:
        raise
    except Exception as exc:
        file_path.unlink(missing_ok=True)
        logger.error(f"Failed to save uploaded bitable file: {exc}")
        raise HTTPException(status_code=500, detail="Failed to save file") from exc
    finally:
        await file.close()

    return {
        "filename": original_name,
        "stored_name": stored_name,
        "mime_type": mime,
        "size": total_size,
        "url": f"/api/v1/bitable/uploads/{stored_name}",
    }


@router.get("/uploads/{filename}")
async def download_file(
    filename: str,
    user: dict = Depends(require_bitable_auth),
) -> FileResponse:
    """Download a bitable attachment/image file by its stored filename."""
    safe_filename = _sanitize_filename(filename)
    file_path = BITABLE_UPLOAD_DIR / safe_filename
    if not file_path.exists() or not file_path.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    return FileResponse(file_path, filename=safe_filename)
