#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Squeeze — interface graphique pour compresser un dossier de photos (JPEG ou WebP) en
réduisant leur poids, et en option leur taille, sans toucher aux originaux.

Installation :
    python3 -m pip install PySide6 Pillow

Lancement :
    python3 squeeze.py                         puis « Ouvrir un dossier… »
    python3 squeeze.py ~/Photos/voyage         (dossier de photos)

Les photos compressées sont écrites dans un dossier à part (par défaut « <dossier>_compresse »),
avec le même nom que l'original. Les réglages sont enregistrés à côté des photos dans
« squeeze_reglages.json » et retrouvés à la réouverture du dossier.
"""

__version__ = "1.0.0"

import io
import json
import os
import shutil
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

from PIL import Image, ImageOps
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QImage, QKeySequence, QPixmap, QShortcut
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout,
                               QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
                               QMainWindow, QMessageBox, QProgressBar, QPushButton, QSlider,
                               QSplitter, QVBoxLayout, QWidget)

APP = "Squeeze"
EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
SETTINGS_FILE = "squeeze_reglages.json"
FORMATS = [("JPEG", "jpg"), ("WebP", "webp")]
SIZES = [("Taille d'origine", 0), ("4096 px", 4096), ("3000 px", 3000), ("2048 px", 2048),
         ("1600 px", 1600), ("1200 px", 1200), ("800 px", 800)]
DEFAULTS = {"format": "jpg", "quality": 80, "max_side": 0, "metadata": True, "keep_smaller": True}


# ============================================================== compression
def human(n):
    for unit in ("o", "Ko", "Mo", "Go"):
        if n < 1024 or unit == "Go":
            return f"{n:.0f} {unit}" if unit == "o" else f"{n:.1f} {unit}"
        n /= 1024


def compress(path, opts):
    """Compresse une photo : renvoie (octets, (largeur, hauteur)). Ne modifie jamais le fichier."""
    with Image.open(path) as src:
        exif = src.getexif()
        icc = src.info.get("icc_profile") if opts["metadata"] else None
        img = ImageOps.exif_transpose(src)          # applique la rotation puis la retire de l'EXIF
        img.load()
    if img.mode not in ("RGB", "L"):
        if img.mode.startswith("I;16") or img.mode == "I":
            img = img.point(lambda v: v / 256).convert("L")
        else:
            rgba = img.convert("RGBA")              # fond blanc sous la transparence
            img = Image.new("RGB", rgba.size, (255, 255, 255))
            img.paste(rgba, mask=rgba.getchannel("A"))
    side = opts["max_side"]
    if side and max(img.size) > side:               # jamais d'agrandissement
        img.thumbnail((side, side), Image.LANCZOS)
    kwargs = {"quality": int(opts["quality"])}
    if opts["format"] == "jpg":
        kwargs.update(optimize=True, progressive=True)
    else:
        kwargs.update(method=6)
    if icc:
        kwargs["icc_profile"] = icc
    if opts["metadata"] and len(exif):
        exif[0x0112] = 1                            # orientation déjà appliquée
        kwargs["exif"] = exif.tobytes()
    buf = io.BytesIO()
    img.save(buf, "JPEG" if opts["format"] == "jpg" else "WEBP", **kwargs)
    return buf.getvalue(), img.size


def gain_text(before, after):
    gain = 100 * (1 - after / before)
    return f"{'−' if gain >= 0 else '+'}{abs(gain):.0f} %"


def output_path(src, out_dir, opts):
    return Path(out_dir) / (Path(src).stem + "." + opts["format"])


def export_one(src, out_dir, opts):
    """Écrit la photo compressée ; renvoie (poids d'origine, poids final)."""
    before = os.path.getsize(src)
    data, _ = compress(src, opts)
    dest = output_path(src, out_dir, opts)
    same_format = Path(src).suffix.lower().lstrip(".").replace("jpeg", "jpg") == opts["format"]
    if opts["keep_smaller"] and same_format and len(data) >= before and not opts["max_side"]:
        shutil.copy2(src, dest)                     # déjà plus léger : on garde l'original
        return before, before
    dest.write_bytes(data)
    return before, len(data)


class ExportWorker(QObject):
    progress = Signal(int, int)                     # faites, total
    finished = Signal(int, int, list)               # poids avant, après, erreurs

    def __init__(self, files, out_dir, opts):
        super().__init__()
        self.files, self.out_dir, self.opts = files, out_dir, dict(opts)
        self.cancelled = False

    def run(self):
        before = after = done = 0
        errors = []
        Path(self.out_dir).mkdir(parents=True, exist_ok=True)
        with ThreadPoolExecutor(max_workers=max(1, (os.cpu_count() or 2) - 1)) as pool:
            futures = [(f, pool.submit(export_one, f, self.out_dir, self.opts)) for f in self.files]
            for f, fut in futures:
                if self.cancelled:
                    for _, other in futures:
                        other.cancel()
                    break
                try:
                    b, a = fut.result()
                    before, after = before + b, after + a
                except Exception as e:              # une photo illisible n'arrête pas le lot
                    errors.append(f"{Path(f).name} : {e}")
                done += 1
                self.progress.emit(done, len(self.files))
        self.finished.emit(before, after, errors)


class PreviewWorker(QObject):
    """Compresse en mémoire la photo sélectionnée pour montrer le résultat avant l'export."""
    ready = Signal(int, bytes, int, int, int)       # jeton, octets, largeur, hauteur, poids d'origine

    def __init__(self, token, path, opts):
        super().__init__()
        self.token, self.path, self.opts = token, path, dict(opts)

    def run(self):
        try:
            data, (w, h) = compress(self.path, self.opts)
            self.ready.emit(self.token, data, w, h, os.path.getsize(self.path))
        except Exception:
            self.ready.emit(self.token, b"", 0, 0, 0)


# ============================================================== fenêtre
class Squeeze(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(APP)
        self.resize(1200, 760)
        self.folder = None
        self.files = []
        self.opts = dict(DEFAULTS)
        self.out_dir = None
        self.token = 0
        self.timer = QTimer(singleShot=True, interval=250)      # évite de recalculer à chaque cran
        self.timer.timeout.connect(self.refresh_preview)
        self.build_ui()
        QShortcut(QKeySequence.StandardKey.Open, self, self.choose_folder)
        QShortcut(QKeySequence("Ctrl+E"), self, self.export)

    # ---------------------------------------------------------- interface
    def build_ui(self):
        self.listing = QListWidget()
        self.listing.currentRowChanged.connect(lambda _: self.timer.start())
        self.listing.setMinimumWidth(240)

        self.preview = QLabel("Ouvrez un dossier de photos")
        self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.preview.setMinimumSize(400, 300)
        self.preview.setStyleSheet("background:#1b1b1b;color:#aaa")
        self.info = QLabel("")
        self.info.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.pixmap = None

        centre = QWidget()
        cl = QVBoxLayout(centre)
        cl.addWidget(self.preview, 1)
        cl.addWidget(self.info)

        self.format = QComboBox()
        for label, key in FORMATS:
            self.format.addItem(label, key)
        self.quality = QSlider(Qt.Orientation.Horizontal)
        self.quality.setRange(10, 100)
        self.quality_label = QLabel()
        self.side = QComboBox()
        for label, value in SIZES:
            self.side.addItem(label, value)
        self.metadata = QCheckBox("Conserver les métadonnées (EXIF)")
        self.keep_smaller = QCheckBox("Garder l'original s'il est déjà plus léger")

        q = QHBoxLayout()
        q.addWidget(self.quality, 1)
        q.addWidget(self.quality_label)
        form = QFormLayout()
        form.addRow("Format", self.format)
        form.addRow("Qualité", q)
        form.addRow("Côté long max.", self.side)
        form.addRow(self.metadata)
        form.addRow(self.keep_smaller)
        box = QGroupBox("Compression")
        box.setLayout(form)

        self.out_label = QLabel("—")
        self.out_label.setWordWrap(True)
        out_btn = QPushButton("Choisir le dossier de sortie…")
        out_btn.clicked.connect(self.choose_output)
        ol = QVBoxLayout()
        ol.addWidget(self.out_label)
        ol.addWidget(out_btn)
        out_box = QGroupBox("Dossier de sortie")
        out_box.setLayout(ol)

        self.total = QLabel("")
        self.total.setWordWrap(True)
        self.bar = QProgressBar()
        self.bar.setVisible(False)
        self.go = QPushButton("Compresser (Ctrl+E)")
        self.go.clicked.connect(self.export)
        self.go.setEnabled(False)
        self.stop = QPushButton("Annuler")
        self.stop.setVisible(False)
        open_btn = QPushButton("Ouvrir un dossier…")
        open_btn.clicked.connect(self.choose_folder)

        side = QWidget()
        sl = QVBoxLayout(side)
        sl.addWidget(open_btn)
        sl.addWidget(box)
        sl.addWidget(out_box)
        sl.addStretch(1)
        sl.addWidget(self.total)
        sl.addWidget(self.bar)
        sl.addWidget(self.go)
        sl.addWidget(self.stop)
        side.setFixedWidth(320)

        split = QSplitter()
        split.addWidget(self.listing)
        split.addWidget(centre)
        split.addWidget(side)
        split.setStretchFactor(1, 1)
        self.setCentralWidget(split)

        self.format.currentIndexChanged.connect(self.changed)
        self.quality.valueChanged.connect(self.changed)
        self.side.currentIndexChanged.connect(self.changed)
        self.metadata.toggled.connect(self.changed)
        self.keep_smaller.toggled.connect(self.changed)
        self.apply_opts()

    def apply_opts(self):
        """Reporte self.opts dans les contrôles, sans déclencher de recalcul."""
        widgets = (self.format, self.quality, self.side, self.metadata, self.keep_smaller)
        for w in widgets:
            w.blockSignals(True)
        self.format.setCurrentIndex(max(0, self.format.findData(self.opts["format"])))
        self.quality.setValue(self.opts["quality"])
        self.side.setCurrentIndex(max(0, self.side.findData(self.opts["max_side"])))
        self.metadata.setChecked(self.opts["metadata"])
        self.keep_smaller.setChecked(self.opts["keep_smaller"])
        for w in widgets:
            w.blockSignals(False)
        self.quality_label.setText(str(self.opts["quality"]))

    def changed(self):
        self.opts.update(format=self.format.currentData(), quality=self.quality.value(),
                         max_side=self.side.currentData(), metadata=self.metadata.isChecked(),
                         keep_smaller=self.keep_smaller.isChecked())
        self.quality_label.setText(str(self.opts["quality"]))
        self.save_settings()
        self.timer.start()

    # ---------------------------------------------------------- dossier
    def choose_folder(self):
        d = QFileDialog.getExistingDirectory(self, "Dossier de photos", str(self.folder or Path.home()))
        if d:
            self.open_folder(d)

    def open_folder(self, d):
        folder = Path(d)
        self.files = sorted((p for p in folder.iterdir()
                             if p.suffix.lower() in EXTS and not p.name.startswith(".")),
                            key=lambda p: p.name.lower())
        if not self.files:
            QMessageBox.information(self, APP, "Aucune photo (JPEG, PNG, TIFF, WebP) dans ce dossier.")
            return
        self.folder = folder
        self.opts = dict(DEFAULTS)
        try:
            saved = json.loads((folder / SETTINGS_FILE).read_text(encoding="utf-8"))
            self.out_dir = saved.pop("out_dir", None)
            self.opts.update({k: v for k, v in saved.items() if k in DEFAULTS})
        except (OSError, ValueError):
            self.out_dir = None
        if not self.out_dir:
            self.out_dir = str(folder.parent / (folder.name + "_compresse"))
        self.apply_opts()
        self.set_output(self.out_dir)
        self.listing.clear()
        total = 0
        for p in self.files:
            size = p.stat().st_size
            total += size
            self.listing.addItem(QListWidgetItem(f"{p.name}   ({human(size)})"))
        self.total.setText(f"{len(self.files)} photos, {human(total)}")
        self.go.setEnabled(True)
        self.setWindowTitle(f"{APP} — {folder.name}")
        self.listing.setCurrentRow(0)

    def choose_output(self):
        d = QFileDialog.getExistingDirectory(self, "Dossier de sortie", self.out_dir or str(Path.home()))
        if d:
            self.set_output(d)

    def set_output(self, d):
        self.out_dir = d
        self.out_label.setText(d)
        self.save_settings()

    def save_settings(self):
        if not self.folder:
            return
        try:
            (self.folder / SETTINGS_FILE).write_text(
                json.dumps({**self.opts, "out_dir": self.out_dir}, ensure_ascii=False, indent=1),
                encoding="utf-8")
        except OSError:
            pass                                     # dossier en lecture seule : sans importance

    # ---------------------------------------------------------- aperçu
    def refresh_preview(self):
        row = self.listing.currentRow()
        if row < 0 or not self.files:
            return
        self.token += 1
        self.info.setText("Calcul…")
        worker = PreviewWorker(self.token, str(self.files[row]), self.opts)
        worker.ready.connect(self.show_preview)
        self.run_in_thread(worker)

    def show_preview(self, token, data, w, h, before):
        if token != self.token:                      # résultat périmé : réglages changés entre-temps
            return
        if not data:
            self.pixmap = None
            self.preview.setPixmap(QPixmap())
            self.preview.setText("Photo illisible")
            self.info.setText("")
            return
        self.pixmap = QPixmap.fromImage(QImage.fromData(data))
        self.fit_preview()
        self.info.setText(f"{w} × {h} px — {human(before)} → {human(len(data))}  "
                          f"({gain_text(before, len(data))})")

    def fit_preview(self):
        if self.pixmap:
            self.preview.setPixmap(self.pixmap.scaled(self.preview.size(),
                                                      Qt.AspectRatioMode.KeepAspectRatio,
                                                      Qt.TransformationMode.SmoothTransformation))

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self.fit_preview()

    def run_in_thread(self, worker):
        # Les signaux émis depuis un thread Python sont livrés dans le thread de l'interface.
        threading.Thread(target=worker.run, daemon=True).start()

    # ---------------------------------------------------------- export
    def export(self):
        if not self.files or not self.go.isEnabled():
            return
        try:
            same = Path(self.out_dir).resolve() == self.folder.resolve()
        except OSError:
            same = False
        if same:
            QMessageBox.warning(self, APP, "Le dossier de sortie ne peut pas être celui des photos "
                                           "d'origine : choisissez-en un autre.")
            return
        self.go.setEnabled(False)
        self.stop.setVisible(True)
        self.bar.setVisible(True)
        self.bar.setRange(0, len(self.files))
        self.bar.setValue(0)
        self.export_worker = ExportWorker([str(f) for f in self.files], self.out_dir, self.opts)
        self.export_worker.progress.connect(lambda d, n: self.bar.setValue(d))
        self.export_worker.finished.connect(self.export_done)
        self.stop.clicked.connect(lambda: setattr(self.export_worker, "cancelled", True))
        self.run_in_thread(self.export_worker)

    def export_done(self, before, after, errors):
        self.go.setEnabled(True)
        self.stop.setVisible(False)
        self.bar.setVisible(False)
        msg = "Terminé."
        if before:
            msg = f"{human(before)} → {human(after)} ({gain_text(before, after)})"
        if self.export_worker.cancelled:
            msg = "Annulé. " + msg
        self.total.setText(msg)
        if errors:
            QMessageBox.warning(self, APP, "Photos non compressées :\n" + "\n".join(errors[:15]))
        elif not self.export_worker.cancelled:
            QMessageBox.information(self, APP, f"{msg}\n\nPhotos écrites dans :\n{self.out_dir}")


def main():
    app = QApplication(sys.argv)
    win = Squeeze()
    win.show()
    if len(sys.argv) > 1 and Path(sys.argv[1]).is_dir():
        win.open_folder(sys.argv[1])
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
