#!/usr/bin/python3
"""Audio DVD Creator Linux — création de DVD-Video, DVD-Audio et hybrides."""
from __future__ import annotations

import json
import math
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
from xml.sax.saxutils import quoteattr

from PySide6.QtCore import QProcess, QThread, Signal
from PySide6.QtWidgets import (
    QApplication, QComboBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel,
    QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox,
    QPushButton, QProgressBar, QPlainTextEdit, QInputDialog, QVBoxLayout, QWidget
)

APP_NAME = "Audio DVD Creator Linux"
APP_VERSION = "0.3.0"
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".wma", ".aiff", ".aif", ".ape"}
DVD_CAPACITY_BYTES = 4_300_000_000  # capacité utile prudente d'un DVD simple couche


class BuildCancelled(Exception):
    pass


def have(cmd: str) -> bool:
    return shutil.which(cmd) is not None


class BuildWorker(QThread):
    message = Signal(str)
    done = Signal(str, str)
    failed = Signal(str)
    cancelled = Signal()

    def __init__(self, tracks, output, mode, codec, bits, rate, ac3_bitrate):
        super().__init__()
        self.tracks = [Path(p).expanduser().resolve() for p in tracks]
        self.output = Path(output).expanduser().resolve()
        self.mode = mode
        self.codec = codec
        self.bits = bits
        self.rate = rate
        self.ac3_bitrate = ac3_bitrate
        self._cancel = threading.Event()
        self._process = None
        self.work = None

    def log(self, message: str) -> None:
        self.message.emit(message)

    def cancel(self) -> None:
        self._cancel.set()
        proc = self._process
        if proc is not None and proc.poll() is None:
            try:
                proc.terminate()
            except OSError:
                pass

    def check_cancelled(self) -> None:
        if self._cancel.is_set():
            raise BuildCancelled()

    def command(self, args: list[str], cwd: Path | None = None) -> None:
        self.check_cancelled()
        self.log("$ " + " ".join(str(x) for x in args))
        proc = subprocess.Popen(
            [str(x) for x in args], cwd=str(cwd) if cwd else None,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, errors="replace", bufsize=1
        )
        self._process = proc
        try:
            assert proc.stdout is not None
            for line in proc.stdout:
                self.check_cancelled()
                line = line.rstrip()
                if line:
                    self.log(line)
            code = proc.wait()
            self.check_cancelled()
            if code:
                raise RuntimeError(f"Échec (code {code}) de la commande : {args[0]}")
        finally:
            self._process = None

    def probe_track(self, src: Path) -> dict:
        if not src.is_file() or not src.exists():
            raise RuntimeError(f"Fichier introuvable : {src}")
        if src.suffix.lower() not in AUDIO_EXTS:
            raise RuntimeError(f"Format de fichier non pris en charge : {src.name}")
        p = subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries",
             "format=duration:stream=codec_type,channels,sample_rate,duration",
             "-of", "json", str(src)], capture_output=True, text=True
        )
        if p.returncode:
            raise RuntimeError(f"Fichier audio illisible : {src.name}\n{p.stderr.strip()}")
        try:
            data = json.loads(p.stdout)
            audio = next(s for s in data.get("streams", []) if s.get("codec_type") == "audio")
            duration = float(data.get("format", {}).get("duration") or audio.get("duration"))
            if not math.isfinite(duration) or duration <= 0:
                raise ValueError()
        except (ValueError, TypeError, StopIteration, KeyError):
            raise RuntimeError(f"Aucune piste audio exploitable ou durée indéterminée : {src.name}")
        return {"duration": duration, "channels": audio.get("channels"),
                "sample_rate": audio.get("sample_rate")}

    def validate_and_estimate(self) -> tuple[list[dict], int]:
        self.log("Vérification des fichiers audio…")
        details = []
        total_duration = 0.0
        for i, src in enumerate(self.tracks, 1):
            self.check_cancelled()
            info = self.probe_track(src)
            details.append(info)
            total_duration += info["duration"]
            self.log(f"{i:02d}. {src.name} — {info['duration'] / 60:.1f} min, "
                     f"{info.get('channels') or '?'} canal(aux), "
                     f"{info.get('sample_rate') or '?'} Hz")

        # Estimation prudente des données audio + flux vidéo et marges de multiplexage.
        audio_bps = self.rate * self.bits * 2 if self.mode in ("DVD-Audio", "DVD hybride") else 0
        if self.mode in ("DVD-Video", "DVD hybride"):
            video_audio_bps = (self.rate if self.rate in (48000, 96000) else 48000) * self.bits * 2 if self.codec == "PCM" else self.ac3_bitrate * 1000
            audio_bps += video_audio_bps
            audio_bps += 600_000  # vidéo MPEG-2 et multiplexage, estimation majorée
        estimate = int(total_duration * audio_bps / 8 * 1.08)
        free = shutil.disk_usage(self.output.parent).free
        if estimate > free:
            raise RuntimeError(
                f"Espace disque insuffisant pour préparer le DVD.\n"
                f"Espace libre : {free / 1e9:.2f} Go ; estimation : {estimate / 1e9:.2f} Go."
            )
        if estimate > DVD_CAPACITY_BYTES:
            self.log(f"AVERTISSEMENT : estimation {estimate / 1e9:.2f} Go, supérieure à la capacité utile d'un DVD simple couche (environ 4,3 Go).")
        else:
            self.log(f"Taille estimée : {estimate / 1e9:.2f} Go / 4,3 Go environ (DVD simple couche).")
        return details, estimate

    def make_video_zone(self, work: Path, video_root: Path, details: list[dict]) -> None:
        """Crée une VIDEO_TS PAL avec une image noire fixe par piste."""
        mpgs = []
        video_rate = self.rate if self.rate in (48000, 96000) else 48000
        for n, src in enumerate(self.tracks, 1):
            self.check_cancelled()
            duration = details[n - 1]["duration"]
            out = work / f"video_track_{n:02d}.mpg"
            self.log(f"Préparation DVD-Video : {src.name}")
            args = [
                "ffmpeg", "-hide_banner", "-y", "-i", str(src),
                "-f", "lavfi", "-i", "color=c=black:s=720x576:r=25",
                "-map", "1:v:0", "-map", "0:a:0", "-t", f"{duration:.3f}",
                "-c:v", "mpeg2video", "-pix_fmt", "yuv420p", "-b:v", "450k",
                "-maxrate", "900k", "-bufsize", "1835k"
            ]
            if self.codec == "PCM":
                sample_fmt = "s16" if self.bits == 16 else "s32"
                args += ["-c:a", "pcm_dvd", "-sample_fmt", sample_fmt,
                         "-ar", str(video_rate), "-ac", "2"]
            else:
                args += ["-c:a", "ac3", "-b:a", f"{self.ac3_bitrate}k",
                         "-ar", "48000", "-ac", "2"]
            args += ["-f", "dvd", str(out)]
            self.command(args)
            mpgs.append(out)

        xml = work / "dvdauthor.xml"
        if self.codec == "PCM":
            aud = (f'<audio format="pcm" channels="2" quant="{self.bits}bps" '
                   f'samplerate="{video_rate // 1000}khz" lang="fr" />')
        else:
            aud = '<audio format="ac3" channels="2" lang="fr" />'
        parts = [
            f'<dvdauthor dest={quoteattr(str(video_root))}><vmgm/><titleset><titles>'
            f'<video format="pal" aspect="4:3" />{aud}'
        ]
        for mpg in mpgs:
            parts.append(f'<pgc><vob file={quoteattr(str(mpg))} /></pgc>')
        parts.append('</titles></titleset></dvdauthor>')
        xml.write_text("".join(parts), encoding="utf-8")
        self.command(["dvdauthor", "-x", str(xml)])

    def make_audio_zone(self, work: Path, video_root: Path | None) -> Path:
        if not have("dvda-author"):
            raise RuntimeError(
                "Le moteur DVD-Audio 'dvda-author' est absent. Installez DVD-Audio Tools "
                "séparément ; il n'est pas fourni dans les dépôts Debian standards."
            )
        wav_dir = work / "audio_pcm"
        wav_dir.mkdir(exist_ok=True)
        wavs = []
        codec = "pcm_s16le" if self.bits == 16 else "pcm_s24le"
        for n, src in enumerate(self.tracks, 1):
            self.check_cancelled()
            dst = wav_dir / f"piste_{n:02d}.wav"
            self.log(f"Préparation DVD-Audio {self.bits} bits / {self.rate} Hz : {src.name}")
            self.command(["ffmpeg", "-hide_banner", "-y", "-i", str(src), "-map", "0:a:0",
                          "-vn", "-ac", "2", "-ar", str(self.rate), "-c:a", codec, str(dst)])
            wavs.append(dst)

        args = ["dvda-author", "-W", "--topmenu", "--mkisofs", f"--workdir={work}"]
        if video_root is not None:
            args += ["--videodir", str(video_root / "VIDEO_TS")]
        args += ["-g", *[str(wav) for wav in wavs]]
        self.command(args, cwd=work)
        candidates = [p for p in work.rglob("*.iso") if p.is_file()]
        if not candidates:
            raise RuntimeError(
                "dvda-author n'a produit aucune image ISO dans son répertoire de travail. "
                "Consultez le journal et vérifiez les outils auxiliaires de DVD-Audio Tools."
            )
        return max(candidates, key=lambda p: p.stat().st_mtime)

    def run(self):
        try:
            if not self.tracks:
                raise RuntimeError("Aucun fichier audio n'a été sélectionné.")
            if len(self.tracks) > 99:
                raise RuntimeError("La limite actuelle est de 99 pistes par projet.")
            required = ["ffmpeg", "ffprobe"]
            if self.mode in ("DVD-Video", "DVD hybride"):
                required += ["dvdauthor", "xorriso"]
            if self.mode in ("DVD-Audio", "DVD hybride"):
                required += ["dvda-author"]
            missing = [x for x in required if not have(x)]
            if missing:
                raise RuntimeError("Outils manquants : " + ", ".join(missing))
            self.output.parent.mkdir(parents=True, exist_ok=True)
            details, _ = self.validate_and_estimate()
            self.check_cancelled()
            self.work = Path(tempfile.mkdtemp(prefix="audio-dvd-creator-"))
            self.log(f"Dossier de travail : {self.work}")
            video_root = self.work / "VIDEO_TS_ROOT"
            if self.mode in ("DVD-Video", "DVD hybride"):
                video_root.mkdir()
                self.make_video_zone(self.work, video_root, details)
            staged = self.output.with_name(self.output.stem + ".partial.iso")
            if staged.exists():
                staged.unlink()
            if self.mode == "DVD-Video":
                self.command(["xorriso", "-as", "mkisofs", "-dvd-video", "-udf",
                              "-o", str(staged), str(video_root)])
            else:
                iso = self.make_audio_zone(self.work, video_root if self.mode == "DVD hybride" else None)
                shutil.copy2(iso, staged)
            if not staged.is_file() or staged.stat().st_size == 0:
                raise RuntimeError("Le fichier ISO temporaire n'a pas été créé correctement.")
            self.check_cancelled()
            size = staged.stat().st_size
            staged.replace(self.output)  # publication atomique : l'ancien ISO reste intact en cas d'échec
            self.log(f"Image créée : {size / 1e9:.2f} Go")
            shutil.rmtree(self.work, ignore_errors=True)
            self.done.emit(str(self.output), f"Image ISO créée. Taille : {size / 1e9:.2f} Go")
        except BuildCancelled:
            try:
                self.output.with_name(self.output.stem + ".partial.iso").unlink(missing_ok=True)
            except OSError:
                pass
            self.cancelled.emit()
        except Exception as exc:
            try:
                self.output.with_name(self.output.stem + ".partial.iso").unlink(missing_ok=True)
            except OSError:
                pass
            if self.work:
                self.log(f"Dossier de travail conservé pour diagnostic : {self.work}")
            self.failed.emit(str(exc))


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(f"{APP_NAME} — {APP_VERSION}")
        self.resize(980, 760)
        self.tracks: list[str] = []
        self.worker = None
        self.burn_process = None

        root = QWidget()
        self.setCentralWidget(root)
        layout = QVBoxLayout(root)
        layout.addWidget(QLabel(
            f"<h1>{APP_NAME}</h1>"
            "<p>Préparation de DVD musicaux : DVD-Video, DVD-Audio et DVD hybride</p>"
        ))

        row = QHBoxLayout()
        self.add_btn = QPushButton("Ajouter des fichiers audio…")
        self.remove_btn = QPushButton("Retirer")
        self.up_btn = QPushButton("Monter")
        self.down_btn = QPushButton("Descendre")
        for button in (self.add_btn, self.remove_btn, self.up_btn, self.down_btn):
            row.addWidget(button)
        layout.addLayout(row)

        self.list = QListWidget()
        self.list.setSelectionMode(QListWidget.ExtendedSelection)
        self.list.setAlternatingRowColors(True)
        layout.addWidget(self.list, 1)
        self.track_count = QLabel("0 piste")
        layout.addWidget(self.track_count)

        form = QFormLayout()
        self.mode = QComboBox()
        self.mode.addItems(["DVD hybride", "DVD-Video", "DVD-Audio"])
        self.codec = QComboBox()
        self.codec.addItems(["PCM", "AC3"])
        self.bits = QComboBox()
        self.bits.addItems(["16", "24"])
        self.rate = QComboBox()
        self.rate.addItems(["44100", "48000", "96000"])
        self.ac3rate = QComboBox()
        self.ac3rate.addItems(["192", "256", "320", "384", "448"])
        self.output = QLineEdit(str(Path.home() / "audio-dvd-hybride.iso"))
        browse = QPushButton("Parcourir…")
        outrow = QHBoxLayout()
        outrow.addWidget(self.output, 1)
        outrow.addWidget(browse)
        form.addRow("Format du disque :", self.mode)
        form.addRow("Encodage DVD-Video :", self.codec)
        form.addRow("Quantification PCM :", self.bits)
        form.addRow("Fréquence PCM :", self.rate)
        form.addRow("Débit AC3 (kbit/s) :", self.ac3rate)
        form.addRow("Image ISO de sortie :", outrow)
        layout.addLayout(form)

        self.info = QLabel()
        self.info.setWordWrap(True)
        self.info.setStyleSheet("color:#555")
        layout.addWidget(self.info)
        self.progress = QProgressBar()
        self.progress.setRange(0, 1)
        self.progress.setValue(0)
        self.progress.setTextVisible(False)
        layout.addWidget(self.progress)
        self.log = QPlainTextEdit()
        self.log.setReadOnly(True)
        self.log.setMaximumBlockCount(1500)
        self.log.setMaximumHeight(175)
        layout.addWidget(self.log)

        actions = QHBoxLayout()
        self.create_btn = QPushButton("Créer l'image ISO")
        self.cancel_btn = QPushButton("Annuler la création")
        self.cancel_btn.setEnabled(False)
        self.burn_btn = QPushButton("Graver une image ISO…")
        actions.addWidget(self.create_btn)
        actions.addWidget(self.cancel_btn)
        actions.addWidget(self.burn_btn)
        layout.addLayout(actions)

        self.add_btn.clicked.connect(self.add_files)
        self.remove_btn.clicked.connect(self.remove_files)
        self.up_btn.clicked.connect(lambda: self.move(-1))
        self.down_btn.clicked.connect(lambda: self.move(1))
        browse.clicked.connect(self.browse_output)
        self.mode.currentTextChanged.connect(self.update_options)
        self.codec.currentTextChanged.connect(self.update_options)
        self.rate.currentTextChanged.connect(self.update_options)
        self.create_btn.clicked.connect(self.create_iso)
        self.cancel_btn.clicked.connect(self.cancel_build)
        self.burn_btn.clicked.connect(self.burn_iso)
        self.update_options()

    def set_busy(self, busy: bool):
        for widget in (self.add_btn, self.remove_btn, self.up_btn, self.down_btn,
                       self.mode, self.codec, self.bits, self.rate, self.ac3rate,
                       self.output, self.create_btn, self.burn_btn):
            widget.setEnabled(not busy)
        self.cancel_btn.setEnabled(busy)
        self.progress.setRange(0, 0 if busy else 1)
        if not busy:
            self.progress.setValue(0)

    def add_files(self):
        files, _ = QFileDialog.getOpenFileNames(
            self, "Choisir les fichiers audio", str(Path.home()),
            "Audio (*.mp3 *.wav *.flac *.ogg *.opus *.m4a *.aac *.wma *.aiff *.ape);;Tous les fichiers (*)"
        )
        for file in files:
            if file not in self.tracks:
                self.tracks.append(file)
                item = QListWidgetItem(Path(file).name)
                item.setToolTip(file)
                self.list.addItem(item)
        self.update_track_count()

    def update_track_count(self):
        count = len(self.tracks)
        self.track_count.setText(f"{count} piste" if count == 1 else f"{count} pistes")

    def remove_files(self):
        for item in sorted(self.list.selectedItems(), key=self.list.row, reverse=True):
            index = self.list.row(item)
            self.list.takeItem(index)
            self.tracks.pop(index)
        self.update_track_count()

    def move(self, delta):
        index = self.list.currentRow()
        target = index + delta
        if 0 <= index < len(self.tracks) and 0 <= target < len(self.tracks):
            self.tracks[index], self.tracks[target] = self.tracks[target], self.tracks[index]
            item = self.list.takeItem(index)
            self.list.insertItem(target, item)
            self.list.setCurrentRow(target)

    def browse_output(self):
        file, _ = QFileDialog.getSaveFileName(
            self, "Enregistrer l'image ISO", self.output.text(), "Image DVD (*.iso)"
        )
        if file:
            self.output.setText(file if file.lower().endswith(".iso") else file + ".iso")

    def update_options(self):
        mode = self.mode.currentText()
        if mode == "DVD-Audio" and self.codec.currentText() != "PCM":
            self.codec.setCurrentText("PCM")
        is_video = mode in ("DVD-Video", "DVD hybride")
        self.codec.setEnabled(is_video)
        pcm_video = is_video and self.codec.currentText() == "PCM"
        self.bits.setEnabled(pcm_video or mode in ("DVD-Audio", "DVD hybride"))
        self.rate.setEnabled(pcm_video or mode in ("DVD-Audio", "DVD hybride"))
        self.ac3rate.setEnabled(is_video and self.codec.currentText() == "AC3")
        if mode == "DVD-Video":
            self.info.setText("DVD-Video PAL 4:3. PCM : 16/24 bits à 48 ou 96 kHz ; AC3 : 48 kHz. Une source à 44,1 kHz est rééchantillonnée à 48 kHz.")
        elif mode == "DVD-Audio":
            self.info.setText("DVD-Audio : PCM 16/24 bits, 44,1 / 48 / 96 kHz. Nécessite dvda-author et un lecteur compatible DVD-Audio.")
        else:
            self.info.setText("DVD hybride : AUDIO_TS et VIDEO_TS. La zone audio conserve la fréquence choisie ; la zone vidéo utilise 48 ou 96 kHz (44,1 kHz est converti en 48 kHz).")

    def create_iso(self):
        if not self.tracks:
            QMessageBox.warning(self, APP_NAME, "Ajoutez au moins un fichier audio.")
            return
        if len(self.tracks) > 99:
            QMessageBox.warning(self, APP_NAME, "Limite actuelle : 99 pistes par projet.")
            return
        output = Path(self.output.text()).expanduser()
        if not self.output.text().strip():
            QMessageBox.warning(self, APP_NAME, "Indiquez le nom de l'image ISO à créer.")
            return
        if output.suffix.lower() != ".iso":
            output = output.with_suffix(".iso")
            self.output.setText(str(output))
        if output.exists() and QMessageBox.question(self, APP_NAME, f"Remplacer {output} ?") != QMessageBox.Yes:
            return
        if self.mode.currentText() == "DVD-Audio" and self.codec.currentText() == "AC3":
            QMessageBox.warning(self, APP_NAME, "Le DVD-Audio utilise ici le PCM. Sélectionnez PCM.")
            return
        self.log.clear()
        self.set_busy(True)
        self.worker = BuildWorker(
            self.tracks[:], str(output), self.mode.currentText(), self.codec.currentText(),
            int(self.bits.currentText()), int(self.rate.currentText()), int(self.ac3rate.currentText())
        )
        self.worker.message.connect(self.log.appendPlainText)
        self.worker.done.connect(self.build_done)
        self.worker.failed.connect(self.build_failed)
        self.worker.cancelled.connect(self.build_cancelled)
        self.worker.start()

    def cancel_build(self):
        if self.worker and self.worker.isRunning():
            self.cancel_btn.setEnabled(False)
            self.log.appendPlainText("Demande d'annulation…")
            self.worker.cancel()

    def build_done(self, path, summary):
        self.set_busy(False)
        self.log.appendPlainText("Terminé : " + path)
        QMessageBox.information(self, APP_NAME, summary + "\n\n" + path)

    def build_failed(self, error):
        self.set_busy(False)
        self.log.appendPlainText("ERREUR : " + error)
        QMessageBox.critical(self, APP_NAME, error)

    def build_cancelled(self):
        self.set_busy(False)
        self.log.appendPlainText("Création annulée par l'utilisateur. Aucun ISO final n'est validé.")
        QMessageBox.information(self, APP_NAME, "Création annulée.")

    def burn_iso(self):
        iso, _ = QFileDialog.getOpenFileName(
            self, "Choisir l'image ISO", str(Path.home()), "Image ISO (*.iso)"
        )
        if not iso:
            return
        iso_path = Path(iso)
        if not iso_path.is_file() or iso_path.stat().st_size == 0:
            QMessageBox.warning(self, APP_NAME, "Le fichier ISO est absent ou vide.")
            return
        if not have("growisofs"):
            QMessageBox.critical(self, APP_NAME, "growisofs n'est pas installé.")
            return
        device, ok = QInputDialog.getText(
            self, APP_NAME, "Périphérique graveur (ex. /dev/sr0) :", text="/dev/sr0"
        )
        if not ok or not device.strip():
            return
        device = device.strip()
        if not device.startswith("/dev/"):
            QMessageBox.warning(self, APP_NAME, "Indiquez un périphérique de type /dev/sr0.")
            return
        answer = QMessageBox.warning(
            self, APP_NAME,
            f"La gravure va écrire sur {device}.\nLe contenu du disque présent peut être effacé.\nContinuer ?",
            QMessageBox.Yes | QMessageBox.No
        )
        if answer != QMessageBox.Yes:
            return
        self.log.appendPlainText(f"Démarrage de la gravure : {iso_path}")
        self.burn_btn.setEnabled(False)
        self.create_btn.setEnabled(False)
        self.progress.setRange(0, 0)
        self.burn_process = QProcess(self)
        self.burn_process.setProcessChannelMode(QProcess.MergedChannels)
        self.burn_process.readyReadStandardOutput.connect(self.read_burn_output)
        self.burn_process.finished.connect(self.burn_finished)
        self.burn_process.errorOccurred.connect(self.burn_error)
        self.burn_process.start("growisofs", ["-dvd-compat", "-Z", f"{device}={iso_path}"])

    def read_burn_output(self):
        if self.burn_process:
            data = bytes(self.burn_process.readAllStandardOutput()).decode("utf-8", "replace")
            if data:
                self.log.appendPlainText(data.rstrip())

    def burn_finished(self, exit_code, exit_status):
        self.read_burn_output()
        self.progress.setRange(0, 1)
        self.progress.setValue(0)
        self.burn_btn.setEnabled(True)
        self.create_btn.setEnabled(True)
        if exit_status == QProcess.NormalExit and exit_code == 0:
            QMessageBox.information(self, APP_NAME, "La gravure s'est terminée sans erreur signalée.")
        else:
            QMessageBox.critical(self, APP_NAME, f"La gravure a échoué (code {exit_code}). Consultez le journal.")

    def burn_error(self, error):
        if self.burn_process and self.burn_process.error() == QProcess.FailedToStart:
            self.progress.setRange(0, 1)
            self.progress.setValue(0)
            self.burn_btn.setEnabled(True)
            self.create_btn.setEnabled(True)
            QMessageBox.critical(self, APP_NAME, "Impossible de démarrer growisofs. Vérifiez son installation et les droits d'accès au graveur.")


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())
