"""
Script pour redimensionner des images en parallèle ou séquentiellement.

Ce script inclut :

- Calcul du hash SHA3-512 des fichiers pour détecter les modifications (la fonction de hachage est volontairement consommatrice en temps pour simuler une charge de travail).
- Redimensionnement des images à plusieurs largeurs cibles tout en préservant le ratio hauteur/largeur.
- Sauvegarde des images redimensionnées dans différents formats (PNG, WebP).
- Gestion des erreurs de lecture de fichiers et de redimensionnement.
- Utilisation de multiprocessing pour paralléliser les tâches de redimensionnement, et de hashing.
"""

import hashlib
import time
import logging
import multiprocessing
import sys
import pandas as pd
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor
from PIL import Image
from typing import List, Tuple, Dict, Callable, Any, Optional

# --- CONFIGURATION ---
EXECUTION_MODE: str = "sequential"  # Options : "parallel" ou "sequential"
SOURCE_DIRECTORY: Path = Path("../../resources/input_images/")
TARGET_DIRECTORY: Path = Path("../../resources/output_images/")
HASHES_CSV_PATH: Path = Path("../../resources/hash/hashes.csv")
RESIZE_WIDTHS: List[int] = [4000, 2500, 1928, 992, 768, 576, 480, 260, 150, 100, 50] # inspiré des breakpoints de Bootstrap
FORMATS: List[str] = ["png", "webp"]
FILENAME_COL: str = 'filename'
HASH_COL: str = 'hash'


def setup_logging() -> None:
    """
    Configure le système de logging pour le script.
    """
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(levelname)s - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
        handlers=[
            logging.StreamHandler(sys.stdout),
        ]
    )

def calculate_new_height(orig_width: int, orig_height: int, new_width: int) -> int:
    """
    Calcule la hauteur d'une image pour une nouvelle largeur donnée,
    tout en préservant le ratio hauteur/largeur original.

    Paramètres:
        orig_width (int): la largeur originale de l'image.
        orig_height (int): la hauteur originale de l'image.
        new_width (int): la nouvelle largeur souhaitée pour l'image.

    Retourne:
        int: la nouvelle hauteur calculée qui maintient le ratio de l'image.
    """
    if orig_width <= 0:
        raise ValueError("La largeur d'origine doit être positive.")
    scale_ratio = new_width / orig_width
    return int(orig_height * scale_ratio)


def compute_sha3_512(args: Tuple[Path]) -> Tuple[Optional[str], Path]:
    """
    Calcule l'empreinte numérique (hash) SHA3-512 d'un fichier.
    La fonction lit le fichier par blocs pour gérer efficacement les fichiers volumineux.
    L'argument est un tuple pour assurer la compatibilité avec `ProcessPoolExecutor.map`.

    Paramètres:
        args (Tuple[Path]): un tuple contenant le chemin du fichier à traiter.
                      Ex: (filepath,)

    Retourne:
        tuple (Tuple[Optional[str], Path]): un tuple contenant le hash hexadécimal (str) et le chemin du fichier (Path).
               En cas d'erreur de lecture, retourne (None, filepath).
    """
    filepath, = args
    hasher = hashlib.sha3_512()
    try:
        with open(filepath, 'rb') as f:
            for block in iter(lambda: f.read(65536), b''):
                hasher.update(block)
        return hasher.hexdigest(), filepath
    except IOError as e:
        logging.error("Erreur de lecture du fichier %s: %s", filepath.name, e)
        return None, filepath


def resize_single_image(args: Tuple[Path, int, str]) -> str:
    """
    Redimensionne une image à une largeur cible et la sauvegarde dans un format spécifié.
    Le redimensionnement n'est effectué que si la largeur cible est inférieure à la largeur originale.
    L'algorithme de rééchantillonnage LANCZOS est utilisé pour une haute qualité de réduction.

    Paramètres:
        args (Tuple[Path, int, str]): un tuple contenant les informations nécessaires :
                      - source_file (Path): Chemin de l'image d'origine.
                      - output_width (int): Largeur de l'image de sortie.
                      - fmt (str): Format de sortie souhaité (ex: "png", "webp").

    Retourne:
        str: un message de statut indiquant le résultat de l'opération (succès, échec, ignoré).
    """
    source_file, output_width, fmt = args
    try:
        with Image.open(source_file) as img:
            orig_width, orig_height = img.size
            if output_width >= orig_width:
                return f"Ignoré (trop grand) : {source_file.name} pour la largeur {output_width}px"

            new_height = calculate_new_height(orig_width, orig_height, output_width)
            img_resized = img.resize((output_width, new_height), Image.Resampling.LANCZOS)

            output_filename = f"{source_file.stem}_{output_width}x{new_height}.{fmt}"
            output_path = TARGET_DIRECTORY / output_filename
            img_resized.save(output_path, format=fmt.upper())
            return f"Traité : {source_file.name} -> {output_filename}"
    except Exception as e:
        error_msg = f"Erreur lors du traitement de {source_file.name} pour {output_width}px ({fmt}): {e}"
        logging.error(error_msg)
        return error_msg


def run_sequential(worker_function: Callable[[Any], Any], tasks: List[Any], description: str = "") -> Tuple[List[Any], float]:
    """
    Exécute une série de tâches de manière séquentielle, l'une après l'autre.
    Cette fonction est utile pour le débogage ou sur des systèmes mono-cœur.

    Paramètres:
        worker_function (Callable[[Any], Any]): la fonction à appliquer à chaque élément de la liste de tâches.
        tasks (List[Any]): une liste d'arguments, où chaque argument est destiné à un appel de `worker_function`.
        description (str, optional): une description de la tâche globale pour l'affichage.

    Retourne:
        tuple (Tuple[List[Any], float]) : un tuple contenant la liste des résultats de chaque tâche et la durée totale
               d'exécution en secondes.
    """
    logging.info("Démarrage de '%s' (%d tâches) en mode séquentiel...", description, len(tasks))

    start_t = time.time()
    # Utilise une list comprehension pour une exécution simple et directe
    results = [worker_function(task) for task in tasks]
    end_t = time.time()

    duration = end_t - start_t
    logging.info("Tâche '%s' terminée en %.2f secondes.", description, duration)
    return results, duration


def run_parallel(worker_function: Callable[[Any], Any], tasks: List[Any], description: str = "") -> Tuple[List[Any], float]:
    """
    Exécute une série de tâches en parallèle en utilisant un pool de processus.
    Le nombre de processus est basé sur le nombre de cœurs CPU disponibles pour optimiser les performances.

    Paramètres:
        worker_function (Callable[[Any], Any]): la fonction à exécuter pour chaque tâche.
        tasks (List[Any]): une liste d'arguments à passer à `worker_function`.
        description (str, optional): une description de la tâche globale pour l'affichage.

    Retourne:
        tuple (Tuple[List[Any], float]): un tuple contenant la liste des résultats (dans l'ordre des tâches)
               et la durée totale d'exécution en secondes.
    """
    workers = max(1, multiprocessing.cpu_count() - 1)
    logging.info("Démarrage de '%s' (%d tâches) avec %d processus...", description, len(tasks), workers)

    start_t = time.time()
    with ProcessPoolExecutor(max_workers=workers, initializer=setup_logging) as executor:
        results = list(executor.map(worker_function, tasks))
    end_t = time.time()

    duration = end_t - start_t
    logging.info("Tâche '%s' terminée en %.2f secondes.", description, duration)
    return results, duration


def get_hashes_from_csv():
    """
    Charge les hashes de fichiers précédemment calculés depuis un fichier CSV.
    Permet de comparer les hashes actuels aux anciens pour détecter les fichiers modifiés.

    Paramètres:
        Aucun.

    Retourne:
        dict (Dict[str, str]) : un dictionnaire où les clés sont les noms de fichiers (str) et les valeurs
              sont leurs hashes SHA3-512 (str). Retourne un dictionnaire vide si le
              fichier CSV n'existe pas.
    """
    if not HASHES_CSV_PATH.exists():
        return {}
    try:
        df = pd.read_csv(HASHES_CSV_PATH)
        return pd.Series(df[HASH_COL].values, index=df[FILENAME_COL]).to_dict()
    except (FileNotFoundError, pd.errors.EmptyDataError) as e:
        logging.warning("Impossible de lire le fichier de hash %s: %s. Un nouveau sera créé.", HASHES_CSV_PATH.name, e)
        return {}


def main() -> None:
    """
    Point d'entrée principal du script.
    Orchestre le processus de traitement d'images :
    1. sélectionne le mode d'exécution (séquentiel ou parallèle).
    2. calcule le hash des images sources pour détecter les changements.
    3. filtre les images qui sont nouvelles ou ont été modifiées.
    4. redimensionne les images filtrées dans plusieurs tailles et formats.
    5. affiche un résumé des temps d'exécution.
    """
    setup_logging()

    # --- Initialisation ---
    TARGET_DIRECTORY.mkdir(parents=True, exist_ok=True)
    HASHES_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)

    total_start_time = time.time()

    # --- Sélection du mode d'exécution ---
    if EXECUTION_MODE == "parallel":
        runner = run_parallel
    elif EXECUTION_MODE == "sequential":
        runner = run_sequential
    else:
        logging.critical("Mode d'exécution inconnu : '%s'. Choisissez 'parallel' ou 'sequential'.", EXECUTION_MODE)
        raise ValueError(f"Mode d'exécution inconnu : '{EXECUTION_MODE}'.")

    logging.info("--- Mode d'exécution sélectionné : %s ---", EXECUTION_MODE.upper())

    all_source_files = [f for f in SOURCE_DIRECTORY.iterdir() if f.is_file()]
    logging.info("Trouvé %d fichier(s) dans '%s'.", len(all_source_files), SOURCE_DIRECTORY.name)

    # --- Étape 1: Hachage ---
    hash_tasks = [(f,) for f in all_source_files]
    hash_results, hash_duration = runner(compute_sha3_512, hash_tasks, "Calcul des hashes")

    # --- Étape 2: Filtrage ---
    logging.info("Filtrage des fichiers modifiés...")
    old_hashes = get_hashes_from_csv()
    files_to_process: List[Path] = []
    new_hash_records: List[Dict[str, str]] = []

    for result in hash_results:
        file_hash: Optional[str] = result[0]
        filepath: Path = result[1]

        if file_hash is None: continue
        filename = filepath.name
        new_hash_records.append({FILENAME_COL: filename, HASH_COL: file_hash})
        if old_hashes.get(filename) != file_hash:
            files_to_process.append(filepath)

    if new_hash_records:
        pd.DataFrame(new_hash_records).to_csv(HASHES_CSV_PATH, index=False)
        logging.info("Fichier de hash '%s' mis à jour.", HASHES_CSV_PATH.name)

    logging.info("-> %d fichier(s) à traiter.", len(files_to_process))

    # --- Étape 3: Redimensionnement ---
    resize_duration = 0.0
    if files_to_process:
        resize_tasks = [
            (file, width, fmt)
            for file in files_to_process
            for width in RESIZE_WIDTHS
            for fmt in FORMATS
        ]
        _, resize_duration = runner(resize_single_image, resize_tasks, "Redimensionnement des images")
    else:
        logging.info("Aucun fichier à redimensionner.")

    # --- Résumé Final ---
    total_duration = time.time() - total_start_time
    logging.info("--- Résumé des temps ---")
    logging.info(f"Temps de hachage :           {hash_duration:.2f} secondes")
    logging.info(f"Temps de redimensionnement : {resize_duration:.2f} secondes")
    logging.info("--------------------------")
    logging.info(f"Traitement complet terminé en {total_duration:.2f} secondes.")


if __name__ == "__main__":
    # Pour s'assurer que le logging fonctionne correctement avec multiprocessing
    multiprocessing.freeze_support()
    main()
