import os
import sys
import shutil
import subprocess
import threading
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List, Tuple

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

# Drag & Drop (tkinterdnd2)
try:
    from tkinterdnd2 import DND_FILES, TkinterDnD
except Exception:
    TkinterDnD = None
    DND_FILES = None

SUPPORTED_EXTS = {".doc", ".docx"}


def is_windows() -> bool:
    return sys.platform.startswith("win")


def unique_path(path: Path) -> Path:
    """Avoid overwrite by adding _1, _2, ..."""
    if not path.exists():
        return path
    stem = path.stem
    suffix = path.suffix
    parent = path.parent
    i = 1
    while True:
        candidate = parent / f"{stem}_{i}{suffix}"
        if not candidate.exists():
            return candidate
        i += 1


def parse_drop_files(data: str) -> List[Path]:
    """
    tkinterdnd2 gives a string that may include braces around paths with spaces.
    Example: '{C:/My Files/a.docx} {C:/b.doc}'
    """
    items = []
    buf = ""
    in_brace = False
    for ch in data:
        if ch == "{":
            in_brace = True
            buf = ""
        elif ch == "}":
            in_brace = False
            if buf.strip():
                items.append(buf.strip())
            buf = ""
        elif ch == " " and not in_brace:
            if buf.strip():
                items.append(buf.strip())
            buf = ""
        else:
            buf += ch
    if buf.strip():
        items.append(buf.strip())
    return [Path(x) for x in items]


def find_soffice_candidates() -> List[Path]:
    """
    Try to locate LibreOffice 'soffice' on Windows even if it's not in PATH.
    Returns a list of candidate Paths (existing files).
    """
    cands: List[Path] = []

    # 1) PATH
    for exe in ("soffice", "soffice.exe", "libreoffice", "libreoffice.exe"):
        p = shutil.which(exe)
        if p:
            cands.append(Path(p))

    # 2) Common install locations (Windows)
    if is_windows():
        common = [
            r"C:\Program Files\LibreOffice\program\soffice.exe",
            r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
        ]
        for s in common:
            cands.append(Path(s))

        # 3) Registry: App Paths (if present)
        try:
            import winreg  # type: ignore

            keys = [
                (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\soffice.exe"),
                (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SOFFICE.EXE"),
            ]
            for root, k in keys:
                try:
                    with winreg.OpenKey(root, k) as hk:
                        val, _ = winreg.QueryValueEx(hk, None)
                        if val:
                            cands.append(Path(val))
                except Exception:
                    pass
        except Exception:
            pass

    # Filter existing files, deduplicate
    out: List[Path] = []
    seen = set()
    for p in cands:
        try:
            pp = p.resolve()
        except Exception:
            pp = p
        if pp.exists() and pp.is_file():
            s = str(pp).lower()
            if s not in seen:
                seen.add(s)
                out.append(pp)

    return out


class ConverterBackend:
    name = "base"

    def available(self) -> Tuple[bool, str]:
        """Return (available?, details)"""
        raise NotImplementedError

    def convert_to_pdf(self, src: Path, out_dir: Path) -> Path:
        raise NotImplementedError


class WordCOMBackend(ConverterBackend):
    name = "word"

    def __init__(self):
        self._ok_import = False
        self._win32 = None
        self._last_error = ""
        if is_windows():
            try:
                import win32com.client  # type: ignore
                self._win32 = win32com.client
                self._ok_import = True
            except Exception as e:
                self._ok_import = False
                self._last_error = f"Import pywin32 (win32com) impossible : {e}"

    def available(self) -> Tuple[bool, str]:
        if not is_windows():
            return False, "Non-Windows : backend Word COM indisponible."
        if not self._ok_import:
            return False, self._last_error or "pywin32 non importable dans cet environnement Python."
        try:
            app = self._win32.Dispatch("Word.Application")
            app.Quit()
            return True, "COM OK (Dispatch Word.Application)."
        except Exception as e:
            return False, f"COM Word inaccessible : {e}"

    def convert_to_pdf(self, src: Path, out_dir: Path) -> Path:
        out_dir.mkdir(parents=True, exist_ok=True)
        target = unique_path(out_dir / (src.stem + ".pdf"))

        word = None
        doc = None
        try:
            word = self._win32.Dispatch("Word.Application")
            word.Visible = False
            word.DisplayAlerts = 0  # wdAlertsNone

            # Options d'ouverture plus tolérantes
            # NB: certains paramètres varient selon versions Office ; on reste sur un set raisonnable.
            doc = word.Documents.Open(
                str(src),
                ReadOnly=True,
                AddToRecentFiles=False,
                ConfirmConversions=False,
                NoEncodingDialog=True,
                OpenAndRepair=True,
            )

            # wdExportFormatPDF = 17
            doc.ExportAsFixedFormat(
                OutputFileName=str(target),
                ExportFormat=17
            )

            if not target.exists():
                raise RuntimeError("Word n'a pas produit de PDF (fichier de sortie introuvable).")

            return target

        finally:
            try:
                if doc is not None:
                    doc.Close(False)
            except Exception:
                pass
            try:
                if word is not None:
                    word.Quit()
            except Exception:
                pass


class LibreOfficeBackend(ConverterBackend):
    name = "libreoffice"

    def __init__(self, soffice_path: Optional[str] = None):
        self._soffice: Optional[Path] = None
        self._details = ""

        if soffice_path:
            p = Path(soffice_path).expanduser()
            if p.exists() and p.is_file():
                self._soffice = p
                self._details = f"Utilisation du chemin sélectionné : {p}"
                return
            else:
                self._details = f"Chemin fourni invalide : {p}"

        cands = find_soffice_candidates()
        if cands:
            self._soffice = cands[0]
            self._details = f"Détecté : {self._soffice}"
        else:
            self._details = "Aucun 'soffice' détecté (PATH + emplacements standards)."

    def set_soffice(self, soffice_path: str) -> None:
        p = Path(soffice_path).expanduser()
        if p.exists() and p.is_file():
            self._soffice = p
            self._details = f"Utilisation du chemin sélectionné : {p}"
        else:
            self._soffice = None
            self._details = f"Chemin fourni invalide : {p}"

    def available(self) -> Tuple[bool, str]:
        if self._soffice is None:
            return False, self._details
        return True, self._details

    def convert_to_pdf(self, src: Path, out_dir: Path) -> Path:
        if self._soffice is None:
            raise RuntimeError("LibreOffice indisponible : 'soffice' introuvable (ou non configuré).")

        out_dir.mkdir(parents=True, exist_ok=True)

        cmd = [
            str(self._soffice),
            "--headless",
            "--nologo",
            "--nofirststartwizard",
            "--convert-to",
            "pdf",
            "--outdir",
            str(out_dir),
            str(src),
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)

        if result.returncode != 0:
            raise RuntimeError(
                "LibreOffice a échoué.\n"
                f"Commande: {' '.join(cmd)}\n"
                f"STDOUT: {result.stdout}\n"
                f"STDERR: {result.stderr}"
            )

        produced = out_dir / (src.stem + ".pdf")
        if not produced.exists():
            # fallback search
            pdfs = list(out_dir.glob(src.stem + "*.pdf"))
            if not pdfs:
                raise RuntimeError(
                    "LibreOffice a indiqué une conversion réussie, "
                    "mais aucun PDF n'a été trouvé dans le dossier de sortie."
                )
            produced = pdfs[0]

        # anti-écrasement : renommer si besoin
        unique = unique_path(out_dir / (src.stem + ".pdf"))
        if unique != produced and produced.exists():
            try:
                produced.rename(unique)
                produced = unique
            except Exception:
                pass

        return produced


class App:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("DOC/DOCX → PDF (Drag & Drop)")

        self.files: List[Path] = []
        self.is_converting = False

        # UI variables
        self.output_mode = tk.StringVar(value="per_file_convert")
        self.backend_mode = tk.StringVar(value="auto")
        self.single_dir = tk.StringVar(value="")
        self.soffice_path = tk.StringVar(value="")

        # Backends
        self.backend_word = WordCOMBackend()
        self.backend_lo = LibreOfficeBackend()

        self._build_ui()
        self._setup_dnd()

        self._log("Prêt. Glissez-déposez des fichiers .doc/.docx, ou utilisez 'Ajouter…'.")
        self._log("Astuce : cliquez sur 'Diagnostics' si aucune conversion ne démarre.")

    def _build_ui(self):
        frm = ttk.Frame(self.root, padding=12)
        frm.pack(fill="both", expand=True)

        # Drop zone
        self.drop = ttk.LabelFrame(frm, text="Zone de dépôt", padding=10)
        self.drop.pack(fill="x", expand=False)

        self.drop_label = ttk.Label(
            self.drop,
            text="Glissez-déposez ici vos fichiers .doc / .docx",
            anchor="center"
        )
        self.drop_label.pack(fill="x")

        # File list
        mid = ttk.Frame(frm)
        mid.pack(fill="both", expand=True, pady=(10, 10))

        left = ttk.Frame(mid)
        left.pack(side="left", fill="both", expand=True)

        ttk.Label(left, text="Fichiers à convertir :").pack(anchor="w")
        self.listbox = tk.Listbox(left, height=10)
        self.listbox.pack(fill="both", expand=True, pady=(4, 0))

        btns = ttk.Frame(mid)
        btns.pack(side="right", fill="y", padx=(10, 0))

        ttk.Button(btns, text="Ajouter…", command=self.add_files).pack(fill="x")
        ttk.Button(btns, text="Retirer sélection", command=self.remove_selected).pack(fill="x", pady=6)
        ttk.Button(btns, text="Vider la liste", command=self.clear_list).pack(fill="x")

        # Output options
        out = ttk.LabelFrame(frm, text="Sortie PDF", padding=10)
        out.pack(fill="x", expand=False)

        ttk.Radiobutton(
            out,
            text="Créer un sous-dossier /convert dans le dossier de chaque fichier",
            variable=self.output_mode,
            value="per_file_convert",
            command=self._refresh_output_state
        ).pack(anchor="w")

        row2 = ttk.Frame(out)
        row2.pack(fill="x", pady=(6, 0))
        ttk.Radiobutton(
            row2,
            text="Créer tous les PDF dans un dossier unique :",
            variable=self.output_mode,
            value="single_folder",
            command=self._refresh_output_state
        ).pack(side="left")

        self.dir_entry = ttk.Entry(row2, textvariable=self.single_dir)
        self.dir_entry.pack(side="left", fill="x", expand=True, padx=6)
        ttk.Button(row2, text="Choisir…", command=self.choose_output_dir).pack(side="left")

        # Backend options
        bck = ttk.LabelFrame(frm, text="Moteur de conversion", padding=10)
        bck.pack(fill="x", expand=False, pady=(10, 0))

        ttk.Radiobutton(bck, text="Auto (recommandé)", variable=self.backend_mode, value="auto").pack(anchor="w")
        ttk.Radiobutton(bck, text="Microsoft Word (Windows + Word requis)", variable=self.backend_mode, value="word").pack(anchor="w")
        ttk.Radiobutton(bck, text="LibreOffice (soffice requis)", variable=self.backend_mode, value="libreoffice").pack(anchor="w")

        # LibreOffice path chooser
        lo = ttk.LabelFrame(frm, text="LibreOffice (optionnel) — chemin vers soffice.exe", padding=10)
        lo.pack(fill="x", expand=False, pady=(10, 0))

        lo_row = ttk.Frame(lo)
        lo_row.pack(fill="x")
        self.soffice_entry = ttk.Entry(lo_row, textvariable=self.soffice_path)
        self.soffice_entry.pack(side="left", fill="x", expand=True)
        ttk.Button(lo_row, text="Parcourir…", command=self.choose_soffice).pack(side="left", padx=(8, 0))
        ttk.Button(lo_row, text="Appliquer", command=self.apply_soffice).pack(side="left", padx=(8, 0))

        # Actions + log
        actions = ttk.Frame(frm)
        actions.pack(fill="x", pady=(10, 0))

        self.btn_convert = ttk.Button(actions, text="Convertir", command=self.start_convert)
        self.btn_convert.pack(side="left")

        ttk.Button(actions, text="Diagnostics", command=self.run_diagnostics).pack(side="left", padx=8)

        self.progress = ttk.Label(actions, text="")
        self.progress.pack(side="left", padx=10)

        logf = ttk.LabelFrame(frm, text="Journal", padding=10)
        logf.pack(fill="both", expand=True, pady=(10, 0))

        self.log = tk.Text(logf, height=12, wrap="word")
        self.log.pack(fill="both", expand=True)

        self._refresh_output_state()

    def _setup_dnd(self):
        if TkinterDnD is None:
            self._log("Drag & Drop indisponible : installez tkinterdnd2 (pip install tkinterdnd2).")
            return
        self.drop_label.drop_target_register(DND_FILES)
        self.drop_label.dnd_bind("<<Drop>>", self._on_drop)

    def _on_drop(self, event):
        paths = parse_drop_files(event.data)
        self._add_paths(paths)

    def _add_paths(self, paths: List[Path]):
        added = 0
        for p in paths:
            if p.is_dir():
                for f in p.rglob("*"):
                    if f.suffix.lower() in SUPPORTED_EXTS and f.is_file():
                        added += self._add_one(f)
            else:
                added += self._add_one(p)
        if added:
            self._log(f"{added} fichier(s) ajouté(s).")
        else:
            self._log("Aucun fichier .doc/.docx valide n'a été ajouté.")

    def _add_one(self, p: Path) -> int:
        if not p.exists() or not p.is_file():
            return 0
        if p.suffix.lower() not in SUPPORTED_EXTS:
            return 0
        p = p.resolve()
        if p in self.files:
            return 0
        self.files.append(p)
        self.listbox.insert(tk.END, str(p))
        return 1

    def add_files(self):
        fpaths = filedialog.askopenfilenames(
            title="Choisir des fichiers DOC/DOCX",
            filetypes=[("Documents Word", "*.doc *.docx")]
        )
        self._add_paths([Path(x) for x in fpaths])

    def remove_selected(self):
        sel = list(self.listbox.curselection())
        if not sel:
            return
        for idx in reversed(sel):
            self.listbox.delete(idx)
            del self.files[idx]
        self._log("Sélection retirée.")

    def clear_list(self):
        self.listbox.delete(0, tk.END)
        self.files.clear()
        self._log("Liste vidée.")

    def choose_output_dir(self):
        d = filedialog.askdirectory(title="Choisir le dossier de sortie")
        if d:
            self.single_dir.set(d)
            self.output_mode.set("single_folder")
            self._refresh_output_state()

    def _refresh_output_state(self):
        mode = self.output_mode.get()
        self.dir_entry.configure(state="normal" if mode == "single_folder" else "disabled")

    def choose_soffice(self):
        initial = r"C:\Program Files\LibreOffice\program" if is_windows() else ""
        f = filedialog.askopenfilename(
            title="Choisir soffice.exe",
            initialdir=initial,
            filetypes=[("LibreOffice soffice", "soffice.exe" if is_windows() else "soffice"), ("Tous fichiers", "*.*")]
        )
        if f:
            self.soffice_path.set(f)

    def apply_soffice(self):
        p = self.soffice_path.get().strip()
        if not p:
            self.backend_lo = LibreOfficeBackend(None)
            self._log("Chemin LibreOffice vidé → retour à la détection automatique.")
            return
        self.backend_lo.set_soffice(p)
        ok, info = self.backend_lo.available()
        self._log(f"LibreOffice configuré : {'OK' if ok else 'NON'} — {info}")

    def _select_backend(self) -> ConverterBackend:
        mode = self.backend_mode.get()

        if mode == "word":
            ok, info = self.backend_word.available()
            if not ok:
                raise RuntimeError("Backend Word indisponible : " + info)
            return self.backend_word

        if mode == "libreoffice":
            ok, info = self.backend_lo.available()
            if not ok:
                raise RuntimeError("Backend LibreOffice indisponible : " + info)
            return self.backend_lo

        # auto
        ok_w, info_w = self.backend_word.available()
        if ok_w:
            return self.backend_word
        ok_l, info_l = self.backend_lo.available()
        if ok_l:
            return self.backend_lo

        raise RuntimeError(
            "Aucun moteur de conversion n'est disponible.\n"
            f"- Word : {info_w}\n"
            f"- LibreOffice : {info_l}\n"
            "Actions possibles :\n"
            "1) Vérifier que pywin32 est installé dans CE Python (voir Diagnostics)\n"
            "2) Ou sélectionner manuellement soffice.exe (section LibreOffice)."
        )

    def _get_out_dir_for_file(self, src: Path) -> Path:
        mode = self.output_mode.get()
        if mode == "per_file_convert":
            return src.parent / "convert"
        d = self.single_dir.get().strip()
        if not d:
            raise RuntimeError("Veuillez choisir un dossier de sortie (mode dossier unique).")
        return Path(d)

    def start_convert(self):
        if self.is_converting:
            return
        if not self.files:
            messagebox.showwarning("Aucun fichier", "Veuillez ajouter au moins un fichier .doc ou .docx.")
            return

        self.is_converting = True
        self.btn_convert.configure(state="disabled")
        self.progress.configure(text="Conversion en cours…")
        self._log("Démarrage de la conversion…")

        t = threading.Thread(target=self._convert_thread, daemon=True)
        t.start()

    def _convert_thread(self):
        ok = 0
        fail = 0
        try:
            backend = self._select_backend()
            self._log(f"Moteur utilisé : {backend.name}")

            total = len(self.files)
            for i, src in enumerate(self.files, start=1):
                try:
                    out_dir = self._get_out_dir_for_file(src)
                    self._ui_progress(f"{i}/{total} : {src.name}")
                    pdf = backend.convert_to_pdf(src, out_dir)
                    self._log(f"OK   : {src.name}  →  {pdf}")
                    ok += 1
                except Exception as e:
                    fail += 1
                    self._log(f"ECHEC: {src} ({type(e).__name__}: {e})")
                    self._log(traceback.format_exc())

        except Exception as e:
            self._log("ERREUR FATALE : " + str(e))
            self._log(traceback.format_exc())
        finally:
            self._ui_done(ok, fail)

    def _ui_progress(self, text: str):
        self.root.after(0, lambda: self.progress.configure(text=text))

    def _ui_done(self, ok: int, fail: int):
        def done():
            self.is_converting = False
            self.btn_convert.configure(state="normal")
            self.progress.configure(text=f"Terminé — OK: {ok}, Échecs: {fail}")
            if fail == 0:
                messagebox.showinfo("Conversion terminée", f"Conversion terminée.\nOK: {ok}")
            else:
                messagebox.showwarning("Conversion terminée", f"Conversion terminée.\nOK: {ok}\nÉchecs: {fail}\nConsultez le journal.")
        self.root.after(0, done)

    def _log(self, msg: str):
        self.root.after(0, lambda: self._append_log(msg))

    def _append_log(self, msg: str):
        self.log.insert(tk.END, msg + "\n")
        self.log.see(tk.END)

    def run_diagnostics(self):
        self._log("=== DIAGNOSTICS ===")
        self._log(f"Python executable : {sys.executable}")
        self._log(f"Python version    : {sys.version}")

        ok_w, info_w = self.backend_word.available()
        self._log(f"Word backend      : {'OK' if ok_w else 'NON'} — {info_w}")

        ok_l, info_l = self.backend_lo.available()
        self._log(f"LibreOffice       : {'OK' if ok_l else 'NON'} — {info_l}")

        if is_windows():
            self._log("Conseil : si Word=NON et que vous êtes sûr d'avoir pywin32, "
                      "c'est souvent que vous l'avez installé dans un autre Python que celui-ci.")
        self._log("===================")


def main():
    if TkinterDnD is not None:
        root = TkinterDnD.Tk()
    else:
        root = tk.Tk()

    try:
        ttk.Style().theme_use("clam")
    except Exception:
        pass

    App(root)
    root.geometry("980x720")
    root.mainloop()


if __name__ == "__main__":
    main()
