import hashlib
import math
import os
import tempfile
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path

import cv2
import numpy as np
from PIL import Image, ImageOps

from . import config

try:
    from pillow_heif import register_heif_opener

    register_heif_opener()
except ImportError:
    # HEIC import will fail at decode time with a clear error if deps missing.
    pass

# Calibration constants for the local CV technical-quality heuristics.
# These are rough starting points (open question #1 in the spec: sub-score
# weighting needs real calibration against user judgments before it can be
# trusted). Treat focus/exposure scores as a first cut, not ground truth.
FOCUS_LAPLACIAN_TARGET = 300.0  # laplacian variance mapped to a 100 score
EXPOSURE_CLIP_PENALTY = 300.0  # scales clipped-pixel fraction into a 0-100 penalty

# Exposure verdict thresholds (0–255 mean brightness scale + clipped-pixel %).
# Same caveat as above: a rough first cut, not calibrated against real judgments —
# a legitimately bright snow scene or dark night shot can still read as "wrong"
# to a scalar brightness check. Clipping fraction catches blown highlights /
# crushed blacks even when the mean looks fine.
EXPOSURE_HIGHLIGHT_CLIP_PCT = 2.0
EXPOSURE_SHADOW_CLIP_PCT = 2.0
EXPOSURE_OVEREXPOSED_MEAN = 195.0
EXPOSURE_UNDEREXPOSED_MEAN = 55.0

# Tag thresholds — tuned for ~1280px previews. Soft focus = isotropic blur;
# camera shake = low sharpness + directional residual structure.
SOFT_FOCUS_MAX = 32.0
SHAKE_FOCUS_MAX = 42.0
SHAKE_ANISOTROPY_MIN = 1.55
# Show / count as tagged when severity score is at least this (0–100, higher = worse).
TAG_SCORE_MIN = 40.0
# Map anisotropy → directional factor: ~1.1 → 0 (isotropic), ~2.2 → 1 (directional).
ANISO_DIR_START = 1.1
ANISO_DIR_SPAN = 1.1


def scan_folder(folder: Path) -> list[Path]:
    return sorted(
        p for p in folder.rglob("*")
        if p.is_file()
        and not p.name.startswith("._")
        and not p.name.startswith(".")
        and p.suffix.lower() in config.SUPPORTED_EXTENSIONS
    )


def is_raw_path(path: Path | str) -> bool:
    return Path(path).suffix.lower() in config.RAW_EXTENSIONS


def is_heic_path(path: Path | str) -> bool:
    return Path(path).suffix.lower() in config.HEIC_EXTENSIONS


def is_jpeg_path(path: Path | str) -> bool:
    """True for JPEG/PNG/HEIC stills (non-RAW companions in RAW+JPG grouping)."""
    return Path(path).suffix.lower() in config.RASTER_EXTENSIONS


def group_shot_files(paths: list[Path]) -> list[dict]:
    """Group camera RAW+JPG twins by folder + basename (case-insensitive stem).

    Each group picks RAW as primary when present; otherwise the JPEG/PNG.
    Companion files become sidecars so reject/delete/rename can move them together.
    """
    buckets: dict[tuple[str, str], dict] = {}
    for p in paths:
        try:
            parent = str(p.parent.resolve())
        except OSError:
            parent = str(p.parent)
        key = (parent, p.stem.lower())
        entry = buckets.setdefault(key, {"raw": [], "jpeg": []})
        if is_raw_path(p):
            entry["raw"].append(p)
        else:
            entry["jpeg"].append(p)

    groups = []
    for (parent, stem), parts in sorted(buckets.items(), key=lambda kv: kv[0]):
        raws = sorted(parts["raw"], key=lambda x: x.name.lower())
        jpegs = sorted(parts["jpeg"], key=lambda x: x.name.lower())
        if raws:
            primary = raws[0]
            sidecars = [str(x) for x in raws[1:] + jpegs]
        elif jpegs:
            primary = jpegs[0]
            sidecars = [str(x) for x in jpegs[1:]]
        else:
            continue
        groups.append({
            "primary": primary,
            "sidecars": sidecars,
            "stem": stem,
            "parent": parent,
        })
    return groups


def pair_label(is_raw: bool, sidecar_paths: list | None) -> str | None:
    """UI badge: RAW+J / RAW / None (JPEG-only)."""
    sidecars = sidecar_paths or []
    has_jpeg = any(is_jpeg_path(p) for p in sidecars)
    if is_raw and has_jpeg:
        return "RAW+J"
    if is_raw:
        return "RAW"
    return None


def file_hash(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def file_created_time(path: Path) -> str:
    ts = path.stat().st_mtime
    return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()


def _as_float(value) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _format_shutter(seconds: float) -> str:
    if seconds >= 1:
        if abs(seconds - round(seconds)) < 1e-6:
            return f"{int(round(seconds))}s"
        return f"{seconds:.1f}s"
    denom = max(1, int(round(1.0 / seconds)))
    return f"1/{denom}"


def _format_taken_at(date_str: str | None, offset: str | None) -> str | None:
    """EXIF DateTimeOriginal is 'YYYY:MM:DD HH:MM:SS' — show local wall time."""
    if not date_str:
        return None
    try:
        dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
        text = dt.strftime("%Y-%m-%d %H:%M:%S")
        if offset:
            text = f"{text} {offset.strip()}"
        return text
    except ValueError:
        return date_str.replace(":", "-", 2)


def _empty_capture_info() -> dict:
    return {
        "width": None,
        "height": None,
        "taken_at": None,
        "make": None,
        "model": None,
        "lens": None,
        "focal_length": None,
        "focal_length_35mm": None,
        "aperture": None,
        "shutter": None,
        "iso": None,
        "camera": None,
        "exposure_line": None,
        "gps_lat": None,
        "gps_lon": None,
    }


def _gps_to_decimal(value, ref) -> float | None:
    """Convert an EXIF GPS (degrees, minutes, seconds) rational triple to decimal degrees."""
    try:
        deg, minutes, seconds = (float(v) for v in value)
    except (TypeError, ValueError):
        return None
    decimal = deg + minutes / 60 + seconds / 3600
    if isinstance(ref, bytes):
        ref = ref.decode("utf-8", errors="ignore")
    if isinstance(ref, str) and ref.upper() in ("S", "W"):
        decimal = -decimal
    return decimal


def extract_capture_info(path: Path) -> dict:
    """Read photographer-facing EXIF without decoding the full pixel raster."""
    if is_raw_path(path):
        return _extract_capture_info_raw(path)
    return _extract_capture_info_raster(path)


def _extract_capture_info_raster(path: Path) -> dict:
    from PIL.ExifTags import Base, IFD

    info = _empty_capture_info()
    with Image.open(path) as img:
        info["width"], info["height"] = img.size
        exif = img.getexif()
        if not exif:
            return info

        make = exif.get(Base.Make)
        model = exif.get(Base.Model)
        if isinstance(make, str):
            make = make.strip() or None
        if isinstance(model, str):
            model = model.strip() or None
        info["make"] = make
        info["model"] = model
        if make or model:
            # Avoid "SONY ILCE-7CM2" doubling if model already contains make
            if make and model and model.upper().startswith(make.upper()):
                info["camera"] = model
            else:
                info["camera"] = " ".join(p for p in (make, model) if p)

        try:
            ifd = exif.get_ifd(IFD.Exif)
        except Exception:  # noqa: BLE001
            ifd = {}

        taken = ifd.get(Base.DateTimeOriginal) or exif.get(Base.DateTime)
        offset = ifd.get(0x9011)  # OffsetTimeOriginal
        if isinstance(offset, bytes):
            offset = offset.decode("utf-8", errors="ignore")
        info["taken_at"] = _format_taken_at(
            taken if isinstance(taken, str) else None,
            offset if isinstance(offset, str) else None,
        )

        lens = ifd.get(Base.LensModel)
        if isinstance(lens, bytes):
            lens = lens.decode("utf-8", errors="ignore")
        if isinstance(lens, str):
            info["lens"] = lens.strip() or None

        focal = _as_float(ifd.get(Base.FocalLength))
        if focal:
            info["focal_length"] = f"{focal:g}mm"
        focal35 = ifd.get(Base.FocalLengthIn35mmFilm)
        if isinstance(focal35, int) and focal35 > 0:
            info["focal_length_35mm"] = f"{focal35}mm"

        fnumber = _as_float(ifd.get(Base.FNumber))
        if fnumber:
            info["aperture"] = f"f/{fnumber:g}"

        exposure = _as_float(ifd.get(Base.ExposureTime))
        if exposure and exposure > 0:
            info["shutter"] = _format_shutter(exposure)

        iso = ifd.get(Base.ISOSpeedRatings) or ifd.get(Base.PhotographicSensitivity)
        if isinstance(iso, (list, tuple)) and iso:
            iso = iso[0]
        if isinstance(iso, int):
            info["iso"] = iso

        parts = [
            info["focal_length"],
            info["aperture"],
            info["shutter"],
            f"ISO {info['iso']}" if info["iso"] is not None else None,
        ]
        info["exposure_line"] = " · ".join(p for p in parts if p) or None

        try:
            gps = exif.get_ifd(IFD.GPSInfo)
        except Exception:  # noqa: BLE001
            gps = {}
        if gps:
            lat = _gps_to_decimal(gps.get(2), gps.get(1))  # GPSLatitude, GPSLatitudeRef
            lon = _gps_to_decimal(gps.get(4), gps.get(3))  # GPSLongitude, GPSLongitudeRef
            info["gps_lat"] = lat
            info["gps_lon"] = lon
    return info


def _extract_capture_info_raw(path: Path) -> dict:
    """Prefer EXIF from the embedded JPEG; fall back to LibRaw sizes."""
    import rawpy

    info = _empty_capture_info()
    try:
        with rawpy.imread(str(path)) as raw:
            sizes = raw.sizes
            info["width"] = int(
                getattr(sizes, "width", 0) or getattr(sizes, "raw_width", 0) or 0
            ) or None
            info["height"] = int(
                getattr(sizes, "height", 0) or getattr(sizes, "raw_height", 0) or 0
            ) or None
            try:
                thumb = raw.extract_thumb()
            except Exception:  # noqa: BLE001
                thumb = None
            if thumb is not None and thumb.format == rawpy.ThumbFormat.JPEG:
                with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
                    tmp.write(thumb.data)
                    tmp_path = Path(tmp.name)
                try:
                    nested = _extract_capture_info_raster(tmp_path)
                    for key, value in nested.items():
                        if value is not None:
                            info[key] = value
                finally:
                    tmp_path.unlink(missing_ok=True)
    except Exception:  # noqa: BLE001
        pass
    return info


def _apply_user_transform(
    img: Image.Image,
    rotation: int,
    flip_h: bool,
    straighten_angle: float = 0.0,
    crop: dict | None = None,
) -> Image.Image:
    """Apply the user's non-destructive rotate/flip/straighten/crop, in that order.

    Straighten uses expand=True so no source pixels are lost — the new corner
    triangles get filled (black, since this runs before the RGB convert that
    would otherwise need an alpha channel to leave them transparent). The UI
    always sets a crop alongside a non-zero straighten angle specifically to
    trim those corners; calling this with an angle but no crop will leave
    them baked into the output.
    """
    if rotation:
        img = img.rotate(-rotation, expand=True)
    if flip_h:
        img = ImageOps.mirror(img)
    if straighten_angle:
        img = img.rotate(-straighten_angle, expand=True, resample=Image.BICUBIC)
    if crop:
        w, h = img.size
        left = round(crop["x"] * w)
        top = round(crop["y"] * h)
        right = round((crop["x"] + crop["w"]) * w)
        bottom = round((crop["y"] + crop["h"]) * h)
        # Guard against float rounding producing an empty/inverted box.
        right = max(right, left + 1)
        bottom = max(bottom, top + 1)
        img = img.crop((left, top, right, bottom))
    return img


def _save_preview_image(
    img: Image.Image,
    preview_path: Path,
    rotation: int = 0,
    flip_h: bool = False,
    straighten_angle: float = 0.0,
    crop: dict | None = None,
) -> None:
    img = ImageOps.exif_transpose(img)
    img = _apply_user_transform(img, rotation, flip_h, straighten_angle, crop)
    img = img.convert("RGB")
    w, h = img.size
    if w > config.PREVIEW_MAX_WIDTH:
        new_h = round(h * (config.PREVIEW_MAX_WIDTH / w))
        img = img.resize((config.PREVIEW_MAX_WIDTH, new_h), Image.BILINEAR)
    preview_path.parent.mkdir(parents=True, exist_ok=True)
    # Atomic replace so a crash mid-write can't leave a truncated preview
    # that later ensure_preview() would treat as valid.
    tmp = preview_path.with_suffix(".tmp.jpg")
    img.save(tmp, "JPEG", quality=config.PREVIEW_JPEG_QUALITY, optimize=True)
    tmp.replace(preview_path)


def _generate_preview_from_raw(
    original_path: Path,
    preview_path: Path,
    rotation: int,
    flip_h: bool,
    straighten_angle: float,
    crop: dict | None,
) -> None:
    """Build a grid JPEG from embedded thumb when possible, else half-size demosaic."""
    import rawpy

    with rawpy.imread(str(original_path)) as raw:
        thumb = None
        try:
            thumb = raw.extract_thumb()
        except Exception:  # noqa: BLE001
            thumb = None

        if thumb is not None and thumb.format == rawpy.ThumbFormat.JPEG:
            with Image.open(BytesIO(thumb.data)) as img:
                _save_preview_image(img.copy(), preview_path, rotation, flip_h, straighten_angle, crop)
            return

        if thumb is not None and thumb.format == rawpy.ThumbFormat.BITMAP:
            arr = np.asarray(thumb.data)
            if arr.ndim == 3:
                _save_preview_image(Image.fromarray(arr), preview_path, rotation, flip_h, straighten_angle, crop)
                return

        rgb = raw.postprocess(
            use_camera_wb=True,
            half_size=True,
            no_auto_bright=False,
            output_bps=8,
        )
        _save_preview_image(Image.fromarray(rgb), preview_path, rotation, flip_h, straighten_angle, crop)


def generate_preview(
    original_path: Path,
    preview_path: Path,
    sidecar_paths: list[str] | None = None,
    rotation: int = 0,
    flip_h: bool = False,
    straighten_angle: float = 0.0,
    crop: dict | None = None,
) -> None:
    """Write a downscaled JPEG for fast grid display.

    JPEG/PNG/HEIC use Pillow (HEIC via pillow-heif). For RAW+JPEG pairs, the
    paired JPEG is the camera's own full-size rendering (real picture-profile
    processing, full resolution) so it beats both the RAW's small embedded
    thumbnail and a from-scratch demosaic — use it directly when present.
    Otherwise RAW falls back to the embedded thumbnail, then a half-size
    demosaic. Previews always live under PhotoPick's data/ tree, never beside
    originals. `rotation`/`flip_h`/`straighten_angle`/`crop` are the user's
    non-destructive transform, baked into the preview pixels (the original
    file is never touched).
    """
    if is_raw_path(original_path):
        sidecar_jpeg = next(
            (p for p in (sidecar_paths or []) if Path(p).suffix.lower() in (".jpg", ".jpeg") and Path(p).exists()),
            None,
        )
        if sidecar_jpeg:
            with Image.open(sidecar_jpeg) as img:
                try:
                    img.draft("RGB", (config.PREVIEW_MAX_WIDTH, config.PREVIEW_MAX_WIDTH))
                except Exception:  # noqa: BLE001 - draft is best-effort
                    pass
                _save_preview_image(img, preview_path, rotation, flip_h, straighten_angle, crop)
            return
        _generate_preview_from_raw(original_path, preview_path, rotation, flip_h, straighten_angle, crop)
        return

    with Image.open(original_path) as img:
        try:
            img.draft("RGB", (config.PREVIEW_MAX_WIDTH, config.PREVIEW_MAX_WIDTH))
        except Exception:  # noqa: BLE001 - draft is best-effort
            pass
        _save_preview_image(img, preview_path, rotation, flip_h, straighten_angle, crop)


def encode_for_analysis(original_path: Path) -> bytes:
    """Return a JPEG byte payload suitable for Gemini (and for RAW/HEIC via preview)."""
    if is_raw_path(original_path) or is_heic_path(original_path):
        with tempfile.TemporaryDirectory() as td:
            out = Path(td) / "preview.jpg"
            generate_preview(original_path, out)
            return out.read_bytes()

    buf = BytesIO()
    with Image.open(original_path) as img:
        try:
            img.draft("RGB", (config.PREVIEW_MAX_WIDTH, config.PREVIEW_MAX_WIDTH))
        except Exception:  # noqa: BLE001
            pass
        img = ImageOps.exif_transpose(img)
        img = img.convert("RGB")
        w, h = img.size
        if w > config.PREVIEW_MAX_WIDTH:
            new_h = round(h * (config.PREVIEW_MAX_WIDTH / w))
            img = img.resize((config.PREVIEW_MAX_WIDTH, new_h), Image.BILINEAR)
        img.save(buf, "JPEG", quality=config.PREVIEW_JPEG_QUALITY)
    return buf.getvalue()


def _focus_metrics(gray: np.ndarray) -> tuple[float, float]:
    lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
    focus = float(np.clip((lap_var / FOCUS_LAPLACIAN_TARGET) * 100, 0, 100))
    return focus, lap_var


def _edge_anisotropy(gray: np.ndarray) -> float:
    """How uneven edge energy is across directions (1.0 = even).

    Motion blur tends to leave stronger residual structure along one axis,
    while defocus blur is more isotropic. Sharp photos with strong lines can
    also score high — always combine with a low focus score before tagging.
    """
    gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
    gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
    mag = np.sqrt(gx * gx + gy * gy)
    # Fold 180° — blur direction is undirected.
    angle = (np.arctan2(gy, gx) + np.pi) % np.pi
    bins = 8
    idx = np.clip((angle / np.pi * bins).astype(np.int32), 0, bins - 1)
    hist = np.bincount(idx.ravel(), weights=mag.ravel(), minlength=bins).astype(np.float64)
    mean = float(hist.mean()) + 1e-6
    return float(hist.max() / mean)


def _exposure_metrics(gray: np.ndarray) -> dict:
    hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
    total = gray.size
    shadow_clip = float(hist[0] / total)
    highlight_clip = float(hist[255] / total)
    penalty = (shadow_clip + highlight_clip) * 100 * EXPOSURE_CLIP_PENALTY / 100
    score = float(np.clip(100 - penalty, 0, 100))
    mean_brightness = float(gray.mean())
    shadow_clip_pct = round(shadow_clip * 100, 2)
    highlight_clip_pct = round(highlight_clip * 100, 2)

    if highlight_clip_pct > EXPOSURE_HIGHLIGHT_CLIP_PCT or mean_brightness > EXPOSURE_OVEREXPOSED_MEAN:
        verdict = "overexposed"
    elif shadow_clip_pct > EXPOSURE_SHADOW_CLIP_PCT or mean_brightness < EXPOSURE_UNDEREXPOSED_MEAN:
        verdict = "underexposed"
    else:
        verdict = "normal"

    return {
        "score": round(score, 1),
        "mean_brightness": round(mean_brightness, 1),
        "shadow_clip_pct": shadow_clip_pct,
        "highlight_clip_pct": highlight_clip_pct,
        "verdict": verdict,
    }


def defect_scores(focus: float, anisotropy: float) -> tuple[float, float]:
    """Return (soft_focus_score, camera_shake_score), each 0–100 (higher = worse).

    Unsharpness comes from low Laplacian focus. That blur mass is split between
    soft-focus (isotropic) and camera-shake (directional) by edge anisotropy.
    """
    blur = max(0.0, 100.0 - float(focus or 0.0))
    dir_factor = float(np.clip(
        (float(anisotropy or 1.0) - ANISO_DIR_START) / ANISO_DIR_SPAN,
        0.0,
        1.0,
    ))
    soft = blur * (1.0 - dir_factor)
    shake = blur * dir_factor
    return round(soft, 1), round(shake, 1)


def _tags_from_scores(soft_score: float, shake_score: float,
                       min_score: float = TAG_SCORE_MIN) -> list[str]:
    tags: list[str] = []
    if soft_score >= min_score:
        tags.append("soft_focus")
    if shake_score >= min_score:
        tags.append("camera_shake")
    return tags


def compute_quality_tags(image_path: Path) -> dict:
    """Local (non-AI) soft-focus / camera-shake heuristics on a preview."""
    img = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
    if img is None:
        raise ValueError(f"Failed to read image: {image_path}")
    # Downscale further if needed so scan stays cheap on large previews.
    h, w = img.shape[:2]
    max_side = 960
    if max(h, w) > max_side:
        scale = max_side / max(h, w)
        img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    focus, lap_var = _focus_metrics(gray)
    anisotropy = _edge_anisotropy(gray)
    exposure_metrics = _exposure_metrics(gray)
    exposure = exposure_metrics["score"]
    soft_score, shake_score = defect_scores(focus, anisotropy)
    tags = _tags_from_scores(soft_score, shake_score)
    return {
        "focus": round(focus, 1),
        "exposure": round(exposure, 1),
        "exposure_verdict": exposure_metrics["verdict"],
        "mean_brightness": exposure_metrics["mean_brightness"],
        "shadow_clip_pct": exposure_metrics["shadow_clip_pct"],
        "highlight_clip_pct": exposure_metrics["highlight_clip_pct"],
        "anisotropy": round(anisotropy, 3),
        "laplacian_var": round(lap_var, 1),
        "soft_focus_score": soft_score,
        "camera_shake_score": shake_score,
        "technical_score": round((focus + exposure) / 2, 1),
        "tags": tags,
    }


def compute_technical_score(image_path: Path) -> dict:
    """Back-compat wrapper used by AI analysis; includes quality tags."""
    return compute_quality_tags(image_path)


def compute_rgb_histogram(image_path: Path) -> dict:
    """256-bucket R/G/B histograms for the lightbox histogram widget.

    Computed on demand from the (already-generated) preview — cheap, and
    keeps the ~1.5KB-per-channel payload out of the persisted photo record.
    """
    img = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
    if img is None:
        raise ValueError(f"Failed to read image: {image_path}")
    channels = {}
    for name, idx in (("b", 0), ("g", 1), ("r", 2)):
        hist = cv2.calcHist([img], [idx], None, [256], [0, 256]).flatten()
        channels[name] = hist.astype(int).tolist()
    return channels


# --- format conversion / export ------------------------------------------

EXPORT_PILLOW_FORMAT = {"jpeg": "JPEG", "png": "PNG", "webp": "WEBP"}
EXPORT_QUALITY = 92  # applies to jpeg/webp; png is always lossless


def _load_full_resolution(original_path: Path) -> Image.Image:
    """Decode at full resolution (not the downscaled grid preview)."""
    if is_raw_path(original_path):
        import rawpy

        with rawpy.imread(str(original_path)) as raw:
            rgb = raw.postprocess(use_camera_wb=True, half_size=False, output_bps=8)
        return Image.fromarray(rgb)

    img = Image.open(original_path)
    img.load()
    return ImageOps.exif_transpose(img)


def convert_image(
    original_path: Path,
    dest_path: Path,
    target_format: str,
    rotation: int = 0,
    flip_h: bool = False,
    straighten_angle: float = 0.0,
    crop: dict | None = None,
    quality: int | None = None,
) -> tuple[tuple[int, int], tuple[int, int]]:
    """Decode `original_path` (RAW/HEIC/JPEG/PNG) and save as jpeg/png/webp.

    Used by the batch export feature — always writes into a project
    subfolder chosen by the caller, never over the original file.
    `rotation`/`flip_h`/`straighten_angle`/`crop` bake in the user's
    non-destructive lightbox/grid transform, so exports look the way they
    looked in the app. `quality` (1-100) applies to jpeg/webp only; png is
    always lossless and ignores it.

    Returns (original_dims, output_dims) — export never resizes, so these
    only differ when `crop` shrinks the frame (or a 90/270 rotation swaps
    width/height). Used by the export-size estimator to report the change.
    """
    pillow_format = EXPORT_PILLOW_FORMAT.get(target_format)
    if not pillow_format:
        raise ValueError(f"Unsupported export format: {target_format}")
    q = quality if quality is not None else EXPORT_QUALITY

    img = _load_full_resolution(original_path)
    original_dims = img.size
    img = _apply_user_transform(img, rotation, flip_h, straighten_angle, crop)
    output_dims = img.size
    try:
        if pillow_format == "JPEG":
            if img.mode in ("RGBA", "LA", "P"):
                bg = Image.new("RGB", img.size, (255, 255, 255))
                rgba = img.convert("RGBA")
                bg.paste(rgba, mask=rgba.split()[-1])
                img = bg
            else:
                img = img.convert("RGB")
            img.save(dest_path, "JPEG", quality=q, optimize=True)
        elif pillow_format == "WEBP":
            img.save(dest_path, "WEBP", quality=q)
        else:  # PNG
            img.save(dest_path, "PNG")
    finally:
        img.close()
    return original_dims, output_dims


def compute_transformed_dims(
    width: int, height: int, rotation: int, straighten_angle: float, crop: dict | None
) -> tuple[int, int]:
    """Exact (not sampled) output pixel size after rotation/straighten/crop,
    computed from metadata alone — no pixel decode needed. Rotation is a
    trivial swap; straighten's bounding box follows the same expand=True
    geometry PIL's own Image.rotate() uses; crop is a plain fraction of
    whatever canvas came out of the previous two steps.
    """
    w, h = float(width), float(height)
    if rotation in (90, 270):
        w, h = h, w
    if straighten_angle:
        rad = math.radians(abs(straighten_angle))
        w, h = (
            abs(w * math.cos(rad)) + abs(h * math.sin(rad)),
            abs(w * math.sin(rad)) + abs(h * math.cos(rad)),
        )
    if crop:
        w, h = w * crop["w"], h * crop["h"]
    return round(w), round(h)


def estimate_export_one(
    original_path: Path,
    target_format: str,
    rotation: int = 0,
    flip_h: bool = False,
    straighten_angle: float = 0.0,
    crop: dict | None = None,
    quality: int | None = None,
) -> dict:
    """Render one photo through the real export pipeline into a throwaway
    temp file so the batch-export modal can show an actual (not guessed)
    before/after size and dimension estimate, without writing anything into
    the project folder. Used on a small sample, not the whole selection —
    RAW demosaic is too slow to do for hundreds of photos just for a preview.
    """
    fd, tmp_name = tempfile.mkstemp(suffix=".photopick-estimate")
    os.close(fd)
    tmp_path = Path(tmp_name)
    try:
        original_dims, output_dims = convert_image(
            original_path, tmp_path, target_format,
            rotation, flip_h, straighten_angle, crop, quality,
        )
        output_bytes = tmp_path.stat().st_size
    finally:
        tmp_path.unlink(missing_ok=True)
    return {
        "original_dims": original_dims,
        "output_dims": output_dims,
        "output_bytes": output_bytes,
    }


OVERWRITE_JPEG_QUALITY = 95


def overwrite_original(
    original_path: Path,
    rotation: int,
    flip_h: bool,
    straighten_angle: float,
    crop: dict | None,
) -> None:
    """Bake rotation/flip/straighten/crop into `original_path` in place.

    Destructive — callers must confirm this with the user before calling.
    Only plain jpeg/png originals are supported (RAW/HEIC can't sanely be
    "overwritten" with a raster result). The source is fully decoded into
    memory, written to a sibling temp file, then swapped in with os.replace
    so a crash or interrupted write can never leave a truncated/corrupt file
    at the original path.
    """
    if is_raw_path(original_path) or is_heic_path(original_path):
        raise ValueError("cannot overwrite a RAW/HEIC original")
    target_format = "PNG" if original_path.suffix.lower() == ".png" else "JPEG"

    img = _load_full_resolution(original_path)
    img = _apply_user_transform(img, rotation, flip_h, straighten_angle, crop)
    tmp_path = original_path.with_name(original_path.name + ".photopick-tmp")
    try:
        if target_format == "JPEG":
            if img.mode in ("RGBA", "LA", "P"):
                bg = Image.new("RGB", img.size, (255, 255, 255))
                rgba = img.convert("RGBA")
                bg.paste(rgba, mask=rgba.split()[-1])
                img = bg
            else:
                img = img.convert("RGB")
            img.save(tmp_path, "JPEG", quality=OVERWRITE_JPEG_QUALITY, optimize=True)
        else:
            img.save(tmp_path, "PNG")
    finally:
        img.close()
    tmp_path.replace(original_path)
