import os
import json
import shutil
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path

from . import config

_lock = threading.RLock()
# v2 multi-project store. Migrated from legacy {next_id, photos} on load.
_state = {
    "version": 2,
    "next_project_id": 1,
    "active_project_id": None,
    "projects": {},
}


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _empty_state():
    return {
        "version": 2,
        "next_project_id": 1,
        "active_project_id": None,
        "projects": {},
    }


def _migrate_photo_record(rec: dict) -> bool:
    """Normalize status/starred. Returns True if the record was changed."""
    changed = False
    if "starred" not in rec:
        old = rec.get("status")
        if old == "reject":
            rec["status"] = "reject"
            rec["starred"] = False
        elif old == "keep":
            # Previously intentional Keep → treat as starred shortlist.
            rec["status"] = "keep"
            rec["starred"] = True
        else:
            # pending / review / unknown → default keep, not starred
            rec["status"] = "keep"
            rec["starred"] = False
        changed = True
    else:
        if rec.get("status") not in ("keep", "reject"):
            rec["status"] = "keep"
            changed = True
        if not isinstance(rec.get("starred"), bool):
            rec["starred"] = bool(rec.get("starred"))
            changed = True
    # Starred rejects are invalid; unstar to keep reject, or keep star and restore.
    if rec.get("starred") and rec.get("status") == "reject":
        rec["starred"] = False
        changed = True
    if "quality_tags" not in rec:
        rec["quality_tags"] = []
        changed = True
    elif not isinstance(rec.get("quality_tags"), list):
        rec["quality_tags"] = []
        changed = True
    if "quality_metrics" not in rec:
        rec["quality_metrics"] = None
        changed = True
    if "quality_state" not in rec:
        rec["quality_state"] = "not_checked"
        changed = True
    elif rec.get("quality_state") not in ("not_checked", "checked", "failed"):
        rec["quality_state"] = "not_checked"
        changed = True
    # Derive severity scores from cached metrics when missing (no rescan needed).
    if rec.get("soft_focus_score") is None or rec.get("camera_shake_score") is None:
        metrics = rec.get("quality_metrics") or {}
        if metrics.get("focus") is not None:
            soft, shake = _defect_scores_from_metrics(
                metrics.get("focus"), metrics.get("anisotropy")
            )
            rec["soft_focus_score"] = soft
            rec["camera_shake_score"] = shake
            rec["quality_tags"] = _tags_from_defect_scores(soft, shake)
            changed = True
        elif "soft_focus_score" not in rec:
            rec["soft_focus_score"] = None
            rec["camera_shake_score"] = None
            changed = True
    if "is_raw" not in rec:
        path = rec.get("original_path") or rec.get("filename") or ""
        rec["is_raw"] = Path(path).suffix.lower() in config.RAW_EXTENSIONS
        changed = True
    if "sidecar_paths" not in rec:
        rec["sidecar_paths"] = []
        changed = True
    elif not isinstance(rec.get("sidecar_paths"), list):
        rec["sidecar_paths"] = []
        changed = True
    if rec.get("rotation") not in (0, 90, 180, 270):
        rec["rotation"] = 0
        changed = True
    if not isinstance(rec.get("flip_h"), bool):
        rec["flip_h"] = False
        changed = True
    if not isinstance(rec.get("straighten_angle"), (int, float)):
        rec["straighten_angle"] = 0.0
        changed = True
    if not _valid_crop(rec.get("crop")):
        rec["crop"] = None
        changed = True
    return changed


def _valid_crop(crop) -> bool:
    if crop is None:
        return True
    if not isinstance(crop, dict):
        return False
    try:
        x, y, w, h = float(crop["x"]), float(crop["y"]), float(crop["w"]), float(crop["h"])
    except (KeyError, TypeError, ValueError):
        return False
    eps = 1e-6
    return (
        -eps <= x <= 1 + eps and -eps <= y <= 1 + eps
        and w > eps and h > eps
        and x + w <= 1 + eps and y + h <= 1 + eps
    )


def _defect_scores_from_metrics(focus, anisotropy) -> tuple:
    """Pure score split — keep in sync with image_processing.defect_scores."""
    blur = max(0.0, 100.0 - float(focus or 0.0))
    an = float(anisotropy if anisotropy is not None else 1.0)
    dir_factor = max(0.0, min(1.0, (an - 1.1) / 1.1))
    return round(blur * (1.0 - dir_factor), 1), round(blur * dir_factor, 1)


def _tags_from_defect_scores(soft: float, shake: float, min_score: float = 40.0) -> list:
    tags = []
    if soft is not None and soft >= min_score:
        tags.append("soft_focus")
    if shake is not None and shake >= min_score:
        tags.append("camera_shake")
    return tags


def _migrate_if_needed(raw: dict) -> dict:
    """Upgrade legacy single-bucket store into one project."""
    if raw.get("version") == 2 and "projects" in raw:
        return raw

    photos = raw.get("photos") or {}
    for rec in photos.values():
        _migrate_photo_record(rec)
    next_id = int(raw.get("next_id") or 1)
    state = _empty_state()
    if not photos:
        return state

    # Infer a reasonable root from common path prefix of originals.
    paths = [p.get("original_path") or "" for p in photos.values()]
    root = str(Path(paths[0]).parent) if paths else ""
    if len(paths) > 1:
        try:
            root = str(Path(os.path.commonpath(paths)))
        except ValueError:
            root = str(Path(paths[0]).parent)

    name = Path(root).name or "匯入的專案"
    pid = 1
    state["next_project_id"] = 2
    state["active_project_id"] = pid
    state["projects"][str(pid)] = {
        "id": pid,
        "name": name,
        "root_path": root,
        "created_at": now_iso(),
        "next_id": next_id,
        "photos": photos,
    }
    return state


def _load():
    global _state
    if config.STORE_PATH.exists():
        with open(config.STORE_PATH, "r", encoding="utf-8") as f:
            raw = json.load(f)
        migrated_structure = raw.get("version") != 2 or "projects" not in raw
        _state = _migrate_if_needed(raw)
        photo_changed = False
        for proj in _state.get("projects", {}).values():
            for rec in (proj.get("photos") or {}).values():
                if _migrate_photo_record(rec):
                    photo_changed = True
        if migrated_structure or photo_changed:
            _save()
    else:
        _state = _empty_state()


def _save():
    fd, tmp_path = tempfile.mkstemp(dir=str(config.STORE_PATH.parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(_state, f, ensure_ascii=False)
        os.replace(tmp_path, config.STORE_PATH)
    except Exception:
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
        raise


def init_db():
    with _lock:
        _load()


def _active_project() -> dict | None:
    pid = _state.get("active_project_id")
    if pid is None:
        return None
    return _state["projects"].get(str(pid))


def _require_active() -> dict:
    proj = _active_project()
    if not proj:
        raise RuntimeError("No active project")
    return proj


def _photos() -> dict:
    proj = _active_project()
    return proj["photos"] if proj else {}


def preview_dir_for(project_id: int | None = None) -> Path:
    pid = project_id if project_id is not None else _state.get("active_project_id")
    if pid is None:
        return config.PREVIEW_DIR / "_none"
    path = config.PREVIEW_DIR / str(pid)
    path.mkdir(parents=True, exist_ok=True)
    return path


def _project_public(proj: dict) -> dict:
    root = Path(proj["root_path"])
    return {
        "id": proj["id"],
        "name": proj["name"],
        "root_path": proj["root_path"],
        "created_at": proj.get("created_at"),
        "photo_count": len(proj.get("photos") or {}),
        "root_exists": root.exists(),
        "active": proj["id"] == _state.get("active_project_id"),
    }


def list_projects() -> list:
    with _lock:
        projects = [_project_public(p) for p in _state["projects"].values()]
    projects.sort(key=lambda p: p.get("created_at") or "", reverse=True)
    return projects


def get_active_project() -> dict | None:
    with _lock:
        proj = _active_project()
        return _project_public(proj) if proj else None


def create_or_open_project(root_path: str, name: str | None = None) -> dict:
    """If a project already uses this root, activate it; otherwise create one."""
    root = str(Path(root_path).expanduser().resolve())
    display = name or Path(root).name or "未命名專案"
    with _lock:
        for proj in _state["projects"].values():
            if Path(proj["root_path"]).resolve() == Path(root):
                _state["active_project_id"] = proj["id"]
                _save()
                return _project_public(proj)

        pid = _state["next_project_id"]
        _state["next_project_id"] = pid + 1
        proj = {
            "id": pid,
            "name": display,
            "root_path": root,
            "created_at": now_iso(),
            "next_id": 1,
            "photos": {},
        }
        _state["projects"][str(pid)] = proj
        _state["active_project_id"] = pid
        _save()
        preview_dir_for(pid)
        return _project_public(proj)


def activate_project(project_id: int) -> dict:
    with _lock:
        proj = _state["projects"].get(str(project_id))
        if not proj:
            raise KeyError("project not found")
        _state["active_project_id"] = project_id
        _save()
        return _project_public(proj)


def delete_project(project_id: int) -> dict:
    """Remove project index + its previews. Never touches original photos."""
    with _lock:
        proj = _state["projects"].pop(str(project_id), None)
        if not proj:
            raise KeyError("project not found")
        if _state.get("active_project_id") == project_id:
            remaining = sorted(_state["projects"].values(), key=lambda p: p["id"])
            _state["active_project_id"] = remaining[0]["id"] if remaining else None
        photos = list((proj.get("photos") or {}).values())
        _save()

    # Delete every recorded preview, plus deterministic paths (project-scoped
    # and legacy flat files under data/previews/).
    for rec in photos:
        paths = []
        pp = rec.get("preview_path")
        if pp:
            paths.append(Path(pp))
        stem = Path(rec.get("original_path") or rec.get("filename") or "x").stem
        h = (rec.get("file_hash") or "unknown")[:12]
        pid = rec.get("id")
        if pid is not None:
            name = f"{pid}_{h}_{stem}_preview.jpg"
            paths.append(config.PREVIEW_DIR / name)
            paths.append(config.PREVIEW_DIR / str(project_id) / name)
        for path in paths:
            try:
                if path.is_file():
                    path.unlink()
            except OSError:
                pass

    preview_dir = config.PREVIEW_DIR / str(project_id)
    if preview_dir.exists():
        shutil.rmtree(preview_dir, ignore_errors=True)

    return {
        "deleted_id": project_id,
        "active_project_id": _state.get("active_project_id"),
        "name": proj.get("name"),
        "root_path": proj.get("root_path"),
    }


def _new_record(original_path: str, filename: str, file_hash: str, file_size: int, created_time: str) -> dict:
    return {
        "id": None,
        "original_path": original_path,
        "preview_path": None,
        "filename": filename,
        "file_hash": file_hash,
        "file_size": file_size,
        "created_time": created_time,
        "imported_at": now_iso(),
        "status": "keep",
        "starred": False,
        "analysis_state": "not_analyzed",
        "analysis_error": None,
        "analyzed_at": None,
        "technical_score": None,
        "subject_score": None,
        "composition_score": None,
        "emotion_score": None,
        "overall_score": None,
        "recommendation": None,
        "ai_result": None,
        "capture_info": None,
        "file_missing": False,
        "missing_reason": None,
        "quality_tags": [],
        "quality_metrics": None,
        "quality_state": "not_checked",
        "soft_focus_score": None,
        "camera_shake_score": None,
        "is_raw": Path(original_path).suffix.lower() in config.RAW_EXTENSIONS,
        "sidecar_paths": [],
        "rotation": 0,
        "flip_h": False,
        "straighten_angle": 0.0,
        "crop": None,
    }


def insert_photo(original_path: str, filename: str, file_hash: str, file_size: int, created_time: str) -> int:
    with _lock:
        proj = _require_active()
        photos = proj["photos"]
        for rec in photos.values():
            if rec["original_path"] == original_path:
                return rec["id"]
        photo_id = proj["next_id"]
        proj["next_id"] = photo_id + 1
        rec = _new_record(original_path, filename, file_hash, file_size, created_time)
        rec["id"] = photo_id
        photos[str(photo_id)] = rec
        _save()
        return photo_id


def set_preview_path(photo_id: int, preview_path: str | None, *, flush: bool = False):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["preview_path"] = preview_path
            if flush:
                _save()


def set_capture_info(photo_id: int, capture_info: dict, *, flush: bool = True):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["capture_info"] = capture_info
            if flush:
                _save()


def flush_store():
    with _lock:
        _save()


def get_photo_by_hash(file_hash: str):
    with _lock:
        for rec in _photos().values():
            if rec["file_hash"] == file_hash:
                return dict(rec)
    return None


def get_photo_by_path(path: str):
    with _lock:
        path_n = str(Path(path))
        for rec in _photos().values():
            if rec.get("original_path") == path_n:
                return dict(rec)
            for sc in rec.get("sidecar_paths") or []:
                if sc == path_n:
                    return dict(rec)
    return None


def get_photo_by_shot_key(parent: str, stem: str):
    """Find a record whose primary or sidecar shares folder + basename."""
    stem_l = stem.lower()
    try:
        parent_n = str(Path(parent).resolve())
    except OSError:
        parent_n = str(Path(parent))
    with _lock:
        for rec in _photos().values():
            paths = [rec.get("original_path")] + list(rec.get("sidecar_paths") or [])
            for p in paths:
                if not p:
                    continue
                pp = Path(p)
                try:
                    p_parent = str(pp.parent.resolve())
                except OSError:
                    p_parent = str(pp.parent)
                if p_parent == parent_n and pp.stem.lower() == stem_l:
                    return dict(rec)
    return None


def photo_disk_paths(rec: dict) -> list[str]:
    paths = []
    if rec.get("original_path"):
        paths.append(rec["original_path"])
    for sc in rec.get("sidecar_paths") or []:
        if sc and sc not in paths:
            paths.append(sc)
    return paths


def set_shot_files(
    photo_id: int,
    *,
    original_path: str,
    filename: str,
    file_hash: str,
    file_size: int,
    created_time: str,
    sidecar_paths: list[str],
    flush: bool = True,
):
    """Update primary + sidecar paths after RAW/JPG pairing."""
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["original_path"] = original_path
        rec["filename"] = filename
        rec["file_hash"] = file_hash
        rec["file_size"] = file_size
        rec["created_time"] = created_time
        rec["is_raw"] = Path(original_path).suffix.lower() in config.RAW_EXTENSIONS
        rec["sidecar_paths"] = list(sidecar_paths or [])
        rec["file_missing"] = False
        rec["missing_reason"] = None
        if flush:
            _save()


def absorb_duplicate_photo(keep_id: int, drop_id: int):
    """Remove a duplicate JPG/RAW record after pairing into keep_id."""
    if keep_id == drop_id:
        return
    with _lock:
        photos = _photos()
        drop = photos.pop(str(drop_id), None)
        if not drop:
            return
        preview = drop.get("preview_path")
        if preview and Path(preview).exists():
            try:
                Path(preview).unlink()
            except OSError:
                pass
        _save()


def merge_raw_jpeg_twins() -> int:
    """Collapse same-folder + same-basename RAW/JPG twins into one grid item.

    Prefers the RAW record as primary; JPEG becomes a sidecar. Also attaches an
    on-disk sibling JPG next to a RAW even if it was never a separate DB row.
    Returns how many duplicate records were removed.
    """
    with _lock:
        photos = _photos()
        buckets: dict[tuple[str, str], list] = {}
        for rec in list(photos.values()):
            path = rec.get("original_path") or ""
            if not path:
                continue
            pp = Path(path)
            try:
                parent = str(pp.parent.resolve())
            except OSError:
                parent = str(pp.parent)
            key = (parent, pp.stem.lower())
            buckets.setdefault(key, []).append(rec)

        removed = 0
        changed = False
        for _key, recs in buckets.items():
            raws = [
                r for r in recs
                if r.get("is_raw")
                or Path(r.get("original_path") or "").suffix.lower() in config.RAW_EXTENSIONS
            ]
            others = [r for r in recs if r not in raws]

            if len(recs) >= 2:
                keep = raws[0] if raws else recs[0]
                drop_list = [r for r in recs if r is not keep]
            elif raws:
                keep = raws[0]
                drop_list = []
            else:
                continue

            sidecar = list(keep.get("sidecar_paths") or [])
            for d in drop_list:
                if d.get("original_path"):
                    sidecar.append(d["original_path"])
                for sc in d.get("sidecar_paths") or []:
                    sidecar.append(sc)
                preview = d.get("preview_path")
                if preview and Path(preview).exists():
                    try:
                        Path(preview).unlink()
                    except OSError:
                        pass
                photos.pop(str(d["id"]), None)
                removed += 1
                changed = True

            # Attach on-disk JPEG sibling next to a RAW primary.
            primary = Path(keep["original_path"])
            if primary.suffix.lower() in config.RAW_EXTENSIONS:
                for ext in (".JPG", ".JPEG", ".jpg", ".jpeg", ".PNG", ".png"):
                    cand = primary.with_name(primary.stem + ext)
                    if cand.exists():
                        sidecar.append(str(cand))

            # Dedupe (APFS is case-insensitive — compare stem+suffix lower).
            def _path_key(p: str) -> tuple:
                pp = Path(p)
                try:
                    parent = str(pp.parent.resolve())
                except OSError:
                    parent = str(pp.parent)
                return (parent.lower(), pp.stem.lower(), pp.suffix.lower())

            primary_key = _path_key(str(primary))
            seen = {primary_key}
            cleaned = []
            for p in sidecar:
                if not p:
                    continue
                key = _path_key(p)
                if key in seen:
                    continue
                seen.add(key)
                cleaned.append(p)
            if cleaned != list(keep.get("sidecar_paths") or []):
                keep["sidecar_paths"] = cleaned
                changed = True
            keep["is_raw"] = primary.suffix.lower() in config.RAW_EXTENSIONS

        if changed:
            _save()
        return removed


def update_photo_path(photo_id: int, original_path: str, filename: str, created_time: str):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["original_path"] = original_path
            rec["filename"] = filename
            rec["created_time"] = created_time
            rec["is_raw"] = Path(original_path).suffix.lower() in config.RAW_EXTENSIONS
            rec["file_missing"] = False
            rec["missing_reason"] = None
            _save()


def rename_photo_files(photo_id: int, original_path: str, filename: str, sidecar_paths: list[str]):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["original_path"] = original_path
            rec["filename"] = filename
            rec["sidecar_paths"] = list(sidecar_paths or [])
            rec["is_raw"] = Path(original_path).suffix.lower() in config.RAW_EXTENSIONS
            rec["file_missing"] = False
            rec["missing_reason"] = None
            _save()


def mark_missing_under_root(root: str, keep_ids: set) -> list:
    keep_ids = set(keep_ids)
    prefix = root.rstrip("/") + "/"
    newly_missing = []
    with _lock:
        for rec in _photos().values():
            if rec["file_missing"] or rec["id"] in keep_ids:
                continue
            if not rec["original_path"].startswith(prefix):
                continue
            exists = Path(rec["original_path"]).exists()
            rec["file_missing"] = True
            rec["missing_reason"] = "gone" if not exists else "not_in_folder"
            newly_missing.append(dict(rec))
        if newly_missing:
            _save()
    return newly_missing


def sync_missing_flags() -> dict:
    marked = 0
    restored = 0
    with _lock:
        photos = _photos()
        for rec in photos.values():
            exists = Path(rec["original_path"]).exists()
            if not exists:
                if not rec.get("file_missing") or rec.get("missing_reason") != "gone":
                    rec["file_missing"] = True
                    rec["missing_reason"] = "gone"
                    marked += 1
            elif rec.get("file_missing"):
                rec["file_missing"] = False
                rec["missing_reason"] = None
                restored += 1
        if marked or restored:
            _save()
        missing_count = sum(1 for r in photos.values() if r.get("file_missing"))
    return {"marked": marked, "restored": restored, "missing_count": missing_count}


def list_missing() -> list:
    with _lock:
        records = [dict(r) for r in _photos().values() if r.get("file_missing")]
    records.sort(key=lambda r: r["filename"])
    return records


def purge_missing() -> list:
    removed = []
    with _lock:
        photos = _photos()
        to_delete = [rec for rec in list(photos.values()) if rec.get("file_missing")]
        for rec in to_delete:
            preview = rec.get("preview_path")
            if preview and Path(preview).exists():
                try:
                    Path(preview).unlink()
                except OSError:
                    pass
            photos.pop(str(rec["id"]), None)
            removed.append({
                "id": rec["id"],
                "filename": rec["filename"],
                "missing_reason": rec.get("missing_reason"),
                "original_path": rec["original_path"],
            })
        if removed:
            _save()
    return removed


def reset_all():
    """Clear photos inside the active project (kept for compatibility)."""
    with _lock:
        proj = _active_project()
        if not proj:
            return
        proj["photos"] = {}
        proj["next_id"] = 1
        _save()
    pid = _state.get("active_project_id")
    if pid is not None:
        pdir = preview_dir_for(pid)
        for f in pdir.glob("*"):
            try:
                f.unlink()
            except OSError:
                pass


def set_analyzing(photo_id: int):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["analysis_state"] = "analyzing"
            rec["analysis_error"] = None
            _save()


def set_analysis_result(photo_id: int, scores: dict, ai_result: dict):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["analysis_state"] = "analyzed"
            rec["analysis_error"] = None
            rec["analyzed_at"] = now_iso()
            rec["technical_score"] = scores["technical_score"]
            rec["subject_score"] = scores["subject_score"]
            rec["composition_score"] = scores["composition_score"]
            rec["emotion_score"] = scores["emotion_score"]
            rec["overall_score"] = scores["overall_score"]
            rec["recommendation"] = scores["recommendation"]
            rec["ai_result"] = ai_result
            # AI path already computed local focus metrics — keep scores in sync.
            tech = (ai_result or {}).get("technical") or {}
            if tech.get("focus") is not None or "tags" in tech:
                _apply_quality_result(rec, tech)
            _save()


def _apply_quality_result(rec: dict, result: dict):
    soft = result.get("soft_focus_score")
    shake = result.get("camera_shake_score")
    if soft is None or shake is None:
        soft, shake = _defect_scores_from_metrics(
            result.get("focus"), result.get("anisotropy")
        )
    rec["soft_focus_score"] = soft
    rec["camera_shake_score"] = shake
    rec["quality_tags"] = list(result.get("tags") or _tags_from_defect_scores(soft, shake))
    rec["quality_metrics"] = {
        "focus": result.get("focus"),
        "exposure": result.get("exposure"),
        "exposure_verdict": result.get("exposure_verdict"),
        "mean_brightness": result.get("mean_brightness"),
        "shadow_clip_pct": result.get("shadow_clip_pct"),
        "highlight_clip_pct": result.get("highlight_clip_pct"),
        "anisotropy": result.get("anisotropy"),
        "laplacian_var": result.get("laplacian_var"),
        "soft_focus_score": soft,
        "camera_shake_score": shake,
    }
    rec["quality_state"] = "checked"
    if result.get("technical_score") is not None and rec.get("technical_score") is None:
        rec["technical_score"] = result["technical_score"]


def set_quality_result(photo_id: int, result: dict, *, flush: bool = True):
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        _apply_quality_result(rec, result)
        if flush:
            _save()


def set_quality_error(photo_id: int, error: str, *, flush: bool = True):
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["quality_state"] = "failed"
        rec["quality_tags"] = []
        rec["soft_focus_score"] = None
        rec["camera_shake_score"] = None
        if flush:
            _save()


def set_analysis_error(photo_id: int, error: str):
    with _lock:
        rec = _photos().get(str(photo_id))
        if rec:
            rec["analysis_state"] = "failed"
            rec["analysis_error"] = error
            _save()


class StatusError(ValueError):
    """Raised when a status change is not allowed (e.g. reject while starred)."""


def set_status(photo_id: int, status: str):
    if status not in ("keep", "reject"):
        raise StatusError("status must be keep or reject")
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        if status == "reject" and rec.get("starred"):
            raise StatusError("已加星的照片不能淘汰，請先取消星號")
        rec["status"] = status
        _save()


def bulk_set_status(photo_ids: list, status: str) -> dict:
    if status not in ("keep", "reject"):
        raise StatusError("status must be keep or reject")
    updated, skipped = [], []
    with _lock:
        photos = _photos()
        for pid in photo_ids:
            rec = photos.get(str(pid))
            if not rec:
                continue
            if status == "reject" and rec.get("starred"):
                skipped.append({"id": pid, "filename": rec.get("filename"), "reason": "starred"})
                continue
            rec["status"] = status
            updated.append(pid)
        _save()
    return {"updated": updated, "skipped": skipped}


def set_starred(photo_id: int, starred: bool):
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["starred"] = bool(starred)
        # Starring a rejected photo restores it to keep.
        if rec["starred"] and rec.get("status") == "reject":
            rec["status"] = "keep"
        _save()


def bulk_set_starred(photo_ids: list, starred: bool) -> int:
    count = 0
    with _lock:
        photos = _photos()
        for pid in photo_ids:
            rec = photos.get(str(pid))
            if not rec:
                continue
            rec["starred"] = bool(starred)
            if rec["starred"] and rec.get("status") == "reject":
                rec["status"] = "keep"
            count += 1
        _save()
    return count


class TransformError(Exception):
    pass


def _invalidate_preview(rec: dict) -> None:
    # Baked-in preview no longer matches — drop it so the next view regenerates
    # one with the new orientation/crop (mirrors how sidecar-JPEG previews refresh).
    old_preview = rec.get("preview_path")
    if old_preview:
        try:
            Path(old_preview).unlink(missing_ok=True)
        except OSError:
            pass
    rec["preview_path"] = None


def _apply_transform_action(rec: dict, action: str) -> None:
    if action == "rotate_cw":
        rec["rotation"] = (rec.get("rotation", 0) + 90) % 360
    elif action == "rotate_ccw":
        rec["rotation"] = (rec.get("rotation", 0) - 90) % 360
    elif action == "flip_h":
        rec["flip_h"] = not rec.get("flip_h", False)
    else:
        raise TransformError(f"unknown transform action: {action}")
    _invalidate_preview(rec)


def set_transform(photo_id: int, action: str):
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        _apply_transform_action(rec, action)
        _save()


def bulk_set_transform(photo_ids: list, action: str) -> int:
    count = 0
    with _lock:
        photos = _photos()
        for pid in photo_ids:
            rec = photos.get(str(pid))
            if not rec:
                continue
            _apply_transform_action(rec, action)
            count += 1
        _save()
    return count


def set_straighten(photo_id: int, angle: float):
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["straighten_angle"] = float(angle)
        _invalidate_preview(rec)
        _save()


def set_crop(photo_id: int, crop: dict | None):
    if not _valid_crop(crop):
        raise TransformError("invalid crop rectangle")
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["crop"] = dict(crop) if crop else None
        _invalidate_preview(rec)
        _save()


def commit_overwrite_original(photo_id: int, file_hash: str, file_size: int) -> None:
    """Record that the original file was just overwritten with the baked-in
    edit result. The rotation/flip/straighten/crop are now part of the pixels
    themselves, so the transform fields reset to identity; anything derived
    from the old pixel content (quality scan, AI analysis, EXIF-derived
    capture info) is stale and cleared so it gets recomputed on next view.
    """
    with _lock:
        rec = _photos().get(str(photo_id))
        if not rec:
            return
        rec["file_hash"] = file_hash
        rec["file_size"] = file_size
        rec["rotation"] = 0
        rec["flip_h"] = False
        rec["straighten_angle"] = 0.0
        rec["crop"] = None
        rec["capture_info"] = None
        rec["quality_tags"] = []
        rec["quality_metrics"] = None
        rec["quality_state"] = "not_checked"
        rec["soft_focus_score"] = None
        rec["camera_shake_score"] = None
        rec["analysis_state"] = "not_analyzed"
        rec["analysis_error"] = None
        rec["analyzed_at"] = None
        rec["technical_score"] = None
        rec["subject_score"] = None
        rec["composition_score"] = None
        rec["emotion_score"] = None
        rec["overall_score"] = None
        rec["recommendation"] = None
        rec["ai_result"] = None
        _invalidate_preview(rec)
        _save()


def get_photo(photo_id: int):
    with _lock:
        rec = _photos().get(str(photo_id))
        return dict(rec) if rec else None


def list_photos(status: str = None, starred: bool = None,
                 tag: str = None,
                 min_soft_focus: float = None,
                 min_camera_shake: float = None,
                 min_blur: float = None,
                 exposure_verdict: list = None,
                 recommendation: str = None,
                 min_score: float = None, max_score: float = None):
    with _lock:
        records = list(_photos().values())
    result = []
    for rec in records:
        if status and rec["status"] != status:
            continue
        if starred is not None and bool(rec.get("starred")) != starred:
            continue
        soft = rec.get("soft_focus_score")
        shake = rec.get("camera_shake_score")
        if min_soft_focus is not None:
            if soft is None or soft < min_soft_focus:
                continue
        if min_camera_shake is not None:
            if shake is None or shake < min_camera_shake:
                continue
        if min_blur is not None:
            if soft is None and shake is None:
                continue
            if (soft or 0) + (shake or 0) < min_blur:
                continue
        if exposure_verdict:
            verdict = (rec.get("quality_metrics") or {}).get("exposure_verdict")
            if verdict not in exposure_verdict:
                continue
        if tag == "soft_focus":
            threshold = min_soft_focus if min_soft_focus is not None else 40.0
            if soft is None or soft < threshold:
                continue
        elif tag == "camera_shake":
            threshold = min_camera_shake if min_camera_shake is not None else 40.0
            if shake is None or shake < threshold:
                continue
        elif tag and tag not in (rec.get("quality_tags") or []):
            continue
        if recommendation and rec["recommendation"] != recommendation:
            continue
        if min_score is not None and (rec["overall_score"] is None or rec["overall_score"] < min_score):
            continue
        if max_score is not None and (rec["overall_score"] is None or rec["overall_score"] > max_score):
            continue
        result.append(dict(rec))
    # When filtering by defect severity, show worst first.
    if min_soft_focus is not None or tag == "soft_focus":
        result.sort(key=lambda r: (-(r.get("soft_focus_score") or 0), r["filename"]))
    elif min_camera_shake is not None or tag == "camera_shake":
        result.sort(key=lambda r: (-(r.get("camera_shake_score") or 0), r["filename"]))
    elif min_blur is not None:
        result.sort(key=lambda r: (-((r.get("soft_focus_score") or 0) + (r.get("camera_shake_score") or 0)), r["filename"]))
    else:
        result.sort(key=lambda r: r["filename"])
    return result


def list_all_not_analyzed():
    with _lock:
        records = list(_photos().values())
    return [dict(r) for r in records if r["analysis_state"] in ("not_analyzed", "failed")]


def list_quality_unchecked(*, include_failed: bool = True):
    with _lock:
        records = list(_photos().values())
    allowed = {"not_checked", "failed"} if include_failed else {"not_checked"}
    return [
        dict(r) for r in records
        if r.get("quality_state", "not_checked") in allowed
        and not r.get("file_missing")
    ]


def get_stats():
    with _lock:
        records = list(_photos().values())
        active = _active_project()
    by_status = {}
    starred_count = 0
    keep_count = 0
    missing_count = 0
    missing_by_reason = {}
    tag_counts = {"soft_focus": 0, "camera_shake": 0}
    # Counts at common severity floors for UI badges / sliders.
    soft_buckets = {"ge_20": 0, "ge_40": 0, "ge_60": 0, "ge_80": 0}
    shake_buckets = {"ge_20": 0, "ge_40": 0, "ge_60": 0, "ge_80": 0}
    # soft_focus_score + camera_shake_score is a strict split of one blur
    # quantity (see image_processing.defect_scores), so their sum recovers
    # the photo's overall blur severity — same 0–100 floors apply.
    blur_buckets = {"ge_20": 0, "ge_40": 0, "ge_60": 0, "ge_80": 0}
    exposure_counts = {"overexposed": 0, "underexposed": 0}
    quality_checked = 0
    for rec in records:
        st = rec["status"] if rec.get("status") in ("keep", "reject") else "keep"
        entry = by_status.setdefault(st, {"count": 0, "size_bytes": 0})
        entry["count"] += 1
        entry["size_bytes"] += rec["file_size"] or 0
        if rec.get("starred"):
            starred_count += 1
        if st == "keep":
            keep_count += 1
        if rec.get("file_missing"):
            missing_count += 1
            reason = rec.get("missing_reason") or "gone"
            missing_by_reason[reason] = missing_by_reason.get(reason, 0) + 1
        if rec.get("quality_state") == "checked":
            quality_checked += 1
        soft = rec.get("soft_focus_score")
        shake = rec.get("camera_shake_score")
        if soft is not None:
            if soft >= 20:
                soft_buckets["ge_20"] += 1
            if soft >= 40:
                soft_buckets["ge_40"] += 1
                tag_counts["soft_focus"] += 1
            if soft >= 60:
                soft_buckets["ge_60"] += 1
            if soft >= 80:
                soft_buckets["ge_80"] += 1
        if shake is not None:
            if shake >= 20:
                shake_buckets["ge_20"] += 1
            if shake >= 40:
                shake_buckets["ge_40"] += 1
                tag_counts["camera_shake"] += 1
            if shake >= 60:
                shake_buckets["ge_60"] += 1
            if shake >= 80:
                shake_buckets["ge_80"] += 1
        if soft is not None or shake is not None:
            blur = (soft or 0) + (shake or 0)
            if blur >= 20:
                blur_buckets["ge_20"] += 1
            if blur >= 40:
                blur_buckets["ge_40"] += 1
            if blur >= 60:
                blur_buckets["ge_60"] += 1
            if blur >= 80:
                blur_buckets["ge_80"] += 1
        verdict = (rec.get("quality_metrics") or {}).get("exposure_verdict")
        if verdict in exposure_counts:
            exposure_counts[verdict] += 1
    total_count = sum(v["count"] for v in by_status.values())
    total_size = sum(v["size_bytes"] for v in by_status.values())
    return {
        "total_count": total_count,
        "total_size_bytes": total_size,
        "by_status": by_status,
        "keep_count": keep_count,
        "starred_count": starred_count,
        "missing_count": missing_count,
        "missing_by_reason": missing_by_reason,
        "tag_counts": tag_counts,
        "soft_focus_buckets": soft_buckets,
        "camera_shake_buckets": shake_buckets,
        "blur_buckets": blur_buckets,
        "exposure_counts": exposure_counts,
        "quality_checked": quality_checked,
        "active_project": _project_public(active) if active else None,
    }


def delete_photo_row(photo_id: int):
    with _lock:
        _photos().pop(str(photo_id), None)
        _save()


def cleanup_orphan_previews(project_id: int | None = None) -> int:
    """Delete preview JPEGs under the project dir that no photo record references.

    Called after import / project activate so a removed RAW doesn't leave a
    stale thumb, and so we don't accumulate .tmp.jpg crash leftovers.
    """
    with _lock:
        pid = project_id if project_id is not None else _state.get("active_project_id")
        if pid is None:
            return 0
        proj = _state["projects"].get(str(pid))
        if not proj:
            return 0
        referenced = set()
        for rec in (proj.get("photos") or {}).values():
            pp = rec.get("preview_path")
            if pp:
                try:
                    referenced.add(str(Path(pp).resolve()))
                except OSError:
                    referenced.add(str(Path(pp)))
        pdir = preview_dir_for(pid)
        if not pdir.exists():
            return 0
        removed = 0
        for f in pdir.iterdir():
            if not f.is_file():
                continue
            name = f.name.lower()
            if not (name.endswith(".jpg") or name.endswith(".jpeg") or name.endswith(".tmp.jpg")):
                continue
            try:
                key = str(f.resolve())
            except OSError:
                key = str(f)
            if key not in referenced:
                try:
                    f.unlink()
                    removed += 1
                except OSError:
                    pass
        return removed
