import asyncio
import subprocess
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from send2trash import send2trash

from . import ai_analysis, config, db, image_processing, scoring, user_settings
from .licensing import releases as release_check
from .licensing import everflow_client
from .licensing import guard as license_guard
from .licensing import state as license_state

app = FastAPI(title="PhotoPick MVP")
db.init_db()

STATIC_DIR = config.PROJECT_ROOT / "static"

analyze_progress = {"running": False, "total": 0, "done": 0, "failed": 0}
quality_progress = {"running": False, "total": 0, "done": 0, "failed": 0}

_EMPTY_DEFECT_BUCKETS = {"ge_20": 0, "ge_40": 0, "ge_60": 0, "ge_80": 0}


def _defect_detection_allowed() -> bool:
    return license_guard.check_defect_detection().allowed


def _exposure_detection_allowed() -> bool:
    return license_guard.check_exposure_detection().allowed


def _editing_allowed() -> bool:
    return license_guard.check_editing().allowed


def _quality_scan_allowed() -> bool:
    """Whether the local-CV scan should run at all — it computes blur AND
    exposure metrics in one pass, so it's worth running if either is unlocked;
    display-time gating (_strip_defect_from_photo/_sanitize_stats) still hides
    whichever half the license doesn't cover."""
    return _defect_detection_allowed() or _exposure_detection_allowed()


_BLUR_METRIC_KEYS = ("focus", "anisotropy", "laplacian_var", "soft_focus_score", "camera_shake_score")
_EXPOSURE_METRIC_KEYS = ("exposure", "exposure_verdict", "mean_brightness", "shadow_clip_pct", "highlight_clip_pct")


def _strip_defect_from_photo(photo: dict) -> dict:
    blur_ok = _defect_detection_allowed()
    exposure_ok = _exposure_detection_allowed()
    if blur_ok and exposure_ok:
        return photo
    out = dict(photo)
    metrics = dict(out.get("quality_metrics") or {})
    if not blur_ok:
        out["soft_focus_score"] = None
        out["camera_shake_score"] = None
        for key in _BLUR_METRIC_KEYS:
            metrics[key] = None
        tags = out.get("quality_tags") or []
        out["quality_tags"] = [t for t in tags if t not in ("soft_focus", "camera_shake")]
    if not exposure_ok:
        for key in _EXPOSURE_METRIC_KEYS:
            metrics[key] = None
    out["quality_metrics"] = metrics if any(v is not None for v in metrics.values()) else None
    return out


def _sanitize_stats(stats: dict) -> dict:
    out = dict(stats)
    if not _defect_detection_allowed():
        out["soft_focus_buckets"] = dict(_EMPTY_DEFECT_BUCKETS)
        out["camera_shake_buckets"] = dict(_EMPTY_DEFECT_BUCKETS)
        out["blur_buckets"] = dict(_EMPTY_DEFECT_BUCKETS)
        tag_counts = dict(out.get("tag_counts") or {})
        tag_counts["soft_focus"] = 0
        tag_counts["camera_shake"] = 0
        out["tag_counts"] = tag_counts
    if not _exposure_detection_allowed():
        out["exposure_counts"] = {"overexposed": 0, "underexposed": 0}
    return out


# --- native folder picker ------------------------------------------------
# Browsers deliberately withhold the real filesystem path of dragged/dropped
# local files from page JS (privacy: a site shouldn't learn your folder
# structure just from a drag gesture). The backend runs as a local, non-
# sandboxed process though, so it can shell out to the native macOS "choose
# folder" dialog and hand back a real path - no upload, no path guessing.

@app.post("/api/pick-folder")
def pick_folder(prompt: str = "選擇要匯入的照片資料夾"):
    safe_prompt = prompt.replace('"', '\\"')
    script = (
        'tell application "System Events" to activate\n'
        f'POSIX path of (choose folder with prompt "{safe_prompt}")'
    )
    result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
    if result.returncode != 0:
        if "-128" in result.stderr:  # user clicked Cancel
            return {"path": None}
        raise HTTPException(500, result.stderr.strip())
    return {"path": result.stdout.strip()}


# --- import -------------------------------------------------------------
# Hashing + preview generation per file is I/O and CPU heavy (real camera
# JPEGs are tens of MB) - doing this synchronously inside the request handler
# blocked the whole HTTP call until every file was done, with zero feedback.
# Same fix as analysis: run as a background task, process files concurrently,
# and expose progress for the UI to poll. Grid thumbs use the generated
# preview; lightbox always serves the untouched original.

class ImportRequest(BaseModel):
    folder: str


import_progress = {
    "running": False, "total": 0, "done": 0,
    "imported": 0, "already_present": 0, "moved_or_renamed": 0, "newly_missing": 0,
    "error": None,
}

_preview_locks: dict[int, asyncio.Lock] = {}
_preview_locks_guard = asyncio.Lock()
# Cap concurrent Pillow decodes globally (import + on-demand + backfill share this).
_preview_gen_sem = asyncio.Semaphore(config.PREVIEW_GEN_CONCURRENCY)
_preview_saves_since_flush = 0


async def _preview_lock_for(photo_id: int) -> asyncio.Lock:
    async with _preview_locks_guard:
        lock = _preview_locks.get(photo_id)
        if lock is None:
            lock = asyncio.Lock()
            _preview_locks[photo_id] = lock
        return lock


def _preview_path_for(photo: dict) -> Path:
    stem = Path(photo["original_path"]).stem
    # Include hash prefix so a reused id after reset cannot collide with a
    # stale preview file from a previous import of a different photo.
    h = (photo.get("file_hash") or "unknown")[:12]
    return db.preview_dir_for() / f"{photo['id']}_{h}_{stem}_preview.jpg"


async def ensure_preview(photo: dict) -> Path:
    """Return a on-disk preview path, generating it from the original if needed."""
    existing = photo.get("preview_path")
    if existing and Path(existing).exists():
        return Path(existing)

    # Deterministic path — may already exist from a previous run even if DB
    # wasn't flushed after set_preview_path.
    candidate = _preview_path_for(photo)
    if candidate.exists():
        db.set_preview_path(photo["id"], str(candidate))
        return candidate

    lock = await _preview_lock_for(photo["id"])
    async with lock:
        photo = db.get_photo(photo["id"]) or photo
        existing = photo.get("preview_path")
        if existing and Path(existing).exists():
            return Path(existing)
        if candidate.exists():
            db.set_preview_path(photo["id"], str(candidate))
            return candidate

        original = Path(photo["original_path"])
        if not original.exists():
            raise FileNotFoundError(photo["original_path"])

        global _preview_saves_since_flush
        async with _preview_gen_sem:
            await asyncio.to_thread(
                image_processing.generate_preview,
                original,
                candidate,
                photo.get("sidecar_paths"),
                photo.get("rotation", 0),
                photo.get("flip_h", False),
                photo.get("straighten_angle", 0.0),
                photo.get("crop"),
            )
        db.set_preview_path(photo["id"], str(candidate))
        _preview_saves_since_flush += 1
        if _preview_saves_since_flush >= 10:
            db.flush_store()
            _preview_saves_since_flush = 0
        return candidate


async def ensure_capture_info(photo: dict) -> dict:
    """Return cached EXIF summary, extracting from the original on first use."""
    if photo.get("capture_info") is not None:
        return photo["capture_info"]

    original = Path(photo["original_path"])
    if not original.exists():
        info = {}
        db.set_capture_info(photo["id"], info)
        return info

    info = await asyncio.to_thread(image_processing.extract_capture_info, original)
    db.set_capture_info(photo["id"], info)
    return info


async def _ensure_quality(photo: dict, *, force: bool = False):
    """Tag soft-focus / camera-shake / exposure as part of import/load (local CV, no AI)."""
    if not _quality_scan_allowed():
        return
    if not photo:
        return
    if not force and photo.get("quality_state") == "checked":
        return
    try:
        preview_path = await ensure_preview(photo)
        result = await asyncio.to_thread(
            image_processing.compute_quality_tags, preview_path
        )
        db.set_quality_result(photo["id"], result, flush=False)
    except Exception:  # noqa: BLE001
        db.set_quality_error(photo["id"], "quality scan failed", flush=False)


async def _import_one_shot(group: dict, seen_ids: set, counters: dict, semaphore: asyncio.Semaphore):
    """Import one camera shot (RAW primary + optional JPG sidecars, or JPEG-only)."""
    async with semaphore:
        primary: Path = group["primary"]
        sidecars: list[str] = list(group["sidecars"])
        h = await asyncio.to_thread(image_processing.file_hash, primary)
        created = image_processing.file_created_time(primary)
        primary_s = str(primary)

        existing = (
            db.get_photo_by_hash(h)
            or db.get_photo_by_path(primary_s)
            or db.get_photo_by_shot_key(group["parent"], group["stem"])
        )

        # Absorb any leftover twin records that were imported as separate photos.
        absorb_ids = []
        for path in [primary_s, *sidecars]:
            other = db.get_photo_by_path(path)
            if other and (not existing or other["id"] != existing["id"]):
                if not existing:
                    existing = other
                elif other["id"] != existing["id"]:
                    absorb_ids.append(other["id"])

        if existing:
            photo_id = existing["id"]
            prev_primary = existing.get("original_path")
            primary_changed = prev_primary != primary_s
            sidecars_changed = sorted(existing.get("sidecar_paths") or []) != sorted(sidecars)
            if primary_changed or sidecars_changed:
                db.set_shot_files(
                    photo_id,
                    original_path=primary_s,
                    filename=primary.name,
                    file_hash=h,
                    file_size=primary.stat().st_size,
                    created_time=created,
                    sidecar_paths=sidecars,
                    flush=False,
                )
                if primary_changed:
                    counters["moved_or_renamed"] += 1
                else:
                    counters["already_present"] += 1
            else:
                counters["already_present"] += 1
            for dup_id in absorb_ids:
                db.absorb_duplicate_photo(photo_id, dup_id)
            seen_ids.add(photo_id)
            photo = db.get_photo(photo_id)
            # Primary switched RAW↔JPG → rebuild preview / quality.
            if primary_changed and photo:
                old_preview = photo.get("preview_path")
                if old_preview and Path(old_preview).exists():
                    try:
                        Path(old_preview).unlink()
                    except OSError:
                        pass
                db.set_preview_path(photo_id, None, flush=False)
                photo = db.get_photo(photo_id)
        else:
            check = license_guard.check_import_photo(len(db.list_photos()), importing_new=True)
            if not check.allowed:
                import_progress["error"] = check.message
                import_progress["done"] += 1
                return
            photo_id = db.insert_photo(
                primary_s, primary.name, h, primary.stat().st_size, created
            )
            db.set_shot_files(
                photo_id,
                original_path=primary_s,
                filename=primary.name,
                file_hash=h,
                file_size=primary.stat().st_size,
                created_time=created,
                sidecar_paths=sidecars,
                flush=False,
            )
            for dup_id in absorb_ids:
                db.absorb_duplicate_photo(photo_id, dup_id)
            seen_ids.add(photo_id)
            photo = db.get_photo(photo_id)
            counters["imported"] += 1

        if photo:
            await ensure_preview(photo)
            photo = db.get_photo(photo_id) or photo
            if photo.get("capture_info") is None:
                await ensure_capture_info(photo)
            await _ensure_quality(photo)

        import_progress["done"] += 1
        if import_progress["done"] % 5 == 0:
            db.flush_store()


async def _backfill_missing_previews():
    """Gently build missing thumbs one-at-a-time so scrolling the grid doesn't
    compete with a stampede of 20MB decodes at startup."""
    await asyncio.sleep(1)  # let the first HTTP requests settle
    db.merge_raw_jpeg_twins()
    db.cleanup_orphan_previews()
    photos = [
        p for p in db.list_photos()
        if not (p.get("preview_path") and Path(p["preview_path"]).exists())
        and not _preview_path_for(p).exists()
    ]
    for photo in photos:
        try:
            await ensure_preview(photo)
        except Exception:  # noqa: BLE001
            pass
        await asyncio.sleep(0)  # yield to HTTP handlers
    db.flush_store()
    db.cleanup_orphan_previews()


async def _backfill_quality_scan():
    """After preview backfill, tag any photos still missing quality checks."""
    if not _quality_scan_allowed():
        return
    await asyncio.sleep(3)
    if quality_progress["running"] or analyze_progress["running"] or import_progress["running"]:
        return
    unchecked = db.list_quality_unchecked(include_failed=True)
    if not unchecked:
        return
    await _run_quality_scan(force=False)


@app.on_event("startup")
async def _startup_backfill():
    asyncio.create_task(_backfill_missing_previews())
    asyncio.create_task(_backfill_quality_scan())
    asyncio.create_task(_startup_validate_license())


async def _startup_validate_license():
    await asyncio.sleep(2)
    try:
        license_state.validate_saved_license()
    except Exception:  # noqa: BLE001
        pass


async def _run_import(folder: Path):
    files = image_processing.scan_folder(folder)
    groups = image_processing.group_shot_files(files)
    import_progress.update(running=True, total=len(groups), done=0,
                            imported=0, already_present=0, moved_or_renamed=0, newly_missing=0, error=None)
    seen_ids: set[int] = set()
    counters = {"imported": 0, "already_present": 0, "moved_or_renamed": 0}
    semaphore = asyncio.Semaphore(config.IMPORT_CONCURRENCY)
    try:
        await asyncio.gather(*(_import_one_shot(g, seen_ids, counters, semaphore) for g in groups))
        db.flush_store()
        db.merge_raw_jpeg_twins()
        newly_missing = db.mark_missing_under_root(str(folder), seen_ids)
        import_progress.update(
            imported=counters["imported"],
            already_present=counters["already_present"],
            moved_or_renamed=counters["moved_or_renamed"],
            newly_missing=len(newly_missing),
        )
    except Exception as e:  # noqa: BLE001
        import_progress["error"] = str(e)
    finally:
        import_progress["running"] = False
        db.flush_store()
        db.cleanup_orphan_previews()
        # Safety net: anything still unchecked after per-file import scan.
        if not import_progress.get("error") and not quality_progress["running"]:
            if _quality_scan_allowed():
                unchecked = db.list_quality_unchecked(include_failed=True)
                if unchecked:
                    asyncio.create_task(_run_quality_scan(force=False))


@app.post("/api/import")
async def import_folder(req: ImportRequest):
    folder = Path(req.folder).expanduser().resolve()
    if not folder.is_dir():
        raise HTTPException(400, f"Not a folder: {folder}")
    if import_progress["running"]:
        raise HTTPException(409, "Import already running")

    root = str(folder)
    is_new_project = not any(
        Path(p["root_path"]).resolve() == folder
        for p in db.list_projects()
    )
    if is_new_project:
        license_guard.raise_if_blocked(
            license_guard.check_new_project(len(db.list_projects()))
        )

    # Selecting a folder creates a project (or reopens an existing one for
    # the same root) instead of wiping previous work.
    project = db.create_or_open_project(root)
    asyncio.create_task(_run_import(folder))
    return {"started": True, "project": project}


@app.get("/api/import/progress")
def get_import_progress():
    return import_progress


@app.get("/api/projects")
def get_projects():
    return {
        "projects": db.list_projects(),
        "active": db.get_active_project(),
    }


@app.post("/api/projects/{project_id}/activate")
async def activate_project(project_id: int):
    try:
        project = db.activate_project(project_id)
    except KeyError:
        raise HTTPException(404, "Project not found")
    db.merge_raw_jpeg_twins()
    db.cleanup_orphan_previews(project_id)
    # Kick a gentle preview backfill for any RAW/JPEG still missing thumbs.
    asyncio.create_task(_backfill_missing_previews())
    return {"ok": True, "project": project}


@app.delete("/api/projects/{project_id}")
def remove_project(project_id: int):
    """Delete project index + previews. Original photos on disk are untouched.
    Works whether the folder still exists or not."""
    try:
        result = db.delete_project(project_id)
    except KeyError:
        raise HTTPException(404, "Project not found")
    return {"ok": True, **result}


@app.post("/api/reset")
def reset_database():
    """Clear photos inside the active project only (legacy endpoint)."""
    db.reset_all()
    return {"ok": True}


# --- analyze --------------------------------------------------------------

async def _analyze_one(photo: dict, semaphore: asyncio.Semaphore):
    async with semaphore:
        photo_id = photo["id"]
        db.set_analyzing(photo_id)
        try:
            preview_path = await ensure_preview(photo)
            technical = await asyncio.to_thread(
                image_processing.compute_technical_score, preview_path
            )
            ai_result = await ai_analysis.analyze_image(Path(photo["original_path"]))
            scores = scoring.combine(technical["technical_score"], ai_result)
            ai_result["technical"] = technical
            db.set_analysis_result(photo_id, scores, ai_result)
            analyze_progress["done"] += 1
        except Exception as e:  # noqa: BLE001
            db.set_analysis_error(photo_id, str(e))
            analyze_progress["failed"] += 1


async def _run_batch_analysis():
    photos = db.list_all_not_analyzed()
    analyze_progress.update(running=True, total=len(photos), done=0, failed=0)
    semaphore = asyncio.Semaphore(config.ANALYZE_CONCURRENCY)
    try:
        await asyncio.gather(*(_analyze_one(p, semaphore) for p in photos))
    finally:
        analyze_progress["running"] = False


@app.post("/api/analyze")
async def start_analysis():
    if analyze_progress["running"]:
        raise HTTPException(409, "Analysis already running")
    asyncio.create_task(_run_batch_analysis())
    return {"started": True}


@app.get("/api/analyze/progress")
def get_progress():
    return analyze_progress


# --- quality scan (local CV, no AI) ---------------------------------------

async def _quality_one(photo: dict, semaphore: asyncio.Semaphore, counters: dict):
    async with semaphore:
        try:
            preview_path = await ensure_preview(photo)
            result = await asyncio.to_thread(
                image_processing.compute_quality_tags, preview_path
            )
            db.set_quality_result(photo["id"], result, flush=False)
            counters["done"] += 1
            quality_progress["done"] = counters["done"]
        except Exception:  # noqa: BLE001
            db.set_quality_error(photo["id"], "quality scan failed", flush=False)
            counters["failed"] += 1
            quality_progress["failed"] = counters["failed"]
        if (counters["done"] + counters["failed"]) % 10 == 0:
            db.flush_store()


async def _run_quality_scan(*, force: bool = False):
    if not _quality_scan_allowed():
        return
    if quality_progress["running"]:
        return
    if force:
        photos = [p for p in db.list_photos() if not p.get("file_missing")]
    else:
        photos = db.list_quality_unchecked(include_failed=True)
    quality_progress.update(running=True, total=len(photos), done=0, failed=0)
    counters = {"done": 0, "failed": 0}
    semaphore = asyncio.Semaphore(max(1, config.PREVIEW_GEN_CONCURRENCY))
    try:
        if photos:
            await asyncio.gather(*(_quality_one(p, semaphore, counters) for p in photos))
        db.flush_store()
    finally:
        quality_progress["running"] = False


@app.post("/api/quality-scan")
async def start_quality_scan(force: bool = False):
    """Scan soft-focus / camera-shake / exposure tags with local OpenCV (no Gemini)."""
    if not _quality_scan_allowed():
        license_guard.raise_if_blocked(license_guard.check_defect_detection())
    if quality_progress["running"]:
        raise HTTPException(409, "Quality scan already running")
    if analyze_progress["running"]:
        raise HTTPException(409, "AI analysis is running; wait until it finishes")

    active = db.get_active_project()
    project_id = active["id"] if active else None
    if force:
        license_guard.raise_if_blocked(
            license_guard.check_quality_rescan(project_id, force=True)
        )
        license_guard.record_quality_rescan_if_needed(project_id, force=True)

    asyncio.create_task(_run_quality_scan(force=force))
    return {"started": True, "force": force}


@app.get("/api/quality-scan/progress")
def get_quality_progress():
    return quality_progress


# --- photos -----------------------------------------------------------------

@app.get("/api/photos")
def list_photos(status: Optional[str] = None, starred: Optional[bool] = None,
                 tag: Optional[str] = None,
                 min_soft_focus: Optional[float] = None,
                 min_camera_shake: Optional[float] = None,
                 min_blur: Optional[float] = None,
                 exposure_verdict: Optional[str] = None,
                 recommendation: Optional[str] = None,
                 min_score: Optional[float] = None, max_score: Optional[float] = None):
    if tag and tag not in ("soft_focus", "camera_shake"):
        raise HTTPException(400, "tag must be soft_focus or camera_shake")
    if tag or min_soft_focus is not None or min_camera_shake is not None or min_blur is not None:
        license_guard.raise_if_blocked(license_guard.check_defect_detection())
    if exposure_verdict is not None:
        license_guard.raise_if_blocked(license_guard.check_exposure_detection())
    verdict_list = exposure_verdict.split(",") if exposure_verdict else None
    photos = db.list_photos(
        status, starred, tag, min_soft_focus, min_camera_shake,
        min_blur, verdict_list,
        recommendation, min_score, max_score,
    )
    return [_strip_defect_from_photo(p) for p in photos]


@app.get("/api/photos/missing")
def get_missing_photos():
    db.sync_missing_flags()
    return db.list_missing()


@app.post("/api/photos/purge-missing")
def purge_missing_photos():
    """Drop DB/preview records for missing originals (no trash needed)."""
    removed = db.purge_missing()
    return {"removed": removed, "count": len(removed)}


@app.get("/api/photos/{photo_id}")
def get_photo(photo_id: int):
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    return _strip_defect_from_photo(photo)


@app.get("/api/photos/{photo_id}/capture")
async def get_capture_info(photo_id: int):
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    return await ensure_capture_info(photo)


@app.get("/api/photos/{photo_id}/histogram")
async def get_histogram(photo_id: int):
    license_guard.raise_if_blocked(license_guard.check_exposure_detection())
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    try:
        preview_path = await ensure_preview(photo)
    except FileNotFoundError:
        raise HTTPException(404, "Original file missing")
    return await asyncio.to_thread(image_processing.compute_rgb_histogram, preview_path)


@app.get("/api/photos/{photo_id}/edit-source")
async def get_edit_source(photo_id: int):
    """Preview-quality JPEG with rotation/flip baked in but NOT straighten/crop —
    what the crop/straighten editor draws on, so re-opening it to adjust an
    existing crop starts from the un-cropped frame rather than compounding."""
    license_guard.raise_if_blocked(license_guard.check_editing())
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    original = Path(photo.get("original_path") or "")
    if not original.exists():
        raise HTTPException(404, "Original file missing")
    edit_dir = db.preview_dir_for() / "_edit"
    edit_dir.mkdir(parents=True, exist_ok=True)
    tmp_path = edit_dir / f"{photo_id}.jpg"
    try:
        await asyncio.to_thread(
            image_processing.generate_preview,
            original,
            tmp_path,
            photo.get("sidecar_paths"),
            photo.get("rotation", 0),
            photo.get("flip_h", False),
            0.0,
            None,
        )
    except Exception as e:  # noqa: BLE001
        raise HTTPException(500, f"Failed to build edit source: {e}")
    return FileResponse(tmp_path, headers=NO_STORE_HEADERS)


class StatusUpdate(BaseModel):
    status: str


@app.post("/api/photos/{photo_id}/status")
def update_status(photo_id: int, req: StatusUpdate):
    if req.status not in ("keep", "reject"):
        raise HTTPException(400, "Invalid status")
    if not db.get_photo(photo_id):
        raise HTTPException(404, "Not found")
    try:
        db.set_status(photo_id, req.status)
    except db.StatusError as e:
        raise HTTPException(400, str(e))
    return {"ok": True}


class BulkStatusUpdate(BaseModel):
    ids: list[int]
    status: str


@app.post("/api/photos/bulk-status")
def bulk_update_status(req: BulkStatusUpdate):
    if req.status not in ("keep", "reject"):
        raise HTTPException(400, "Invalid status")
    if not req.ids:
        return {"updated": [], "skipped": []}
    try:
        result = db.bulk_set_status(req.ids, req.status)
    except db.StatusError as e:
        raise HTTPException(400, str(e))
    return result


class StarUpdate(BaseModel):
    starred: bool


@app.post("/api/photos/{photo_id}/star")
def update_star(photo_id: int, req: StarUpdate):
    if not db.get_photo(photo_id):
        raise HTTPException(404, "Not found")
    db.set_starred(photo_id, req.starred)
    return {"ok": True}


class BulkStarUpdate(BaseModel):
    ids: list[int]
    starred: bool


@app.post("/api/photos/bulk-star")
def bulk_update_star(req: BulkStarUpdate):
    if not req.ids:
        return {"updated": 0}
    count = db.bulk_set_starred(req.ids, req.starred)
    return {"updated": count}


TRANSFORM_ACTIONS = ("rotate_cw", "rotate_ccw", "flip_h")


class TransformUpdate(BaseModel):
    action: str


@app.post("/api/photos/{photo_id}/transform")
def update_transform(photo_id: int, req: TransformUpdate):
    if req.action not in TRANSFORM_ACTIONS:
        raise HTTPException(400, "Invalid action")
    if not db.get_photo(photo_id):
        raise HTTPException(404, "Not found")
    db.set_transform(photo_id, req.action)
    return {"ok": True}


class BulkTransformUpdate(BaseModel):
    ids: list[int]
    action: str


@app.post("/api/photos/bulk-transform")
def bulk_update_transform(req: BulkTransformUpdate):
    if req.action not in TRANSFORM_ACTIONS:
        raise HTTPException(400, "Invalid action")
    if not req.ids:
        return {"updated": 0}
    count = db.bulk_set_transform(req.ids, req.action)
    return {"updated": count}


STRAIGHTEN_MAX_ANGLE = 45.0


class StraightenUpdate(BaseModel):
    angle: float


@app.post("/api/photos/{photo_id}/straighten")
def update_straighten(photo_id: int, req: StraightenUpdate):
    license_guard.raise_if_blocked(license_guard.check_editing())
    if abs(req.angle) > STRAIGHTEN_MAX_ANGLE:
        raise HTTPException(400, f"angle must be within ±{STRAIGHTEN_MAX_ANGLE}")
    if not db.get_photo(photo_id):
        raise HTTPException(404, "Not found")
    db.set_straighten(photo_id, req.angle)
    return {"ok": True}


class CropUpdate(BaseModel):
    x: Optional[float] = None
    y: Optional[float] = None
    w: Optional[float] = None
    h: Optional[float] = None


@app.post("/api/photos/{photo_id}/crop")
def update_crop(photo_id: int, req: CropUpdate):
    license_guard.raise_if_blocked(license_guard.check_editing())
    if not db.get_photo(photo_id):
        raise HTTPException(404, "Not found")
    values = (req.x, req.y, req.w, req.h)
    if all(v is None for v in values):
        crop = None
    elif any(v is None for v in values):
        raise HTTPException(400, "x/y/w/h must all be provided together, or all omitted to clear")
    else:
        crop = {"x": req.x, "y": req.y, "w": req.w, "h": req.h}
    try:
        db.set_crop(photo_id, crop)
    except db.TransformError as e:
        raise HTTPException(400, str(e))
    return {"ok": True}


@app.post("/api/photos/{photo_id}/overwrite-original")
async def overwrite_original(photo_id: int):
    """Bake the current rotation/flip/straighten/crop into the original file
    in place — destructive, no undo. Frontend must confirm with the user
    before calling this; only ever reached from the crop editor's preview
    step, never automatically.
    """
    license_guard.raise_if_blocked(license_guard.check_editing())
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    original = Path(photo["original_path"])
    if not original.exists():
        raise HTTPException(404, "Original file missing")
    if (
        photo.get("is_raw")
        or image_processing.is_raw_path(original)
        or image_processing.is_heic_path(original)
    ):
        raise HTTPException(400, "RAW/HEIC originals cannot be overwritten — use Save As instead")
    if not (photo.get("rotation") or photo.get("flip_h") or photo.get("straighten_angle") or photo.get("crop")):
        raise HTTPException(400, "No edits to apply")

    try:
        await asyncio.to_thread(
            image_processing.overwrite_original,
            original,
            photo.get("rotation", 0),
            photo.get("flip_h", False),
            photo.get("straighten_angle", 0.0),
            photo.get("crop"),
        )
    except Exception as e:  # noqa: BLE001
        raise HTTPException(500, f"Overwrite failed: {e}")

    new_hash = await asyncio.to_thread(image_processing.file_hash, original)
    new_size = original.stat().st_size
    db.commit_overwrite_original(photo_id, new_hash, new_size)
    info = await asyncio.to_thread(image_processing.extract_capture_info, original)
    db.set_capture_info(photo_id, info)
    return {"ok": True}


@app.post("/api/photos/{photo_id}/save-as")
async def save_photo_as(photo_id: int):
    """Non-destructive single-photo export — bakes the current edit into a
    new file next to the project (same convention as batch export), leaving
    the original untouched.
    """
    license_guard.raise_if_blocked(license_guard.check_export())
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    src = Path(photo["original_path"])
    if not src.exists():
        raise HTTPException(404, "Original file missing")

    active = db.get_active_project()
    if not active:
        raise HTTPException(400, "No active project")
    project_root = Path(active["root_path"]).resolve()
    target_format = "png" if src.suffix.lower() == ".png" else "jpeg"
    out_root = project_root / EXPORT_FORMAT_FOLDER[target_format]
    out_root.mkdir(parents=True, exist_ok=True)

    try:
        rel = src.resolve().relative_to(project_root)
    except ValueError:
        rel = Path(src.name)
    dest = _unique_export_dest(out_root / rel.with_suffix(EXPORT_FORMAT_EXT[target_format]), set())

    try:
        dest.parent.mkdir(parents=True, exist_ok=True)
        await asyncio.to_thread(
            image_processing.convert_image,
            src,
            dest,
            target_format,
            photo.get("rotation", 0),
            photo.get("flip_h", False),
            photo.get("straighten_angle", 0.0),
            photo.get("crop"),
        )
    except Exception as e:  # noqa: BLE001
        raise HTTPException(500, f"Save As failed: {e}")
    return {"output": str(dest), "output_dir": str(out_root)}


@app.get("/api/stats")
def get_stats():
    # Cheap existence check so "遺失" badges stay honest after Finder deletes.
    db.sync_missing_flags()
    return _sanitize_stats(db.get_stats())


@app.get("/api/version")
def get_version():
    return {"name": "PhotoPick", "version": config.app_version()}


# /original/{id} URLs are stable, but what they point to on disk isn't
# (move/rename detection repoints an id's original_path, ids also get reused
# after a reset). Force revalidation so the browser can't keep serving a
# stale cached image for an id whose underlying file changed.
# Ids get reused after reset / path repoint, so a bare /original/{id} is not a
# stable content URL. Clients also pass ?h=<file_hash> as a cache buster;
# no-store stops the browser from keeping a previous id→bytes mapping.
NO_STORE_HEADERS = {"Cache-Control": "no-store"}


@app.get("/original/{photo_id}")
async def get_original(photo_id: int):
    """Serve the file the lightbox displays.

    Browsers cannot render camera RAW or HEIC reliably, so for those we serve
    the generated JPEG preview instead (grid already uses /preview).
    Straighten/crop also require going through the preview pipeline — unlike
    rotation/flip (faked client-side with a CSS transform, so the untouched
    original can still be shown at full resolution), there is no cheap way to
    preview an arbitrary-angle straighten + crop without baking it in.
    Reject/delete still operate on the real original path on disk.
    """
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    original = photo.get("original_path") or ""
    needs_preview = (
        photo.get("is_raw")
        or image_processing.is_raw_path(original)
        or image_processing.is_heic_path(original)
        or bool(photo.get("straighten_angle"))
        or bool(photo.get("crop"))
    )
    if needs_preview:
        try:
            preview_path = await ensure_preview(photo)
        except FileNotFoundError:
            raise HTTPException(404, "Original file missing")
        except Exception as e:  # noqa: BLE001
            raise HTTPException(500, f"Preview failed: {e}")
        return FileResponse(preview_path, headers=NO_STORE_HEADERS)
    return FileResponse(photo["original_path"], headers=NO_STORE_HEADERS)


@app.get("/preview/{photo_id}")
async def get_preview(photo_id: int):
    photo = db.get_photo(photo_id)
    if not photo:
        raise HTTPException(404, "Not found")
    try:
        preview_path = await ensure_preview(photo)
    except FileNotFoundError:
        raise HTTPException(404, "Original file missing")
    except Exception as e:  # noqa: BLE001
        raise HTTPException(500, f"Preview failed: {e}")
    return FileResponse(preview_path, headers=NO_STORE_HEADERS)


# --- batch delete / rename ----------------------------------------------

class BatchDeleteRequest(BaseModel):
    ids: list[int]


@app.post("/api/batch/delete")
def batch_delete(req: BatchDeleteRequest):
    license_guard.raise_if_blocked(license_guard.check_batch_delete())
    moved, cleared_missing, errors = [], [], []
    for photo_id in req.ids:
        photo = db.get_photo(photo_id)
        if not photo:
            errors.append({"id": photo_id, "filename": None, "error": "not found"})
            continue
        try:
            disk_paths = db.photo_disk_paths(photo)
            any_existed = False
            for path_s in disk_paths:
                src = Path(path_s)
                if src.exists():
                    send2trash(str(src))
                    any_existed = True
            if any_existed:
                moved.append({
                    "id": photo_id,
                    "filename": photo["filename"],
                    "files": [Path(p).name for p in disk_paths],
                })
            else:
                # Already gone on disk — just drop the DB/preview record.
                cleared_missing.append({
                    "id": photo_id,
                    "filename": photo["filename"],
                    "missing_reason": photo.get("missing_reason") or "gone",
                })
            if photo.get("preview_path") and Path(photo["preview_path"]).exists():
                Path(photo["preview_path"]).unlink()
            # Also drop any alternate hash-named preview left from reimport.
            candidate = _preview_path_for(photo)
            if candidate.exists() and (
                not photo.get("preview_path")
                or Path(photo["preview_path"]).resolve() != candidate.resolve()
            ):
                try:
                    candidate.unlink()
                except OSError:
                    pass
            db.delete_photo_row(photo_id)
        except Exception as e:  # noqa: BLE001
            errors.append({
                "id": photo_id,
                "filename": photo.get("filename"),
                "error": str(e),
            })
    return {
        "moved_to_trash": moved,
        "cleared_missing": cleared_missing,
        "errors": errors,
    }


class BatchRenameRequest(BaseModel):
    ids: list[int]  # order = final serial order
    prefix: str
    separator: str = "-"
    start: int = 1
    pad: int = 0  # 0 = no zero-pad; 2/3/4 = zero-padded width


def _safe_filename_part(text: str) -> str:
    # Block path traversal / illegal filename chars across macOS & Windows.
    bad = '<>:"/\\|?*\0'
    cleaned = "".join("_" if c in bad else c for c in text).strip().rstrip(".")
    return cleaned


@app.post("/api/batch/rename")
def batch_rename(req: BatchRenameRequest):
    license_guard.raise_if_blocked(license_guard.check_batch_rename())
    if not req.ids:
        raise HTTPException(400, "No photos selected")
    if req.start < 0:
        raise HTTPException(400, "start must be >= 0")
    if req.pad < 0 or req.pad > 8:
        raise HTTPException(400, "pad must be between 0 and 8")

    prefix = _safe_filename_part(req.prefix)
    separator = _safe_filename_part(req.separator)
    if not prefix:
        raise HTTPException(400, "prefix is required")

    plans = []
    batch_paths = set()
    for i, photo_id in enumerate(req.ids):
        photo = db.get_photo(photo_id)
        if not photo:
            raise HTTPException(404, f"Photo not found: {photo_id}")
        src = Path(photo["original_path"])
        if not src.exists():
            raise HTTPException(400, f"Missing file: {photo['filename']}")
        sidecar_srcs = []
        for sc in photo.get("sidecar_paths") or []:
            scp = Path(sc)
            if not scp.exists():
                raise HTTPException(400, f"Missing sidecar: {scp.name}")
            sidecar_srcs.append(scp)
            batch_paths.add(str(scp.resolve()))
        batch_paths.add(str(src.resolve()))
        n = req.start + i
        serial = str(n).zfill(req.pad) if req.pad else str(n)
        new_stem = f"{prefix}{separator}{serial}"
        new_name = f"{new_stem}{src.suffix.lower()}"
        dest = src.with_name(new_name)
        sidecar_plans = []
        for scp in sidecar_srcs:
            sc_dest = scp.with_name(f"{new_stem}{scp.suffix.lower()}")
            sidecar_plans.append({"src": scp, "dest": sc_dest})
            batch_paths.add(str(sc_dest.resolve()) if sc_dest.exists() else str(sc_dest))
        plans.append({
            "photo": photo,
            "src": src,
            "dest": dest,
            "new_name": new_name,
            "sidecars": sidecar_plans,
        })

    # Unique targets within the batch (primary + sidecars)
    dest_names = []
    for p in plans:
        dest_names.append(p["dest"].name)
        for sc in p["sidecars"]:
            dest_names.append(sc["dest"].name)
    if len(dest_names) != len(set(dest_names)):
        raise HTTPException(400, "Rename would create duplicate filenames in this batch")

    # Don't clobber files that aren't part of this rename set
    for p in plans:
        targets = [p["dest"]] + [sc["dest"] for sc in p["sidecars"]]
        for dest in targets:
            if dest.exists() and str(dest.resolve()) not in batch_paths:
                raise HTTPException(400, f"Target already exists: {dest.name}")

    # Two-phase rename avoids collisions when swapping names inside the set.
    temps = []
    renamed = []
    try:
        for p in plans:
            ops = [{"src": p["src"], "dest": p["dest"], "kind": "primary"}]
            for sc in p["sidecars"]:
                ops.append({"src": sc["src"], "dest": sc["dest"], "kind": "sidecar"})
            phase = []
            for op in ops:
                if op["src"].resolve() == op["dest"].resolve():
                    phase.append({"tmp": None, "dest": op["dest"], "kind": op["kind"]})
                    continue
                tmp = op["src"].with_name(
                    f".__photopick_ren_{p['photo']['id']}__{op['src'].name}"
                )
                op["src"].rename(tmp)
                phase.append({"tmp": tmp, "dest": op["dest"], "kind": op["kind"]})
            temps.append(phase)

        for p, phase in zip(plans, temps):
            primary_dest = p["dest"]
            new_sidecars = []
            for item in phase:
                if item["tmp"] is not None:
                    item["tmp"].rename(item["dest"])
                if item["kind"] == "primary":
                    primary_dest = item["dest"]
                else:
                    new_sidecars.append(str(item["dest"]))
            db.rename_photo_files(
                p["photo"]["id"],
                str(primary_dest),
                primary_dest.name,
                new_sidecars,
            )
            renamed.append({"id": p["photo"]["id"], "filename": primary_dest.name})
    except Exception as e:  # noqa: BLE001
        raise HTTPException(500, f"Rename failed: {e}")

    return {"renamed": renamed, "errors": []}


# --- batch export / convert ----------------------------------------------
# Full-res RAW demosaic makes this slow for large batches, so it runs as a
# background task with pollable progress (same shape as import/analyze).

EXPORT_FORMAT_FOLDER = {"jpeg": "JPG", "png": "PNG", "webp": "WEBP"}
EXPORT_FORMAT_EXT = {"jpeg": ".jpg", "png": ".png", "webp": ".webp"}

export_progress = {
    "running": False, "total": 0, "done": 0, "failed": 0,
    "output_dir": None, "exported": [], "errors": [],
}


class BatchExportRequest(BaseModel):
    ids: list[int]
    format: str  # "jpeg" | "png" | "webp"
    quality: int | None = None  # 1-100, jpeg/webp only; ignored for png
    output_dir: str | None = None  # defaults to {project_root}/{FORMAT_FOLDER} if omitted


def _unique_export_dest(dest: Path, used: set) -> Path:
    """Avoid clobbering: append -2, -3… until the name is free."""
    if str(dest.resolve(strict=False)) not in used and not dest.exists():
        return dest
    stem, suffix, parent = dest.stem, dest.suffix, dest.parent
    n = 2
    while True:
        candidate = parent / f"{stem}-{n}{suffix}"
        if str(candidate.resolve(strict=False)) not in used and not candidate.exists():
            return candidate
        n += 1


async def _export_one(
    photo: dict, dest: Path, target_format: str, quality: int | None, semaphore: asyncio.Semaphore
):
    async with semaphore:
        try:
            dest.parent.mkdir(parents=True, exist_ok=True)
            await asyncio.to_thread(
                image_processing.convert_image,
                Path(photo["original_path"]),
                dest,
                target_format,
                photo.get("rotation", 0),
                photo.get("flip_h", False),
                photo.get("straighten_angle", 0.0),
                photo.get("crop"),
                quality,
            )
            export_progress["exported"].append(
                {"id": photo["id"], "filename": photo["filename"], "output": str(dest)}
            )
        except Exception as e:  # noqa: BLE001
            export_progress["errors"].append(
                {"id": photo["id"], "filename": photo.get("filename"), "error": str(e)}
            )
            export_progress["failed"] += 1
        export_progress["done"] += 1


async def _run_export(
    ids: list[int], target_format: str, quality: int | None, project_root: Path, out_root: Path
):
    used_dests: set = set()
    tasks = []
    semaphore = asyncio.Semaphore(config.EXPORT_CONCURRENCY)
    for photo_id in ids:
        photo = db.get_photo(photo_id)
        if not photo:
            export_progress["errors"].append({"id": photo_id, "filename": None, "error": "not found"})
            export_progress["done"] += 1
            export_progress["failed"] += 1
            continue
        src = Path(photo["original_path"])
        if not src.exists():
            export_progress["errors"].append(
                {"id": photo_id, "filename": photo["filename"], "error": "file missing"}
            )
            export_progress["done"] += 1
            export_progress["failed"] += 1
            continue
        try:
            rel = src.resolve().relative_to(project_root)
        except ValueError:
            rel = Path(src.name)
        dest = _unique_export_dest(
            out_root / rel.with_suffix(EXPORT_FORMAT_EXT[target_format]), used_dests
        )
        used_dests.add(str(dest.resolve(strict=False)))
        tasks.append(_export_one(photo, dest, target_format, quality, semaphore))

    if tasks:
        await asyncio.gather(*tasks)
    export_progress["running"] = False


@app.post("/api/batch/export")
async def batch_export(req: BatchExportRequest):
    license_guard.raise_if_blocked(license_guard.check_export())
    if req.format not in EXPORT_FORMAT_FOLDER:
        raise HTTPException(400, "Unsupported format")
    if not req.ids:
        raise HTTPException(400, "No photos selected")
    if req.quality is not None and not (1 <= req.quality <= 100):
        raise HTTPException(400, "quality must be between 1 and 100")
    if export_progress["running"]:
        raise HTTPException(409, "Export already running")

    active = db.get_active_project()
    if not active:
        raise HTTPException(400, "No active project")
    project_root = Path(active["root_path"]).resolve()
    if req.output_dir and req.output_dir.strip():
        out_root = Path(req.output_dir.strip()).expanduser()
    else:
        out_root = project_root / EXPORT_FORMAT_FOLDER[req.format]
    try:
        out_root.mkdir(parents=True, exist_ok=True)
    except OSError as e:
        raise HTTPException(400, f"Cannot write to output folder: {e}")

    export_progress.update(
        running=True, total=len(req.ids), done=0, failed=0,
        output_dir=str(out_root), exported=[], errors=[],
    )
    asyncio.create_task(_run_export(req.ids, req.format, req.quality, project_root, out_root))
    return {"started": True}


@app.get("/api/batch/export/progress")
def get_export_progress():
    return export_progress


EXPORT_ESTIMATE_SAMPLE_SIZE = 5


@app.post("/api/batch/export/estimate")
async def estimate_export(req: BatchExportRequest):
    """Real (not guessed) size/dimension estimate for the export modal.

    Converts a small evenly-spread sample of the selection through the
    actual export pipeline into throwaway temp files, measures the
    compression ratio, and scales that up to the full selection's current
    total size. Full-resolution RAW decode is too slow to do for the whole
    selection just for a preview number.
    """
    license_guard.raise_if_blocked(license_guard.check_export())
    if req.format not in EXPORT_FORMAT_FOLDER:
        raise HTTPException(400, "Unsupported format")
    if not req.ids:
        raise HTTPException(400, "No photos selected")
    if req.quality is not None and not (1 <= req.quality <= 100):
        raise HTTPException(400, "quality must be between 1 and 100")

    photos = []
    for pid in req.ids:
        photo = db.get_photo(pid)
        if photo and Path(photo["original_path"]).exists():
            photos.append(photo)
    if not photos:
        raise HTTPException(400, "No valid photos to estimate")

    current_total_bytes = sum(p.get("file_size") or 0 for p in photos)

    n = min(EXPORT_ESTIMATE_SAMPLE_SIZE, len(photos))
    step = max(1, len(photos) // n)
    sample = photos[::step][:n]

    ratios = []
    for photo in sample:
        src = Path(photo["original_path"])
        try:
            result = await asyncio.to_thread(
                image_processing.estimate_export_one,
                src, req.format,
                photo.get("rotation", 0), photo.get("flip_h", False),
                photo.get("straighten_angle", 0.0), photo.get("crop"),
                req.quality,
            )
        except Exception:  # noqa: BLE001
            continue
        src_size = photo.get("file_size") or src.stat().st_size
        if src_size:
            ratios.append(result["output_bytes"] / src_size)

    if not ratios:
        raise HTTPException(500, "Could not estimate — all sampled conversions failed")
    avg_ratio = sum(ratios) / len(ratios)
    estimated_total_bytes = round(current_total_bytes * avg_ratio)

    # Rotation/straighten/crop follow a fixed geometry formula, so unlike file
    # size (which needs a real encode to know) every cropped photo's exact
    # output dimensions can be listed — not just the byte-size sample.
    dimension_changes = []
    for photo in photos:
        if not photo.get("crop"):
            continue
        info = await ensure_capture_info(photo)
        width, height = info.get("width"), info.get("height")
        if not width or not height:
            continue
        output_dims = image_processing.compute_transformed_dims(
            width, height, photo.get("rotation", 0), photo.get("straighten_angle", 0.0), photo.get("crop")
        )
        dimension_changes.append(
            {
                "id": photo["id"],
                "filename": photo["filename"],
                "original": [width, height],
                "output": list(output_dims),
            }
        )

    return {
        "current_total_bytes": current_total_bytes,
        "estimated_total_bytes": estimated_total_bytes,
        "sampled": len(ratios),
        "total": len(photos),
        "dimension_changes": dimension_changes,
    }


class OpenFolderRequest(BaseModel):
    path: str


@app.post("/api/open-folder")
def open_folder(req: OpenFolderRequest):
    folder = Path(req.path)
    if not folder.is_dir():
        raise HTTPException(404, "Folder not found")
    subprocess.run(["open", str(folder)], check=False)
    return {"ok": True}


# --- user settings (theme / language) ---------------------------------------

class UserSettingsUpdate(BaseModel):
    theme: Optional[str] = None
    lang: Optional[str] = None


@app.get("/api/settings")
def get_user_settings():
    return user_settings.load()


@app.put("/api/settings")
def put_user_settings(body: UserSettingsUpdate):
    theme = body.theme if body.theme in ("light", "dark") else None
    return user_settings.save(theme=theme, lang=body.lang)


# --- license / trial -------------------------------------------------------

class LicenseActivateRequest(BaseModel):
    license_key: str


@app.get("/api/license/status")
def get_license_status():
    projects = db.list_projects()
    active = db.get_active_project()
    return license_state.build_status(
        project_count=len(projects),
        photos_in_project=len(db.list_photos()),
        active_project_id=active["id"] if active else None,
    )


@app.post("/api/license/activate")
def activate_license_endpoint(req: LicenseActivateRequest):
    try:
        record = license_state.activate_license(req.license_key.strip())
    except everflow_client.LicenseApiError as exc:
        raise HTTPException(status_code=400, detail={"code": exc.code, "message": str(exc)}) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail={"message": str(exc)}) from exc
    public = dict(record)
    public.pop("license_key", None)
    return {"ok": True, "license": public}


@app.post("/api/license/deactivate")
def deactivate_license():
    license_state.deactivate_license()
    return {"ok": True}


@app.get("/api/releases/check")
def check_app_release():
    return release_check.check_update()


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