#!/usr/bin/env python3
"""
Convertidor de fotografies RAW a JPG.

Ús:
    python raw_to_jpg.py <carpeta_origen> <carpeta_destí> [factor_reducció]

Exemples:
    python raw_to_jpg.py ./fotos ./sortida
    python raw_to_jpg.py ./fotos ./sortida 2
    python raw_to_jpg.py ./fotos ./sortida 4.5

Formats suportats:
    Sony   : .arw, .srf, .sr2
    Canon  : .cr2, .cr3, .crw
    Nikon  : .nef, .nrw
    Olympus: .orf
    JPEG   : .jpg, .jpeg
"""

import sys
import os
import json
import shutil
import subprocess
import argparse
from pathlib import Path

try:
    import rawpy
except ImportError:
    sys.exit("Error: cal instal·lar 'rawpy'  →  pip install rawpy")

try:
    import exifread
except ImportError:
    sys.exit("Error: cal instal·lar 'exifread'  →  pip install exifread")

try:
    from PIL import Image
except ImportError:
    sys.exit("Error: cal instal·lar 'Pillow'  →  pip install Pillow")

import numpy as np

# ── Extensions suportades ────────────────────────────────────────────────────
RAW_EXTENSIONS = {
    # Sony
    ".arw", ".srf", ".sr2",
    # Canon
    ".cr2", ".cr3", ".crw",
    # Nikon
    ".nef", ".nrw",
    # Olympus
    ".orf",
}

JPEG_EXTENSIONS = {".jpg", ".jpeg"}

ALL_EXTENSIONS = RAW_EXTENSIONS | JPEG_EXTENSIONS

JPG_QUALITY = 95  # qualitat JPEG (0-100)

# ── Paraules clau que indiquen B&N en valors de tags EXIF/MakerNote ──────────
_BN_KEYWORDS = {"b&w", "bw", "black", "mono", "gray", "grey", "monotone", "monochrome"}

# Comprovem una vegada si exiftool és accessible.
# shutil.which() només cerca al PATH del procés, que en una app py2app compilada
# és molt reduït i no inclou /usr/local/bin ni /opt/homebrew/bin.
# Per això busquem explícitament als paths habituals de macOS / Linux / Windows.
def _trobar_exiftool() -> str | None:
    # 0. Bundle PyInstaller (sys._MEIPASS) — exiftool.exe extret a carpeta temporal
    if hasattr(sys, "_MEIPASS"):
        candidate = os.path.join(sys._MEIPASS, "exiftool.exe")
        if os.path.isfile(candidate):
            return candidate
    # 1. Directori de l'executable compilat — l'usuari té exiftool.exe al costat del .exe
    if getattr(sys, "frozen", False):
        candidate = os.path.join(os.path.dirname(sys.executable), "exiftool.exe")
        if os.path.isfile(candidate):
            return candidate
    # 2. PATH estàndard del procés
    et = shutil.which("exiftool")
    if et:
        return et
    # 3. Paths habituals que macOS/Homebrew/MacPorts no afegeixen al PATH de les .app
    candidates = [
        "/usr/local/bin/exiftool",       # Homebrew Intel
        "/opt/homebrew/bin/exiftool",    # Homebrew Apple Silicon
        "/opt/local/bin/exiftool",       # MacPorts
        "/usr/bin/exiftool",             # Linux sistema
        "/usr/local/sbin/exiftool",
    ]
    for p in candidates:
        if os.path.isfile(p) and os.access(p, os.X_OK):
            return p
    return None

_EXIFTOOL = _trobar_exiftool()


def _exiftool_tags(path: Path) -> dict:
    """Retorna un dict {NomTag: valor_string} via exiftool -json, o {} si falla."""
    if not _EXIFTOOL:
        return {}
    try:
        extra = {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
        r = subprocess.run(
            [_EXIFTOOL, "-json", "-a", "-u", str(path)],
            capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, **extra
        )
        if r.returncode != 0 or not r.stdout.strip():
            return {}
        data = json.loads(r.stdout)
        return {k.lower(): str(v).lower() for k, v in data[0].items()} if data else {}
    except Exception:
        return {}


def _exifread_tags(path: Path) -> dict:
    """Retorna tags via exifread, o {} si falla (p. ex. CR3 no suportat)."""
    try:
        import warnings, logging
        logging.getLogger("exifread").setLevel(logging.CRITICAL)
        with open(path, "rb") as f:
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                return exifread.process_file(f, details=True, strict=False)
    except Exception:
        return {}


def _tag_val(tags: dict, *noms: str) -> str:
    """Retorna el valor (en minúscules) del primer tag trobat, o ''."""
    for nom in noms:
        tag = tags.get(nom)
        if tag is not None:
            try:
                v = tag.values
                return (v.decode("latin-1") if isinstance(v, bytes) else str(v)).lower()
            except Exception:
                continue
    return ""


def _conté_bn(val: str) -> bool:
    return any(k in val for k in _BN_KEYWORDS)


def es_blanc_i_negre(path: Path) -> bool:
    """
    Detecta si el fitxer d'origen va ser fotografiat en mode B&N.

    Usa exiftool (més fiable) si està disponible, amb fallback a exifread.

    Estratègia per fabricant
    ─────────────────────────
    Sony  (ARW) : ColorMode == 6 ("B&W") o CreativeStyle conté B&W/BW
    Nikon (NEF) : PictureControlData conté 'monochrome'
    Canon (CR2/CR3): PictureStyle conté 'mono'/'black' — CR3 requereix exiftool
    Olympus (ORF): ColorSpace == 3 (Monotone) o conté paraula clau
    Fallback:  qualsevol tag amb valor que contingui paraula clau de B&N
    """
    ext = path.suffix.lower()

    # ── Via exiftool (preferida; l'única que funciona amb CR3) ────────────────
    if _EXIFTOOL:
        et = _exiftool_tags(path)
        if et:
            if ext in {".arw", ".srf", ".sr2"}:
                # ColorMode 6 = B&W en Sony; exiftool el tradueix a 'b&w' o 'black & white'
                cm = et.get("colormode", "")
                cs = et.get("creativestyle", "")
                if _conté_bn(cm) or _conté_bn(cs) or cm == "6":
                    return True

            elif ext in {".nef", ".nrw"}:
                for k in ("picturecontroldata", "custompicturecontrol",
                          "picturestyle", "colorspace"):
                    if _conté_bn(et.get(k, "")):
                        return True

            elif ext in {".cr2", ".cr3", ".crw"}:
                for k in ("picturestyle", "picturestyle", "canonpicturestyle",
                          "colorspace"):
                    if _conté_bn(et.get(k, "")):
                        return True

            elif ext == ".orf":
                cs = et.get("colorspace", "")
                if cs == "3" or _conté_bn(cs):
                    return True

            # Fallback genèric sobre tots els tags exiftool
            for k, v in et.items():
                if _conté_bn(v):
                    return True

            return False  # exiftool ha llegit el fitxer però no ha trobat B&N

    # ── Via exifread (fallback quan exiftool no disponible) ───────────────────
    tags = _exifread_tags(path)
    if not tags:
        return False

    if ext in {".arw", ".srf", ".sr2"}:
        val = _tag_val(tags, "MakerNote ColorMode", "Sony ColorMode",
                       "MakerNote Tag 0x7000")
        # ColorMode 6 = B&W (no 9); exifread pot retornar el número o el text
        if val == "6" or _conté_bn(val):
            return True
        val_cs = _tag_val(tags, "MakerNote CreativeStyle", "Sony CreativeStyle",
                          "MakerNote Tag 0xa004")
        if _conté_bn(val_cs):
            return True

    elif ext in {".nef", ".nrw"}:
        for nom in ("Nikon3 PictureControlData", "Nikon3 CustomPictureControl",
                    "MakerNote PictureControlData", "Nikon3 ColorSpace"):
            if _conté_bn(_tag_val(tags, nom)):
                return True

    elif ext in {".cr2", ".crw"}:   # CR3 necessita exiftool, no arribarà aquí normalment
        for nom in ("MakerNote CanonCameraSettings", "Canon PictureStyle",
                    "CanonCs PictureStyle", "MakerNote PictureStyle"):
            if _conté_bn(_tag_val(tags, nom)):
                return True

    elif ext == ".orf":
        val = _tag_val(tags, "OlympusIp ColorSpace", "Olympus ColorSpace",
                       "MakerNote ColorSpace")
        if val == "3" or _conté_bn(val):
            return True

    # Fallback genèric exifread
    for nom, tag in tags.items():
        try:
            v = tag.values
            val = (v.decode("latin-1") if isinstance(v, bytes) else str(v)).lower()
        except Exception:
            continue
        if _conté_bn(val):
            return True

    return False






def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Converteix fitxers RAW a JPG, amb reducció opcional de resolució."
    )
    parser.add_argument(
        "origen",
        type=Path,
        help="Carpeta origen amb els fitxers RAW",
    )
    parser.add_argument(
        "desti",
        type=Path,
        help="Carpeta destí on es desaran els JPG",
    )
    parser.add_argument(
        "factor",
        nargs="?",
        type=float,
        default=None,
        help="Factor de reducció de resolució (p. ex. 2 → la meitat, 4 → un quart)",
    )
    return parser.parse_args()


def validar_args(args: argparse.Namespace) -> None:
    if not args.origen.is_dir():
        sys.exit(f"Error: la carpeta origen no existeix: {args.origen}")
    if args.factor is not None and args.factor <= 0:
        sys.exit(f"Error: el factor de reducció ha de ser un nombre positiu (rebut: {args.factor})")


def trobar_fitxers(carpeta: Path) -> list[Path]:
    """Retorna tots els fitxers RAW i JPEG (recursivament) dins la carpeta donada."""
    fitxers = [
        f for f in carpeta.rglob("*")
        if f.is_file() and f.suffix.lower() in ALL_EXTENSIONS
    ]
    return sorted(fitxers)


def _extreure_exif_piexif(path: Path) -> bytes | None:
    """
    Extreu les dades EXIF via piexif (fallback sense exiftool).
    Neteja la miniatura, buida l'IFD1 i reseteja Orientation a 1
    (la rotació ja s'ha aplicat píxel a píxel).
    """
    try:
        import piexif
        d = piexif.load(str(path))
        d.pop("thumbnail", None)
        d["1st"] = {}
        d["0th"][piexif.ImageIFD.Orientation] = 1
        return piexif.dump(d)
    except Exception:
        return None


def _transferir_metadades_exiftool(origen: Path, desti: Path) -> None:
    """
    Copia totes les metadades (EXIF, XMP, IPTC, copyright) via exiftool.
    Exclou els tags de dimensions i orientació, que ja no són vàlids
    després del processament de la imatge.
    """
    extra = {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
    subprocess.run(
        [
            _EXIFTOOL,
            "-overwrite_original",
            "-tagsFromFile", str(origen),
            "-all:all",
            "--IFD1:all",
            "--ExifIFD:PixelXDimension",
            "--ExifIFD:PixelYDimension",
            "--IFD0:ImageWidth",
            "--IFD0:ImageLength",
            "--IFD0:Orientation",
            "--ExifIFD:Orientation",
            str(desti),
        ],
        capture_output=True, timeout=30, **extra
    )


def convertir(raw_path: Path, jpg_path: Path, factor: float | None) -> tuple[bool, bool]:
    """
    Llegeix un fitxer RAW o JPEG i el desa com a JPG.
    Retorna (ok, bn) on bn indica si s'ha aplicat conversió B&N.
    Per a JPEGs d'origen, aplica la rotació EXIF i afegeix "R" al nom de sortida.
    Les metadades originals es transfereixen al fitxer de sortida:
      - Via exiftool si disponible (EXIF + XMP + IPTC complet)
      - Via piexif com a fallback (EXIF bàsic)
    """
    bn = es_blanc_i_negre(raw_path)

    if raw_path.suffix.lower() in JPEG_EXTENSIONS:
        from PIL import ImageOps
        img = ImageOps.exif_transpose(Image.open(str(raw_path))).convert("RGB")
        jpg_path = jpg_path.with_name(jpg_path.stem + "R" + jpg_path.suffix)
    else:
        with rawpy.imread(str(raw_path)) as raw:
            rgb = raw.postprocess(
                use_camera_wb=True,
                half_size=False,
                no_auto_bright=False,
                output_bps=8,
            )
        img = Image.fromarray(rgb)

    if bn:
        img = img.convert("L").convert("RGB")

    if factor is not None and factor != 1.0:
        nova_amplada = max(1, round(img.width / factor))
        nova_altura  = max(1, round(img.height / factor))
        img = img.resize((nova_amplada, nova_altura), Image.LANCZOS)

    jpg_path.parent.mkdir(parents=True, exist_ok=True)

    # Guardem amb EXIF bàsic via piexif (fallback sempre disponible)
    save_kw: dict = {"format": "JPEG", "quality": JPG_QUALITY, "subsampling": 0}
    exif_bytes = _extreure_exif_piexif(raw_path)
    if exif_bytes:
        save_kw["exif"] = exif_bytes
    img.save(str(jpg_path), **save_kw)

    # Si exiftool disponible, sobreescrivim amb metadades completes (XMP, IPTC…)
    if _EXIFTOOL:
        _transferir_metadades_exiftool(raw_path, jpg_path)

    return True, bn


def main() -> None:
    args = parse_args()
    validar_args(args)

    fitxers = trobar_fitxers(args.origen)
    if not fitxers:
        print(f"No s'han trobat fitxers RAW ni JPEG a: {args.origen}")
        return

    args.desti.mkdir(parents=True, exist_ok=True)

    total   = len(fitxers)
    errors  = []

    print(f"S'han trobat {total} fitxer(s) (RAW + JPEG).")
    if args.factor:
        print(f"Factor de reducció: /{args.factor}")
    print()

    for i, raw_path in enumerate(fitxers, start=1):
        # Mantenim l'estructura de subcarpetes relativa
        rel = raw_path.relative_to(args.origen)
        jpg_path = args.desti / rel.with_suffix(".jpg")

        print(f"[{i}/{total}] {rel}  →  {jpg_path.relative_to(args.desti)}", end="  ", flush=True)

        try:
            _, bn = convertir(raw_path, jpg_path, args.factor)
            etiqueta = " [B&N]" if bn else ""
            print(f"✓{etiqueta}")
        except Exception as exc:
            print(f"✗  ({exc})")
            errors.append((raw_path, exc))

    print()
    print(f"Conversió finalitzada: {total - len(errors)}/{total} fitxers convertits correctament.")
    if errors:
        print(f"\nErrors ({len(errors)}):")
        for path, err in errors:
            print(f"  • {path}: {err}")


if __name__ == "__main__":
    main()
