# -*- coding: utf-8 -*-
"""
Withcam – Indexeur d'archives

Relève l'arborescence d'un disque d'archive (noms de dossiers, tailles, dates, types de
rushes, projets DaVinci) et l'envoie à la plateforme Withcam (page « Archives disques »),
pour savoir ensuite où se trouve chaque projet. AUCUN fichier n'est copié : seulement la liste.

Utilisation :
  - « python3 withcam-archives.py » : une fenêtre propose les disques branchés (sur Mac, boîtes de dialogue du système) ;
  - en ligne de commande : python3 withcam-archives.py /Volumes/ARCHIVE-03
La première fois, collez le jeton généré sur la page « Archives disques » de la plateforme.
Python 3.6 ou plus récent, sans module supplémentaire.
"""
import json
import os
import platform
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request

SITE = "https://tools.withcam.fr"
CONFIG = os.path.join(os.path.expanduser("~"), ".withcam-archives.json")
PROFONDEUR = 4                      # niveaux de dossiers gardés dans l'index (Année/Client/Projet/…)
IGNORES = {".Spotlight-V100", ".Trashes", ".fseventsd", ".TemporaryItems", ".DocumentRevisions-V100",
           "$RECYCLE.BIN", "System Volume Information", ".DS_Store", "lost+found", "CacheClip",
           ".gallery", "OptimizedMedia", "ProxyMedia"}
EXT_SUIVIES = {"braw", "r3d", "ari", "mov", "mp4", "mxf", "avi", "mts", "wav", "mp3", "aif", "aiff",
               "drp", "drt", "prproj", "fcpbundle", "aep", "arw", "cr2", "cr3", "nef", "dng", "raf", "jpg", "jpeg", "png", "psd"}


def lire_config():
    try:
        with open(CONFIG, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def ecrire_config(c):
    try:
        with open(CONFIG, "w", encoding="utf-8") as f:
            json.dump(c, f)
        os.chmod(CONFIG, 0o600)
    except Exception:
        pass


# ── Disques branchés et identité du volume (pour reconnaître le disque même renommé)
def disques_branches():
    s = platform.system()
    out = []
    if s == "Darwin":
        for n in sorted(os.listdir("/Volumes")):
            p = os.path.join("/Volumes", n)
            if os.path.ismount(p) and os.path.realpath(p) != "/":
                out.append(p)
    elif s == "Windows":
        import string
        for l in string.ascii_uppercase:
            p = l + ":\\"
            if l not in "AC" and os.path.exists(p):
                out.append(p)
    else:
        for base in ("/media/" + os.environ.get("USER", ""), "/media", "/mnt", "/run/media/" + os.environ.get("USER", "")):
            if os.path.isdir(base):
                for n in sorted(os.listdir(base)):
                    p = os.path.join(base, n)
                    if os.path.ismount(p):
                        out.append(p)
    return out


def identite_volume(racine):
    nom = os.path.basename(os.path.normpath(racine)) or racine
    uuid = None
    try:
        if platform.system() == "Darwin":
            import plistlib
            info = plistlib.loads(subprocess.check_output(["diskutil", "info", "-plist", racine], timeout=15))
            uuid = info.get("VolumeUUID") or info.get("DiskUUID")
            nom = info.get("VolumeName") or nom
        elif platform.system() == "Windows":
            import ctypes
            nom_buf, serie = ctypes.create_unicode_buffer(261), ctypes.c_uint32()
            if ctypes.windll.kernel32.GetVolumeInformationW(ctypes.c_wchar_p(racine), nom_buf, 261, ctypes.byref(serie), None, None, None, 0):
                uuid = "%08X" % serie.value
                nom = nom_buf.value or racine.rstrip(":\\")
        else:
            dev = subprocess.check_output(["findmnt", "-n", "-o", "SOURCE", racine], timeout=10).decode().strip()
            uuid = subprocess.check_output(["blkid", "-s", "UUID", "-o", "value", dev], timeout=10).decode().strip() or None
    except Exception:
        pass
    try:
        st = os.statvfs(racine) if hasattr(os, "statvfs") else None
        capacite = st.f_blocks * st.f_frsize if st else None
        libre = st.f_bavail * st.f_frsize if st else None
    except Exception:
        capacite = libre = None
    if capacite is None:
        try:
            import shutil
            u = shutil.disk_usage(racine)
            capacite, libre = u.total, u.free
        except Exception:
            pass
    return {"nom": nom, "uuid": uuid, "capacite": capacite, "libre": libre, "systeme": platform.system()}


# ── Parcours : tailles cumulées par dossier, gardées jusqu'à PROFONDEUR niveaux
def parcourir(racine, progres=None):
    tailles, fichiers, dates, types = {}, {}, {}, {}
    vus, debut = 0, time.time()

    def ajouter(rel, t, m, ext):
        # le fichier compte pour tous ses dossiers parents jusqu'à la profondeur gardée
        parties = rel.split("/")[:-1]
        for k in range(1, min(len(parties), PROFONDEUR) + 1):
            c = "/".join(parties[:k])
            tailles[c] = tailles.get(c, 0) + t
            fichiers[c] = fichiers.get(c, 0) + 1
            if m > dates.get(c, 0):
                dates[c] = m
            if ext in EXT_SUIVIES:
                d = types.setdefault(c, {})
                d[ext] = d.get(ext, 0) + 1

    total_t = total_f = 0
    for dossier, sous, noms in os.walk(racine):
        sous[:] = [d for d in sous if d not in IGNORES and not d.startswith(".")]
        rel_dossier = os.path.relpath(dossier, racine).replace("\\", "/")
        if rel_dossier == ".":
            rel_dossier = ""
        # un dossier vide ou ne contenant que des sous-dossiers existe aussi dans l'index
        if rel_dossier and rel_dossier.count("/") < PROFONDEUR:
            tailles.setdefault(rel_dossier, 0); fichiers.setdefault(rel_dossier, 0)
        for n in noms:
            if n.startswith("._") or n in IGNORES:
                continue
            p = os.path.join(dossier, n)
            try:
                st = os.lstat(p)
            except OSError:
                continue
            rel = (rel_dossier + "/" + n) if rel_dossier else n
            ext = n.rsplit(".", 1)[-1].lower() if "." in n else ""
            ajouter(rel, st.st_size, st.st_mtime, ext)
            total_t += st.st_size; total_f += 1
            vus += 1
            if progres and vus % 2000 == 0:
                progres("%d fichiers parcourus (%s)…" % (vus, lisible(total_t)))
        # les bundles DaVinci/Final Cut sont des dossiers : comptés comme projets
        for d in list(sous):
            ext = d.rsplit(".", 1)[-1].lower() if "." in d else ""
            if ext in ("fcpbundle", "drp"):
                ajouter((rel_dossier + "/" if rel_dossier else "") + d + "/x", 0, 0, ext)
    dossiers = [{"c": c, "t": tailles[c], "f": fichiers.get(c, 0),
                 "m": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(dates[c])) if dates.get(c) else "",
                 "x": types.get(c)} for c in sorted(tailles)]
    return dossiers, {"t": total_t, "f": total_f}, time.time() - debut


def lisible(o):
    return "%.1f To" % (o / 1e12) if o >= 1e12 else "%.0f Go" % (o / 1e9) if o >= 1e9 else "%.0f Mo" % (o / 1e6)


def envoyer(jeton, volume, dossiers, racine_stats):
    corps = json.dumps({"volume": volume, "dossiers": dossiers, "racine": racine_stats,
                        "machine": platform.node()[:60]}).encode("utf-8")
    req = urllib.request.Request(SITE + "/api/archives/index", data=corps, method="POST",
                                 headers={"Content-Type": "application/json", "X-Archives-Jeton": jeton, "User-Agent": "Withcam-Archives/1.0"})
    def lire(r):
        return json.loads(r.read().decode("utf-8"))
    try:
        with urllib.request.urlopen(req, timeout=300) as r:
            return lire(r)
    except urllib.error.HTTPError as e:
        try:
            msg = json.loads(e.read().decode("utf-8")).get("error")
        except Exception:
            msg = None
        raise Exception(msg or "La plateforme a répondu HTTP %d." % e.code)
    except urllib.error.URLError as e:
        if "CERTIFICATE" in str(e).upper():      # Python de python.org sur Mac : certificats parfois absents
            with urllib.request.urlopen(req, timeout=300, context=ssl._create_unverified_context()) as r:
                return lire(r)
        raise Exception("Pas de connexion à la plateforme (%s)." % e.reason)


def indexer(racine, jeton, progres=print):
    volume = identite_volume(racine)
    progres("Lecture de « %s »…" % volume["nom"])
    dossiers, stats, duree = parcourir(racine, progres)
    progres("%d dossiers relevés (%s, %d fichiers) en %d s — envoi…" % (len(dossiers), lisible(stats["t"]), stats["f"], duree))
    r = envoyer(jeton, volume, dossiers, stats)
    return "✓ « %s » indexé : %d dossiers%s." % (r.get("disque"), r.get("dossiers", 0), " (nouveau disque)" if r.get("nouveau") else "")


# ── Interface Mac : boîtes de dialogue natives (osascript). Le Tk 8.5 du Python d'Apple
#    affiche une fenêtre presque vide sur les macOS récents : on ne s'en sert pas sur Mac.
def osa(script):
    r = subprocess.run(["osascript", "-"], input=script, capture_output=True, text=True)
    return r.returncode, r.stdout.strip()


def chaine_as(t):
    return '"' + str(t).replace("\\", "\\\\").replace('"', '\\"') + '"'


def dialogue(texte, boutons=("OK",), titre="Withcam – Indexeur d'archives"):
    code, sortie = osa('button returned of (display dialog %s with title %s buttons {%s} default button %s)'
                       % (chaine_as(texte), chaine_as(titre), ", ".join(chaine_as(b) for b in boutons), chaine_as(boutons[-1])))
    return sortie if code == 0 else None


def demander_jeton():
    code, sortie = osa('text returned of (display dialog %s with title "Withcam – Indexeur d\'archives" default answer "" with hidden answer buttons {"Annuler", "OK"} default button "OK")'
                       % chaine_as("Collez le jeton généré sur la page « Archives disques » de la plateforme (une seule fois par ordinateur) :"))
    return sortie.strip() if code == 0 else None


def interface_mac():
    conf = lire_config()
    while True:
        disques = disques_branches()
        if disques:
            break
        if dialogue("Aucun disque externe détecté.\n\nBranchez un disque d'archive puis cliquez sur « Réessayer ».", ("Annuler", "Réessayer")) != "Réessayer":
            return
    noms = [os.path.basename(d) for d in disques]
    code, sortie = osa('set r to choose from list {%s} with title "Withcam – Indexeur d\'archives" with prompt %s OK button name "Indexer et envoyer" with multiple selections allowed\n'
                       'if r is false then return ""\nset AppleScript\'s text item delimiters to linefeed\nreturn r as text'
                       % (", ".join(chaine_as(n) for n in noms), chaine_as("Disques à indexer (⌘-clic pour en choisir plusieurs).\nAucun fichier n'est copié : seulement la liste des dossiers.")))
    choisis = [disques[noms.index(n)] for n in sortie.split("\n") if n in noms] if code == 0 else []
    if not choisis:
        return
    jeton = conf.get("jeton")
    if not jeton:
        jeton = demander_jeton()
        if not jeton:
            return
        conf["jeton"] = jeton; ecrire_config(conf)
    resultats = []
    for racine in choisis:
        osa('display notification %s with title "Withcam – Indexeur d\'archives"' % chaine_as("Lecture de « %s »… (quelques minutes sur un gros disque)" % os.path.basename(racine)))
        try:
            resultats.append(indexer(racine, jeton))
        except Exception as e:
            resultats.append("⚠️ %s : %s" % (os.path.basename(racine), e))
            if "jeton" in str(e).lower():          # jeton révoqué ou mal collé : on le redemandera
                conf.pop("jeton", None); ecrire_config(conf)
    dialogue("\n".join(resultats), ("OK",))


# ── Interface : petite fenêtre Tk si disponible, sinon ligne de commande
def interface():
    import tkinter as tk
    from tkinter import ttk
    conf = lire_config()
    fen = tk.Tk(); fen.title("Withcam – Indexeur d'archives"); fen.geometry("560x330")
    tk.Label(fen, text="Disque à indexer :", anchor="w").pack(fill="x", padx=14, pady=(14, 2))
    disques = disques_branches()
    choix = tk.StringVar(value=disques[0] if disques else "")
    liste = ttk.Combobox(fen, textvariable=choix, values=disques, state="readonly"); liste.pack(fill="x", padx=14)
    tk.Label(fen, text="Jeton (page « Archives disques » de la plateforme) :", anchor="w").pack(fill="x", padx=14, pady=(12, 2))
    jeton = tk.StringVar(value=conf.get("jeton", ""))
    tk.Entry(fen, textvariable=jeton, show="•").pack(fill="x", padx=14)
    etat = tk.Label(fen, text="Aucun fichier n'est copié : seulement la liste des dossiers.", anchor="w", justify="left", wraplength=530, fg="#555")
    etat.pack(fill="x", padx=14, pady=14)

    def progres(t):
        etat.config(text=t); fen.update()

    def lancer():
        if not choix.get() or not jeton.get().strip():
            progres("Choisissez un disque et collez le jeton.")
            return
        conf["jeton"] = jeton.get().strip(); ecrire_config(conf)
        bouton.config(state="disabled")
        try:
            progres(indexer(choix.get(), conf["jeton"], progres))
        except Exception as e:
            progres("⚠️ " + str(e))
        bouton.config(state="normal")

    def rafraichir():
        d = disques_branches(); liste.config(values=d)
        if d and choix.get() not in d:
            choix.set(d[0])
    cadre = tk.Frame(fen); cadre.pack(fill="x", padx=14)
    tk.Button(cadre, text="Actualiser la liste", command=rafraichir).pack(side="left")
    bouton = tk.Button(cadre, text="Indexer et envoyer", command=lancer); bouton.pack(side="right")
    if not disques:
        progres("Aucun disque externe détecté : branchez un disque puis « Actualiser la liste ».")
    fen.mainloop()


def principal():
    args = [a for a in sys.argv[1:] if not a.startswith("-")]
    if args:
        conf = lire_config()
        jeton = conf.get("jeton") or input("Jeton (page « Archives disques ») : ").strip()
        conf["jeton"] = jeton; ecrire_config(conf)
        for racine in args:
            try:
                print(indexer(racine, jeton))
            except Exception as e:
                print("⚠️ %s : %s" % (racine, e))
        return
    if platform.system() == "Darwin" and "--fenetre" not in sys.argv:
        interface_mac()
        return
    try:
        interface()
    except Exception as e:
        print("Fenêtre indisponible (%s). Utilisation : python3 withcam-archives.py /chemin/du/disque" % e)


if __name__ == "__main__":
    principal()
