"""CAD file router - serve thumbnails, glTF models, parsed objects, and trigger reprocessing."""
import uuid
from datetime import datetime
from pathlib import Path

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload

from app.database import get_db
from app.models.cad_file import CadFile, ProcessingStatus
from app.models.order import Order
from app.models.order_item import OrderItem
from app.models.user import User
from app.utils.auth import get_current_user
from app.services.product_service import link_cad_to_product, lookup_product

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


# ---------------------------------------------------------------------------
# Schemas for match-to-order
# ---------------------------------------------------------------------------

class MatchToOrderRequest(BaseModel):
    order_id: uuid.UUID
    cad_file_ids: list[str]


class MatchedItem(BaseModel):
    item_id: str
    cad_file_id: str
    item_name: str
    cad_name: str


class MatchToOrderResponse(BaseModel):
    matched: list[MatchedItem]
    unmatched_cad: list[str]
    unmatched_items: list[str]


# ---------------------------------------------------------------------------
# Matching helper
# ---------------------------------------------------------------------------

def _normalize_stem(name: str) -> str:
    """Lowercase stem, strip .stp/.step extension for comparison."""
    stem = name.strip()
    for ext in (".step", ".stp"):
        if stem.lower().endswith(ext):
            stem = stem[: -len(ext)]
            break
    return stem.lower()


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------

@router.post("/match-to-order", response_model=MatchToOrderResponse)
async def match_cad_files_to_order(
    body: MatchToOrderRequest,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """
    Match uploaded CAD files to order items by filename similarity.

    For each CAD file, compares the stem of original_name (case-insensitive,
    .stp/.step normalised) to the stem of each item's name_cad_modell field.
    Updates order_item.cad_file_id for successful matches.
    """
    # Load order with items
    order_result = await db.execute(
        select(Order)
        .where(Order.id == body.order_id)
        .options(selectinload(Order.items))
    )
    order = order_result.scalar_one_or_none()
    if not order:
        raise HTTPException(404, detail="Order not found")
    if user.role.value != "admin" and order.created_by != user.id:
        raise HTTPException(403, detail="Access denied")

    # Parse and validate CAD file IDs
    cad_uuids: list[uuid.UUID] = []
    for raw_id in body.cad_file_ids:
        try:
            cad_uuids.append(uuid.UUID(raw_id))
        except ValueError:
            raise HTTPException(400, detail=f"Invalid cad_file_id: {raw_id}")

    # Load CAD files from DB
    cad_result = await db.execute(
        select(CadFile).where(CadFile.id.in_(cad_uuids))
    )
    cad_files: list[CadFile] = list(cad_result.scalars().all())

    found_ids = {str(cf.id) for cf in cad_files}
    missing = [i for i in body.cad_file_ids if i not in found_ids]
    if missing:
        raise HTTPException(404, detail=f"CAD files not found: {missing}")

    # Build lookup: normalized stem -> first OrderItem with that stem
    items: list[OrderItem] = order.items
    item_by_stem: dict[str, OrderItem] = {}
    for item in items:
        if item.name_cad_modell:
            stem = _normalize_stem(item.name_cad_modell)
            if stem not in item_by_stem:
                item_by_stem[stem] = item

    matched: list[MatchedItem] = []
    unmatched_cad: list[str] = []
    matched_item_ids: set[str] = set()

    for cad_file in cad_files:
        cad_stem = _normalize_stem(cad_file.original_name or "")
        if cad_stem in item_by_stem:
            item = item_by_stem[cad_stem]
            item.cad_file_id = cad_file.id
            item.updated_at = datetime.utcnow()
            matched.append(
                MatchedItem(
                    item_id=str(item.id),
                    cad_file_id=str(cad_file.id),
                    item_name=item.name_cad_modell or "",
                    cad_name=cad_file.original_name or "",
                )
            )
            matched_item_ids.add(str(item.id))

            # Propagate the STEP link to the product so that:
            # (a) the render pipeline can find it via product.cad_file_id
            # (b) future orders for the same product inherit the STEP automatically
            # (c) the split-missing-step correctly identifies which products have STEP
            try:
                product = await lookup_product(db, item.pim_id, item.produkt_baureihe)
                if product and product.cad_file_id is None:
                    await link_cad_to_product(db, product.id, cad_file.id)
            except Exception:
                pass  # non-critical — item link already set above
        else:
            unmatched_cad.append(str(cad_file.id))

    await db.commit()

    unmatched_items = [
        str(item.id)
        for item in items
        if str(item.id) not in matched_item_ids
    ]

    return MatchToOrderResponse(
        matched=matched,
        unmatched_cad=unmatched_cad,
        unmatched_items=unmatched_items,
    )


# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------

async def _get_cad_file(cad_id: uuid.UUID, db: AsyncSession) -> CadFile:
    result = await db.execute(select(CadFile).where(CadFile.id == cad_id))
    cad = result.scalar_one_or_none()
    if not cad:
        raise HTTPException(status_code=404, detail="CAD file not found")
    return cad


@router.get("/{id}/thumbnail")
async def get_thumbnail(
    id: uuid.UUID,
    db: AsyncSession = Depends(get_db),
):
    """Serve the thumbnail image for a CAD file (no auth — UUID is opaque enough)."""
    from sqlalchemy import text
    # Bypass RLS for this public endpoint (cad_files has tenant RLS but thumbnails are public)
    await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
    cad = await _get_cad_file(id, db)

    if not cad.thumbnail_path:
        raise HTTPException(404, detail="Thumbnail not yet generated for this CAD file")

    thumb_path = Path(cad.thumbnail_path)
    if not thumb_path.exists():
        raise HTTPException(404, detail="Thumbnail file missing from storage")

    ext = thumb_path.suffix.lower()
    media_type = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png"

    return FileResponse(
        path=str(thumb_path),
        media_type=media_type,
        filename=f"{id}{ext}",
        headers={"Cache-Control": "max-age=3600, public"},
    )


@router.get("/{id}/model")
async def get_model(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Serve the glTF file for a CAD file."""
    cad = await _get_cad_file(id, db)

    if not cad.gltf_path:
        raise HTTPException(
            status_code=404,
            detail="glTF model not yet generated for this CAD file",
        )

    gltf_path = Path(cad.gltf_path)
    if not gltf_path.exists():
        raise HTTPException(
            status_code=404,
            detail="glTF file missing from storage",
        )

    # glTF files may be either .gltf (JSON) or .glb (binary)
    suffix = gltf_path.suffix.lower()
    if suffix == ".glb":
        media_type = "model/gltf-binary"
    else:
        media_type = "model/gltf+json"

    return FileResponse(
        path=str(gltf_path),
        media_type=media_type,
        filename=f"{id}{suffix}",
    )


@router.get("/{id}/objects")
async def get_objects(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Return the parsed_objects JSON extracted from the STEP file."""
    cad = await _get_cad_file(id, db)

    if cad.parsed_objects is None:
        raise HTTPException(
            status_code=404,
            detail="Parsed objects not yet available for this CAD file",
        )

    return {
        "cad_file_id": str(cad.id),
        "original_name": cad.original_name,
        "processing_status": cad.processing_status.value,
        "parsed_objects": cad.parsed_objects,
    }


@router.get("/{id}/stl/{quality}")
async def download_stl(
    id: uuid.UUID,
    quality: str,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Download the cached STL for a CAD file with a human-readable filename.

    The STL is cached next to the STEP file on first render.
    quality must be 'low' or 'high'.
    """
    if quality not in ("low", "high"):
        raise HTTPException(400, detail="quality must be 'low' or 'high'")

    cad = await _get_cad_file(id, db)

    if not cad.stored_path:
        raise HTTPException(404, detail="STEP file not uploaded for this CAD file")

    step_path = Path(cad.stored_path)
    stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl"

    if not stl_path.exists():
        raise HTTPException(
            404,
            detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.",
        )

    original_stem = Path(cad.original_name or "model").stem
    filename = f"{original_stem}_{quality}.stl"

    return FileResponse(
        path=str(stl_path),
        media_type="application/octet-stream",
        filename=filename,
    )


@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED)
async def generate_stl(
    id: uuid.UUID,
    quality: str,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Queue STL generation for the given quality without triggering a full render."""
    if user.role.value not in ("admin", "project_manager"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")
    if quality not in ("low", "high"):
        raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'")

    cad = await _get_cad_file(id, db)

    if not cad.stored_path:
        raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")

    from app.tasks.step_tasks import generate_stl_cache
    task = generate_stl_cache.delay(str(id), quality)
    return {"status": "queued", "task_id": task.id, "quality": quality}


@router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED)
async def generate_gltf_geometry(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Queue GLB geometry export from the existing STL cache (trimesh, no Blender).

    Stores the result as a MediaAsset with asset_type='gltf_geometry'.
    The STL low-quality cache must already exist (run a thumbnail render first).
    """
    if user.role.value not in ("admin", "project_manager"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    cad = await _get_cad_file(id, db)
    if not cad.stored_path:
        raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")

    step_path = Path(cad.stored_path)
    stl_path = step_path.parent / f"{step_path.stem}_low.stl"
    if not stl_path.exists():
        raise HTTPException(
            status_code=404,
            detail="STL low-quality cache not found. Trigger a render first to generate it.",
        )

    # Queue as a thumbnail_rendering task (trimesh available in render-worker)
    from app.tasks.step_tasks import generate_gltf_geometry_task
    task = generate_gltf_geometry_task.delay(str(id))
    return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}


@router.post(
    "/{id}/regenerate-thumbnail",
    status_code=status.HTTP_202_ACCEPTED,
)
async def regenerate_thumbnail(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Queue a Celery task to reprocess the STEP file and regenerate its thumbnail."""
    if user.role.value != "admin":
        raise HTTPException(
            status_code=403,
            detail="Only admins can trigger thumbnail regeneration",
        )

    cad = await _get_cad_file(id, db)

    # Reset processing status so the worker will reprocess
    cad.processing_status = ProcessingStatus.pending
    await db.commit()

    # Enqueue Celery task
    task_id: str | None = None
    try:
        from app.tasks.step_tasks import process_step_file
        result = process_step_file.delay(str(cad.id))
        task_id = result.id
    except Exception:
        # Worker may not be running; status is already reset so it will pick up later
        pass

    return {
        "cad_file_id": str(cad.id),
        "original_name": cad.original_name,
        "status": "queued",
        "task_id": task_id,
    }


@router.get("/{id}/export-gltf-colored")
async def export_gltf_colored(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Export a GLB with PBR colors from part_colors (material alias mapping).

    Loads per-part STLs from the low-quality parts cache directory and applies
    PBR materials based on the product's cad_part_materials color assignments.
    Falls back to the combined STL with a single grey material.
    """
    from fastapi.responses import Response
    from sqlalchemy import text, select
    import trimesh
    import io

    if user.role.value not in ("admin", "project_manager"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    # Bypass RLS for cad_files + products
    await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
    cad = await _get_cad_file(id, db)

    if not cad.stored_path:
        raise HTTPException(404, detail="STEP file not uploaded")

    step_path = Path(cad.stored_path)
    stl_path = step_path.parent / f"{step_path.stem}_low.stl"
    parts_dir = step_path.parent / f"{step_path.stem}_low_parts"

    if not stl_path.exists():
        raise HTTPException(404, detail="STL cache not found. Trigger a render first.")

    # Load settings
    from app.models.system_setting import SystemSetting
    settings_result = await db.execute(
        select(SystemSetting.key, SystemSetting.value).where(
            SystemSetting.key.in_([
                "gltf_scale_factor", "gltf_smooth_normals",
                "gltf_pbr_roughness", "gltf_pbr_metallic",
            ])
        )
    )
    raw_settings = {k: v for k, v in settings_result.all()}
    scale = float(raw_settings.get("gltf_scale_factor", "0.001"))
    smooth = raw_settings.get("gltf_smooth_normals", "true") == "true"
    roughness = float(raw_settings.get("gltf_pbr_roughness", "0.4"))
    metallic = float(raw_settings.get("gltf_pbr_metallic", "0.6"))

    # Load part colors from product
    from app.domains.products.models import Product
    part_colors: dict[str, str] = {}
    if cad.id:
        prod_result = await db.execute(
            select(Product).where(Product.cad_file_id == cad.id).limit(1)
        )
        product = prod_result.scalar_one_or_none()
        if product and product.cad_part_materials:
            for entry in product.cad_part_materials:
                part_name = entry.get("part_name") or entry.get("name", "")
                hex_color = entry.get("hex_color") or entry.get("color", "")
                if part_name and hex_color:
                    part_colors[part_name] = hex_color

    def _hex_to_rgba(h: str) -> list:
        h = h.lstrip("#")
        if len(h) < 6:
            return [0.7, 0.7, 0.7, 1.0]
        try:
            return [int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)] + [1.0]
        except Exception:
            return [0.7, 0.7, 0.7, 1.0]

    def _make_material(hex_color: str | None = None):
        rgba = _hex_to_rgba(hex_color) if hex_color else [0.7, 0.7, 0.7, 1.0]
        return trimesh.visual.material.PBRMaterial(
            baseColorFactor=rgba,
            roughnessFactor=roughness,
            metallicFactor=metallic,
        )

    def _apply_mesh(mesh, color=None):
        mesh.apply_scale(scale)
        if smooth:
            try:
                trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)
            except Exception:
                pass
        mesh.visual = trimesh.visual.TextureVisuals(material=_make_material(color))
        return mesh

    # Try per-part STLs first
    scene = trimesh.Scene()
    used_parts = False

    if parts_dir.exists() and part_colors:
        for part_name, hex_color in part_colors.items():
            # Sanitize part name for filesystem
            safe_name = part_name.replace("/", "_").replace("\\", "_")
            part_stl = parts_dir / f"{safe_name}.stl"
            if not part_stl.exists():
                # Try lowercase / partial match
                candidates = list(parts_dir.glob(f"{safe_name}*.stl"))
                if not candidates:
                    candidates = list(parts_dir.glob("*.stl"))
                    candidates = [c for c in candidates if safe_name.lower() in c.stem.lower()]
                if candidates:
                    part_stl = candidates[0]
                else:
                    continue
            try:
                m = trimesh.load(str(part_stl), force="mesh")
                _apply_mesh(m, hex_color)
                scene.add_geometry(m, geom_name=part_name)
                used_parts = True
            except Exception:
                pass

    if not used_parts:
        # Fallback: combined STL, single color
        combined = trimesh.load(str(stl_path))
        if hasattr(combined, 'geometry'):
            for name, m in combined.geometry.items():
                _apply_mesh(m, next(iter(part_colors.values()), None))
                scene.add_geometry(m, geom_name=name)
        else:
            _apply_mesh(combined, next(iter(part_colors.values()), None))
            scene.add_geometry(combined)

    # Export to bytes
    buf = io.BytesIO()
    scene.export(buf, file_type="glb")
    glb_bytes = buf.getvalue()

    original_stem = Path(cad.original_name or "model").stem
    filename = f"{original_stem}_colored.glb"

    return Response(
        content=glb_bytes,
        media_type="model/gltf-binary",
        headers={"Content-Disposition": f"attachment; filename={filename}"},
    )
