"""FastAPI app: upload DWG/DXF/PDF → vision-detect legend → count selected symbols → Excel."""
import asyncio
import logging
import os
import uuid
from pathlib import Path

from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from counting import count_template, debug_template
from excel_export import export_to_excel
from pdf_export import render_annotated_pdf
from renderer import crop_region, render
from vision import detect_legend

DEFAULT_FLOOR_INDEX = 0  # MVP: process the first detected floor

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(title="DWG Symbol Counter")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-counting"))
WORK_DIR.mkdir(parents=True, exist_ok=True)

jobs: dict[str, dict] = {}
# Persistent action log so the operator can replay what the user did.
ACTION_LOG = WORK_DIR / "action.log"


def _log_action(event: str, **fields):
    import datetime, json as _json
    line = _json.dumps({
        "ts": datetime.datetime.now().isoformat(timespec="seconds"),
        "event": event,
        **fields,
    }, ensure_ascii=False)
    try:
        with open(ACTION_LOG, "a") as f:
            f.write(line + "\n")
    except Exception:
        pass


@app.get("/")
async def root():
    return FileResponse("static/index.html")


@app.post("/api/upload")
async def upload(file: UploadFile = File(...), auto_detect: bool = False):
    suffix = Path(file.filename or "").suffix.lower()
    if suffix != ".pdf":
        raise HTTPException(400, "Podporovaný formát: .pdf")

    job_id = str(uuid.uuid4())
    job_dir = WORK_DIR / job_id
    job_dir.mkdir()

    input_path = job_dir / f"input{suffix}"
    input_path.write_bytes(await file.read())
    logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size)
    _log_action("upload", job_id=job_id, filename=file.filename,
                size=input_path.stat().st_size, auto_detect=auto_detect)

    try:
        rendered = await asyncio.to_thread(render, input_path, job_dir)
        floors = rendered["floors"]
        if not floors:
            raise RuntimeError("Z výkresu se nepodařilo nic vyrenderovat")
        floor = floors[DEFAULT_FLOOR_INDEX]
        png_path = job_dir / floor["png"]
        legend_norm = floor.get("legend_norm_bbox")
        legend_pixel_box = None
        symbols = []
        detect_path = png_path

        if legend_norm:
            from PIL import Image as _Img
            full_img = _Img.open(png_path)
            W, H = full_img.size
            nx0, ny0, nx1, ny1 = legend_norm
            # Expand box generously around the LEGENDA text
            cx = (nx0 + nx1) / 2
            cy = (ny0 + ny1) / 2
            # Legend rows extend DOWNWARD from the LEGENDA header text.
            # Crop a narrow column starting just above the header.
            half_w = 0.10
            top_pad = 0.02
            below = 0.30
            px0 = max(0, int((cx - half_w) * W))
            px1 = min(W, int((cx + half_w) * W))
            py0 = max(0, int((cy - top_pad) * H))
            py1 = min(H, int((cy + below) * H))
            legend_crop = job_dir / "legend_area.png"
            crop_img = full_img.crop((px0, py0, px1, py1))
            crop_img.save(legend_crop, "PNG")
            detect_path = legend_crop
            legend_pixel_box = (px0, py0, px1 - px0, py1 - py0)
            logger.info("Legend area %dx%d ready", px1 - px0, py1 - py0)

        if auto_detect:
            symbols = await detect_legend(detect_path)

        # Symbol bboxes returned by vision are normalized to the image vision saw
        # (detect_path), which may be the cropped legend region or the full page.
            for s in symbols:
                bbox = s.get("bbox") or {}
                if all(k in bbox for k in ("x", "y", "w", "h")):
                    crop_path = job_dir / f"sym_{s['id']}.png"
                    try:
                        crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
                        s["crop_file"] = crop_path.name
                    except Exception as exc:
                        logger.warning("Crop failed for %s: %s", s.get("id"), exc)

        jobs[job_id] = {
            "filename": file.filename,
            "png_path": str(png_path),
            "job_dir": str(job_dir),
            "floors": floors,
            "symbols": symbols,
            "results": [],
            "legend_pixel_box": legend_pixel_box,
            "next_user_sym_id": 1,
        }
        return {"job_id": job_id, "symbols": symbols, "floor_count": len(floors),
                "auto_detect": auto_detect}

    except Exception as exc:
        logger.exception("Job %s failed: %s", job_id, exc)
        raise HTTPException(500, str(exc))


@app.get("/api/preview/{job_id}")
async def preview(job_id: str):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    return FileResponse(jobs[job_id]["png_path"], media_type="image/png")


@app.get("/api/symbol/{job_id}/{sym_id}")
async def symbol_crop(job_id: str, sym_id: str):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job_dir = Path(jobs[job_id]["job_dir"])
    crop_path = job_dir / f"sym_{sym_id}.png"
    if not crop_path.exists():
        raise HTTPException(404, "Crop not available")
    return FileResponse(crop_path, media_type="image/png")


@app.get("/api/legend/{job_id}")
async def legend_image(job_id: str):
    """Return the cropped legend area image (what vision saw)."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    legend_path = Path(jobs[job_id]["job_dir"]) / "legend_area.png"
    if not legend_path.exists():
        # Fall back to full page if no legend crop
        legend_path = Path(jobs[job_id]["png_path"])
    return FileResponse(legend_path, media_type="image/png")


class RecropRequest(BaseModel):
    bbox: dict  # {x, y, w, h} normalized 0-1 relative to the legend image


class CreateSymbolRequest(BaseModel):
    bbox: dict  # normalized 0-1 relative to the FULL drawing image
    description: str
    source: str = "drawing"  # "drawing" or "legend"


@app.post("/api/symbols/{job_id}")
async def create_user_symbol(job_id: str, req: CreateSymbolRequest):
    """User drew a rectangle on the drawing → create a symbol from that crop."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    job_dir = Path(job["job_dir"])
    # Pick source image
    if req.source == "legend":
        src = job_dir / "legend_area.png"
        if not src.exists():
            src = Path(job["png_path"])
    else:
        src = Path(job["png_path"])

    sym_id = f"user_{job['next_user_sym_id']}"
    job["next_user_sym_id"] += 1
    crop_path = job_dir / f"sym_{sym_id}.png"
    # User's rectangle is exact — no padding, no min size enforcement.
    crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
    sym = {
        "id": sym_id,
        "description": req.description or sym_id,
        "bbox": req.bbox,
        "crop_file": crop_path.name,
        "user_defined": True,
    }
    job["symbols"].append(sym)
    _log_action("create_symbol", job_id=job_id, sym_id=sym_id,
                description=req.description, bbox=req.bbox, source=req.source)
    return sym


@app.post("/api/symbols/{job_id}/upload")
async def upload_symbol_image(
    job_id: str,
    file: UploadFile = File(...),
    description: str = "",
):
    """Accept a pre-cropped symbol image as a template, bypassing the
    rectangle-drawing UI. Useful when the user has a clean PNG from elsewhere.
    """
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    job_dir = Path(job["job_dir"])
    sym_id = f"user_{job['next_user_sym_id']}"
    job["next_user_sym_id"] += 1
    crop_path = job_dir / f"sym_{sym_id}.png"
    raw = await file.read()
    crop_path.write_bytes(raw)
    # Normalize: ensure RGB on white background (drop alpha so processing
    # doesn't see transparency as "not white")
    from PIL import Image as _Img
    img = _Img.open(crop_path)
    if img.mode in ("RGBA", "LA"):
        bg = _Img.new("RGB", img.size, (255, 255, 255))
        bg.paste(img, mask=img.split()[-1])
        bg.save(crop_path, "PNG")
    sym = {
        "id": sym_id,
        "description": (description or file.filename or sym_id).strip(),
        "crop_file": crop_path.name,
        "user_defined": True,
        "uploaded": True,
    }
    job["symbols"].append(sym)
    _log_action("upload_symbol", job_id=job_id, sym_id=sym_id,
                description=sym["description"], filename=file.filename,
                size=len(raw))
    return sym


@app.delete("/api/symbols/{job_id}/{sym_id}")
async def delete_symbol(job_id: str, sym_id: str):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    job["symbols"] = [s for s in job["symbols"] if s["id"] != sym_id]
    crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
    if crop.exists():
        crop.unlink()
    return {"ok": True}


@app.post("/api/auto-detect/{job_id}")
async def trigger_auto_detect(job_id: str):
    """Run the vision legend detection on demand (optional shortcut)."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    job_dir = Path(job["job_dir"])
    legend_path = job_dir / "legend_area.png"
    detect_path = legend_path if legend_path.exists() else Path(job["png_path"])
    found = await detect_legend(detect_path)
    for s in found:
        bbox = s.get("bbox") or {}
        if all(k in bbox for k in ("x", "y", "w", "h")):
            crop_path = job_dir / f"sym_{s['id']}.png"
            try:
                crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
                s["crop_file"] = crop_path.name
            except Exception as exc:
                logger.warning("Crop failed for %s: %s", s.get("id"), exc)
    # Replace vision-detected ones (keep user-defined)
    job["symbols"] = [s for s in job["symbols"] if s.get("user_defined")] + found
    return {"symbols": job["symbols"]}


@app.get("/api/drawing/{job_id}")
async def drawing_image(job_id: str):
    """Return the rendered full drawing image (for the user to crop on)."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    return FileResponse(jobs[job_id]["png_path"], media_type="image/png")


@app.get("/api/debug/{job_id}/{sym_id}")
async def debug_symbol(job_id: str, sym_id: str):
    """Diagnostics about a symbol's template: size, ink, match scores."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
    if not crop.exists():
        raise HTTPException(404, "Crop not available")
    drawing = Path(job["png_path"])
    info = await asyncio.to_thread(debug_template, crop, drawing)
    return info


@app.get("/api/debug-template/{job_id}/{sym_id}")
async def debug_template_image(job_id: str, sym_id: str):
    """Return the *processed* template (what the matcher actually sees)."""
    import cv2
    from counting import _prep, _crop_to_content
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
    if not crop.exists():
        raise HTTPException(404, "Crop not available")
    tmpl = _prep(crop)
    tmpl = _crop_to_content(tmpl)
    out = Path(job["job_dir"]) / f"sym_{sym_id}_processed.png"
    cv2.imwrite(str(out), tmpl)
    return FileResponse(str(out), media_type="image/png")


@app.post("/api/symbol/{job_id}/{sym_id}/recrop")
async def recrop_symbol(job_id: str, sym_id: str, req: RecropRequest):
    """Replace a symbol's crop. bbox is normalized 0-1 relative to the FULL
    drawing image (which is what the frontend shows in the editor)."""
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    src = Path(job["png_path"])
    crop_path = Path(job["job_dir"]) / f"sym_{sym_id}.png"
    crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
    return {"ok": True, "crop_file": crop_path.name}


class CountRequest(BaseModel):
    symbol_ids: list[str]
    threshold: float | None = None  # Override default (0.7); lower = more matches


@app.post("/api/count/{job_id}")
async def count(job_id: str, req: CountRequest):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    png = Path(job["png_path"])
    job_dir = Path(job["job_dir"])
    selected = [s for s in job["symbols"] if s["id"] in req.symbol_ids]

    # Determine the legend mask box (avoid matching the legend itself).
    legend_box = job.get("legend_pixel_box")  # set during upload if available

    thr = req.threshold if req.threshold is not None else None

    def _count_one(sym):
        crop = job_dir / f"sym_{sym['id']}.png"
        if not crop.exists():
            return {"id": sym["id"], "description": sym["description"],
                    "count": 0, "matches": [], "notes": "no crop"}
        try:
            kwargs = {"exclude_box": legend_box}
            if thr is not None:
                kwargs["threshold"] = thr
            res = count_template(crop, png, **kwargs)
        except Exception as exc:
            logger.exception("Counting failed for %s", sym["id"])
            return {"id": sym["id"], "description": sym["description"],
                    "count": 0, "matches": [], "notes": f"error: {exc}"}
        return {
            "id": sym["id"],
            "description": sym["description"],
            "count": res["count"],
            "matches": res["matches"],
            "notes": "" if res["count"] else "žádné shody nenalezeny",
        }

    # Serialize OpenCV calls — parallel matchTemplate on a 4000px drawing
    # blows past 4GB peak memory and OOM-kills the container.
    results = []
    for s in selected:
        r = await asyncio.to_thread(_count_one, s)
        results.append(r)
        _log_action("count_one", job_id=job_id, sym_id=s["id"],
                    description=s.get("description"), count=r.get("count"))
    job["results"] = list(results)
    # Trim matches from API response (keep them server-side for PDF export)
    response_results = [{k: v for k, v in r.items() if k != "matches"} | {"count": r["count"]}
                       for r in job["results"]]
    return {"results": response_results}


@app.get("/api/export/{job_id}")
async def export(job_id: str):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    if not job["results"]:
        raise HTTPException(400, "Nejprve spočítejte symboly")
    out_path = Path(job["job_dir"]) / "counts.xlsx"
    export_to_excel(job["results"], job["filename"] or "drawing", str(out_path))
    stem = Path(job["filename"]).stem if job["filename"] else "drawing"
    return FileResponse(
        str(out_path),
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        filename=f"symboly_{stem}.xlsx",
    )


@app.get("/api/export-pdf/{job_id}")
async def export_pdf(job_id: str):
    if job_id not in jobs:
        raise HTTPException(404, "Not found")
    job = jobs[job_id]
    if not job["results"]:
        raise HTTPException(400, "Nejprve spočítejte symboly")
    png_path = Path(job["png_path"])
    stem = Path(job["filename"]).stem if job["filename"] else "drawing"
    out_path = Path(job["job_dir"]) / "annotated.pdf"
    await asyncio.to_thread(
        render_annotated_pdf, png_path, job["results"], out_path, stem,
    )
    return FileResponse(
        str(out_path),
        media_type="application/pdf",
        filename=f"vyznaceno_{stem}.pdf",
    )


@app.get("/health")
async def health():
    return {"status": "ok"}


app.mount("/static", StaticFiles(directory="static"), name="static")
