"""PDFApps – Auto-updater module."""

import json
import os
import sys
import tempfile
import urllib.request
from threading import Thread

from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QProgressBar,
    QMessageBox, QTextEdit,
)

from app.constants import APP_VERSION, GITHUB_REPO, ACCENT, ACCENT_H, TEXT_SEC, _LQ


_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"

# Section headings used in auto-generated release notes (build.yml)
_SECTION_MAP = {
    "## New features":          "update.section.features",
    "## Performance":           "update.section.performance",
    "## Fixes & improvements":  "update.section.fixes",
    "## Other":                 "update.section.other",
}


def _localize_notes(body: str) -> str:
    """Replace English section headings with translated ones."""
    if not body:
        return body
    from app.i18n import t
    for eng, key in _SECTION_MAP.items():
        translated = t(key)
        if translated != key:  # key exists in translations
            body = body.replace(eng, translated)
    # Strip markdown ## prefix for plain-text display
    lines = []
    for line in body.splitlines():
        if line.startswith("## "):
            lines.append(line[3:].upper())
            lines.append("")
        else:
            lines.append(line)
    return "\n".join(lines).strip()


def _parse_version(tag: str) -> tuple:
    """'v1.5.0' -> (1, 5, 0)"""
    return tuple(int(x) for x in tag.lstrip("v").split("."))


class _Signals(QObject):
    progress = Signal(int)       # 0-100
    finished = Signal(str)       # path to downloaded file
    error = Signal(str)


def is_system_install() -> bool:
    """True when running from a system package manager (AUR, Snap, Flatpak, apt, rpm)."""
    if sys.platform == "win32":
        return False
    # Sandboxed runtimes
    if os.environ.get("SNAP") or os.environ.get("FLATPAK_ID") or os.environ.get("APPIMAGE"):
        return True
    # System-wide Python (Arch AUR, Fedora rpm, Debian apt) — executable in system paths
    exe = os.path.realpath(sys.executable)
    system_prefixes = ("/usr/bin/", "/usr/local/bin/", "/usr/lib/", "/opt/")
    if exe.startswith(system_prefixes):
        return True
    return False


def check_for_update() -> dict | None:
    """Return release info dict if a newer version exists, else None."""
    # System-managed installs (AUR, Snap, Flatpak, rpm, apt) must be updated via the package manager.
    if is_system_install():
        return None
    try:
        req = urllib.request.Request(_API_URL, headers={"User-Agent": "PDFApps"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
        remote = _parse_version(data.get("tag_name", "v0"))
        local = _parse_version(APP_VERSION)
        if remote > local:
            return data
    except Exception:
        pass
    return None


def _find_asset(release: dict) -> dict | None:
    """Find the correct asset for the current platform."""
    if sys.platform == "win32":
        name = "PDFAppsSetup.exe"
    elif sys.platform == "darwin":
        name = "PDFApps-macOS.zip"
    else:
        name = "PDFApps-Linux.tar.gz"
    for asset in release.get("assets", []):
        if asset["name"] == name:
            return asset
    return None


def _get_expected_hash(release: dict, asset_name: str) -> str | None:
    """Extract SHA256 hash from release body (checksums section)."""
    body = release.get("body") or ""
    for line in body.splitlines():
        stripped = line.strip()
        # Format: "<sha256>  <filename>" or "<sha256> <filename>"
        if asset_name in stripped:
            parts = stripped.split()
            if len(parts) >= 2 and len(parts[0]) == 64:
                try:
                    int(parts[0], 16)
                    return parts[0].lower()
                except ValueError:
                    pass
    return None


def _download(url: str, dest: str, signals: _Signals, expected_hash: str | None = None):
    """Download file with progress callback and optional SHA256 verification."""
    import hashlib
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "PDFApps"})
        with urllib.request.urlopen(req, timeout=60) as resp:
            total = int(resp.headers.get("Content-Length", 0))
            downloaded = 0
            sha = hashlib.sha256()
            with open(dest, "wb") as f:
                while True:
                    chunk = resp.read(65536)
                    if not chunk:
                        break
                    f.write(chunk)
                    sha.update(chunk)
                    downloaded += len(chunk)
                    if total:
                        signals.progress.emit(int(downloaded * 100 / total))
        if expected_hash and sha.hexdigest() != expected_hash:
            raise ValueError(
                f"SHA256 mismatch: expected {expected_hash}, "
                f"got {sha.hexdigest()}"
            )
        signals.finished.emit(dest)
    except Exception as exc:
        try:
            if os.path.isfile(dest):
                os.remove(dest)
        except OSError:
            pass
        signals.error.emit(str(exc))


def _apply_update_windows(downloaded_installer: str):
    """Run the downloaded installer with admin elevation (UAC prompt)."""
    import ctypes
    ret = ctypes.windll.shell32.ShellExecuteW(
        None, "runas", downloaded_installer, None, None, 1
    )
    # ShellExecuteW returns > 32 on success; <= 32 is an error code
    if ret <= 32:
        raise OSError(f"ShellExecuteW failed (code {ret})")



def _apply_update_unix(downloaded: str):
    """Replace the running binary and restart."""
    import shutil
    import stat
    current = sys.executable
    backup = current + ".bak"
    try:
        shutil.move(current, backup)
        dest_dir = os.path.dirname(current)
        abs_dest = os.path.abspath(dest_dir)
        if downloaded.endswith(".tar.gz"):
            import tarfile
            with tarfile.open(downloaded, "r:gz") as tar:
                # Validate ALL members before extracting any
                for m in tar.getmembers():
                    if m.issym() or m.islnk():
                        raise ValueError(f"Symlink/hardlink rejected: {m.name}")
                    extracted = os.path.abspath(os.path.join(dest_dir, m.name))
                    if not extracted.startswith(abs_dest + os.sep) and extracted != abs_dest:
                        raise ValueError(f"Path traversal detected: {m.name}")
                    if m.name != "PDFApps":
                        raise ValueError(f"Unexpected member: {m.name}")
                # Safe to extract after full validation
                for m in tar.getmembers():
                    tar.extract(m, dest_dir)
        elif downloaded.endswith(".zip"):
            import zipfile
            with zipfile.ZipFile(downloaded, "r") as zf:
                # Validate ALL members before extracting any
                for info in zf.infolist():
                    extracted = os.path.abspath(os.path.join(dest_dir, info.filename))
                    if not extracted.startswith(abs_dest + os.sep) and extracted != abs_dest:
                        raise ValueError(f"Path traversal detected: {info.filename}")
                    if info.filename != "PDFApps":
                        raise ValueError(f"Unexpected member: {info.filename}")
                zf.extract("PDFApps", dest_dir)
        else:
            shutil.copy2(downloaded, current)
        os.chmod(current, os.stat(current).st_mode | stat.S_IEXEC)
        os.remove(downloaded)
        os.remove(backup)
    except Exception:
        if os.path.isfile(backup):
            shutil.move(backup, current)
        raise
    os.execv(current, sys.argv)


class UpdateDialog(QDialog):
    """Dialog that shows update progress and applies the update."""

    def __init__(self, release: dict, parent=None):
        super().__init__(parent)
        self._release = release
        self._asset = _find_asset(release)
        tag = release.get("tag_name", "?")

        from app.i18n import t as _t
        self.setWindowTitle(f"PDFApps — {_t('update.dialog_title')}")
        self.setMinimumSize(520, 420)
        self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)

        lay = QVBoxLayout(self)
        lay.setSpacing(12)

        from app.i18n import t
        self._info = QLabel(t("update.available").format(version=tag))
        self._info.setWordWrap(True)
        self._info.setStyleSheet("font-size: 13pt; font-weight: 600;")
        lay.addWidget(self._info)

        # Release notes (auto-generated body from GitHub release)
        _dark = parent._dark_mode if parent and hasattr(parent, '_dark_mode') else True
        _sec = TEXT_SEC if _dark else _LQ
        notes_lbl = QLabel(t("update.changes"))
        notes_lbl.setStyleSheet(f"color: {_sec}; font-size: 10pt;")
        lay.addWidget(notes_lbl)

        notes = _localize_notes((release.get("body") or "").strip()) or t("update.no_notes")
        self._notes = QTextEdit()
        self._notes.setReadOnly(True)
        self._notes.setPlainText(notes)
        self._notes.setMinimumHeight(180)
        lay.addWidget(self._notes, 1)

        self._progress = QProgressBar()
        self._progress.setRange(0, 100)
        self._progress.setValue(0)
        self._progress.setVisible(False)
        lay.addWidget(self._progress)

        self._status = QLabel("")
        self._status.setStyleSheet(f"color: {_sec}; font-size: 12px;")
        lay.addWidget(self._status)

        btn_row = QHBoxLayout()
        lay.addLayout(btn_row)
        btn_row.addStretch()

        self._cancel_btn = QPushButton(t("btn.cancel"))
        self._cancel_btn.clicked.connect(self.reject)
        btn_row.addWidget(self._cancel_btn)

        self._update_btn = QPushButton(t("update.install"))
        self._update_btn.setStyleSheet(
            f"background: {ACCENT}; color: white; font-weight: bold; "
            f"padding: 8px 20px; border-radius: 6px; border: none;"
        )
        self._update_btn.clicked.connect(self._start_download)
        btn_row.addWidget(self._update_btn)

        if not self._asset:
            self._update_btn.setEnabled(False)
            self._status.setText(t("update.no_asset"))

        self._signals = _Signals()
        self._signals.progress.connect(self._on_progress)
        self._signals.finished.connect(self._on_finished)
        self._signals.error.connect(self._on_error)
        self._dest = ""

    def _start_download(self):
        from app.i18n import t
        from PySide6.QtCore import QThread

        self._update_btn.setEnabled(False)
        self._cancel_btn.setEnabled(False)
        self._progress.setVisible(True)
        self._start_dots_animation(t("update.downloading"))

        # Use unique temp file to avoid permission errors from prior attempts
        suffix = os.path.splitext(self._asset["name"])[1] or ".tmp"
        fd, self._dest = tempfile.mkstemp(suffix=suffix, prefix="PDFApps_update_")
        os.close(fd)
        url = self._asset["browser_download_url"]
        expected_hash = _get_expected_hash(self._release, self._asset["name"])

        class _Worker(QObject):
            def __init__(self, url, dest, signals, expected_hash):
                super().__init__()
                self._url = url
                self._dest = dest
                self._signals = signals
                self._expected_hash = expected_hash
            def run(self):
                _download(self._url, self._dest, self._signals, self._expected_hash)

        self._dl_thread = QThread()
        self._dl_worker = _Worker(url, self._dest, self._signals, expected_hash)
        self._dl_worker.moveToThread(self._dl_thread)
        self._dl_thread.started.connect(self._dl_worker.run)
        self._signals.finished.connect(self._dl_thread.quit)
        self._signals.error.connect(self._dl_thread.quit)
        self._dl_thread.start()

    def _on_progress(self, pct: int):
        self._progress.setValue(pct)

    def _start_dots_animation(self, base_text: str):
        """Animate trailing dots: '...' cycling 1-3 dots."""
        from PySide6.QtCore import QTimer
        self._dots_base = base_text.rstrip(".")
        self._dots_count = 0
        self._dots_timer = QTimer(self)
        self._dots_timer.timeout.connect(self._tick_dots)
        self._dots_timer.start(400)

    def _tick_dots(self):
        self._dots_count = (self._dots_count % 3) + 1
        self._status.setText(self._dots_base + "." * self._dots_count)

    def _stop_dots_animation(self):
        if hasattr(self, "_dots_timer") and self._dots_timer.isActive():
            self._dots_timer.stop()

    def _on_finished(self, path: str):
        from app.i18n import t
        self._stop_dots_animation()
        self._progress.setValue(100)
        self._start_dots_animation(t("update.applying"))

        try:
            if sys.platform == "win32":
                _apply_update_windows(path)
                QMessageBox.information(
                    self, "PDFApps",
                    t("update.restart"),
                )
                import PySide6.QtWidgets as _qw
                _qw.QApplication.instance().quit()
            else:
                _apply_update_unix(path)
        except Exception as exc:
            self._on_error(str(exc))

    def _on_error(self, msg: str):
        self._stop_dots_animation()
        self._cancel_btn.setEnabled(True)
        self._update_btn.setEnabled(True)
        from app.i18n import t
        self._status.setText(t("update.error") + f" {msg}")
        self._status.setStyleSheet("color: #DC2626; font-size: 12px;")

    def closeEvent(self, event):
        """Clean up download thread if dialog is closed mid-download."""
        self._stop_dots_animation()
        if hasattr(self, "_dl_thread") and self._dl_thread.isRunning():
            self._dl_thread.quit()
            self._dl_thread.wait(3000)
        super().closeEvent(event)

    def reject(self):
        """Handle Cancel button — also cleans up thread."""
        self._stop_dots_animation()
        if hasattr(self, "_dl_thread") and self._dl_thread.isRunning():
            self._dl_thread.quit()
            self._dl_thread.wait(3000)
        super().reject()
