"""Background service that periodically checks for new releases."""

import asyncio
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

import httpx

from wled_controller import __version__
from wled_controller.core.update.install_type import InstallType, detect_install_type, get_platform_asset_pattern
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
from wled_controller.core.update.version_check import is_newer, normalize_version
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger

logger = get_logger(__name__)

DEFAULT_SETTINGS: dict[str, Any] = {
    "enabled": True,
    "check_interval_hours": 24.0,
    "include_prerelease": False,
}

_STARTUP_DELAY_S = 30
_MANUAL_CHECK_DEBOUNCE_S = 60


class UpdateService:
    """Periodically polls a ReleaseProvider and fires WebSocket events."""

    def __init__(
        self,
        provider: ReleaseProvider,
        db: Database,
        fire_event: Any = None,
        update_dir: Path | None = None,
    ) -> None:
        self._provider = provider
        self._db = db
        self._fire_event = fire_event

        self._settings = self._load_settings()
        self._task: asyncio.Task | None = None

        # Install type (detected once)
        self._install_type = detect_install_type()
        self._asset_pattern = get_platform_asset_pattern(self._install_type)

        # Download directory
        self._update_dir = update_dir or Path("data/updates")
        self._update_dir.mkdir(parents=True, exist_ok=True)

        # Runtime state
        self._available_release: ReleaseInfo | None = None
        self._last_check: float = 0.0
        self._checking = False
        self._last_error: str | None = None

        # Download/apply state
        self._download_progress: float = 0.0  # 0..1
        self._downloading = False
        self._downloaded_file: Path | None = None
        self._applying = False

        # Load persisted state
        persisted = self._db.get_setting("update_state") or {}
        self._dismissed_version: str = persisted.get("dismissed_version", "")

    # ── Settings persistence ───────────────────────────────────

    def _load_settings(self) -> dict:
        data = self._db.get_setting("auto_update")
        if data:
            return {**DEFAULT_SETTINGS, **data}
        return dict(DEFAULT_SETTINGS)

    def _save_settings(self) -> None:
        self._db.set_setting("auto_update", {
            "enabled": self._settings["enabled"],
            "check_interval_hours": self._settings["check_interval_hours"],
            "include_prerelease": self._settings["include_prerelease"],
        })

    def _save_state(self) -> None:
        self._db.set_setting("update_state", {
            "dismissed_version": self._dismissed_version,
        })

    # ── Lifecycle ──────────────────────────────────────────────

    async def start(self) -> None:
        if self._settings["enabled"]:
            self._start_loop()
            logger.info(
                "Update checker started (every %.1fh, prerelease=%s, install=%s)",
                self._settings["check_interval_hours"],
                self._settings["include_prerelease"],
                self._install_type.value,
            )
        else:
            logger.info("Update checker initialized (disabled, install=%s)", self._install_type.value)

    async def stop(self) -> None:
        self._cancel_loop()
        logger.info("Update checker stopped")

    def _start_loop(self) -> None:
        self._cancel_loop()
        self._task = asyncio.create_task(self._check_loop())

    def _cancel_loop(self) -> None:
        if self._task is not None:
            self._task.cancel()
            self._task = None

    async def _check_loop(self) -> None:
        try:
            await asyncio.sleep(_STARTUP_DELAY_S)
            await self._perform_check()

            interval_s = self._settings["check_interval_hours"] * 3600
            while True:
                await asyncio.sleep(interval_s)
                try:
                    await self._perform_check()
                except Exception as exc:
                    logger.error("Update check failed: %s", exc, exc_info=True)
        except asyncio.CancelledError:
            logger.debug("Update check loop cancelled")
            pass

    # ── Core check logic ───────────────────────────────────────

    async def _perform_check(self) -> None:
        self._checking = True
        self._last_error = None
        try:
            releases = await self._provider.get_releases(limit=10)
            best = self._find_best_release(releases)
            self._available_release = best
            self._last_check = time.time()

            if best and self._fire_event:
                self._fire_event({
                    "type": "update_available",
                    "version": best.version,
                    "tag": best.tag,
                    "name": best.name,
                    "prerelease": best.prerelease,
                    "dismissed": best.version == self._dismissed_version,
                    "can_auto_update": self._can_auto_update(best),
                })
            logger.info(
                "Update check complete — %s",
                f"v{best.version} available" if best else "up to date",
            )
        except Exception as exc:
            self._last_error = str(exc)
            raise
        finally:
            self._checking = False

    def _find_best_release(self, releases: list[ReleaseInfo]) -> ReleaseInfo | None:
        """Find the newest release that is newer than the current version."""
        include_pre = self._settings["include_prerelease"]
        for release in releases:
            if release.prerelease and not include_pre:
                continue
            try:
                normalize_version(release.version)
            except Exception as e:
                logger.debug("Skipping release with unparseable version %s: %s", release.version, e)
                continue
            if is_newer(release.version, __version__):
                return release
        return None

    def _find_asset(self, release: ReleaseInfo) -> AssetInfo | None:
        """Find the matching asset for this platform + install type."""
        if not self._asset_pattern:
            return None
        for asset in release.assets:
            if self._asset_pattern in asset.name:
                return asset
        return None

    def _can_auto_update(self, release: ReleaseInfo) -> bool:
        """Check if auto-update is possible for the given release."""
        return self._find_asset(release) is not None

    # ── Download ───────────────────────────────────────────────

    async def download_update(self) -> Path:
        """Download the update asset. Returns path to downloaded file."""
        release = self._available_release
        if not release:
            raise RuntimeError("No update available")

        asset = self._find_asset(release)
        if not asset:
            raise RuntimeError(
                f"No matching asset for {self._install_type.value} "
                f"on {sys.platform}"
            )

        dest = self._update_dir / asset.name
        # Skip re-download if file exists and size matches
        if dest.exists() and dest.stat().st_size == asset.size:
            logger.info("Update already downloaded: %s", dest.name)
            self._downloaded_file = dest
            self._download_progress = 1.0
            return dest

        self._downloading = True
        self._download_progress = 0.0
        try:
            await self._stream_download(asset.download_url, dest, asset.size)
            self._downloaded_file = dest
            logger.info("Downloaded update: %s (%d bytes)", dest.name, dest.stat().st_size)
            return dest
        except Exception:
            # Clean up partial download
            if dest.exists():
                dest.unlink()
            self._downloaded_file = None
            raise
        finally:
            self._downloading = False

    async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
        """Stream-download a file, updating progress as we go."""
        tmp = dest.with_suffix(dest.suffix + ".tmp")
        received = 0
        async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
            async with client.stream("GET", url) as resp:
                resp.raise_for_status()
                with open(tmp, "wb") as f:
                    async for chunk in resp.aiter_bytes(chunk_size=65536):
                        f.write(chunk)
                        received += len(chunk)
                        if total_size > 0:
                            self._download_progress = received / total_size
                            if self._fire_event:
                                self._fire_event({
                                    "type": "update_download_progress",
                                    "progress": round(self._download_progress, 3),
                                })
        # Atomic rename
        tmp.replace(dest)
        self._download_progress = 1.0

    # ── Apply ──────────────────────────────────────────────────

    async def apply_update(self) -> None:
        """Download (if needed) and apply the update, then shut down."""
        if self._applying:
            raise RuntimeError("Update already in progress")
        self._applying = True
        try:
            if not self._downloaded_file or not self._downloaded_file.exists():
                await self.download_update()

            assert self._downloaded_file is not None
            file_path = self._downloaded_file

            if self._install_type == InstallType.INSTALLER:
                await self._apply_installer(file_path)
            elif self._install_type == InstallType.PORTABLE:
                if file_path.suffix == ".zip":
                    await self._apply_portable_zip(file_path)
                elif file_path.name.endswith(".tar.gz"):
                    await self._apply_portable_tarball(file_path)
                else:
                    raise RuntimeError(f"Unknown portable format: {file_path.name}")
            else:
                raise RuntimeError(f"Auto-update not supported for install type: {self._install_type.value}")
        finally:
            self._applying = False

    async def _apply_installer(self, exe_path: Path) -> None:
        """Launch the NSIS installer silently and shut down."""
        install_dir = str(Path.cwd())
        logger.info("Launching silent installer: %s /S /D=%s", exe_path, install_dir)

        # Fire event so frontend shows restart overlay
        if self._fire_event:
            self._fire_event({"type": "server_restarting"})

        # Launch installer detached — it will wait for python.exe to exit,
        # then install and the VBS launcher / service will restart the app.
        subprocess.Popen(
            [str(exe_path), "/S", f"/D={install_dir}"],
            creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
            if sys.platform == "win32" else 0,
        )

        # Give the installer a moment to start, then shut down
        await asyncio.sleep(1)
        from wled_controller.server_ref import request_shutdown
        request_shutdown()

    async def _apply_portable_zip(self, zip_path: Path) -> None:
        """Extract ZIP over the current installation, then shut down."""
        import zipfile

        app_root = Path.cwd()
        staging = self._update_dir / "_staging"

        logger.info("Extracting portable update: %s", zip_path.name)

        # Extract to staging dir in a thread (I/O bound)
        def _extract():
            if staging.exists():
                shutil.rmtree(staging)
            staging.mkdir(parents=True)
            with zipfile.ZipFile(zip_path, "r") as zf:
                for member in zf.namelist():
                    target = (staging / member).resolve()
                    if not target.is_relative_to(staging.resolve()):
                        raise ValueError(f"Zip entry escapes target directory: {member}")
                zf.extractall(staging)

        await asyncio.to_thread(_extract)

        # The ZIP contains a top-level LedGrab/ dir — find it
        inner = _find_single_child_dir(staging)

        # Write a post-update script that swaps the dirs after shutdown.
        # On Windows, python.exe locks files, so we need a bat script
        # that waits for the process to exit, then does the swap.
        script = self._update_dir / "_apply_update.bat"
        script.write_text(
            _build_swap_script(inner, app_root, ["app", "python", "scripts"]),
            encoding="utf-8",
        )

        logger.info("Launching post-update script and shutting down")
        if self._fire_event:
            self._fire_event({"type": "server_restarting"})

        subprocess.Popen(
            ["cmd.exe", "/c", str(script)],
            creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
            if sys.platform == "win32" else 0,
        )

        await asyncio.sleep(1)
        from wled_controller.server_ref import request_shutdown
        request_shutdown()

    async def _apply_portable_tarball(self, tar_path: Path) -> None:
        """Extract tarball over the current installation, then shut down."""
        import tarfile

        app_root = Path.cwd()
        staging = self._update_dir / "_staging"

        logger.info("Extracting portable update: %s", tar_path.name)

        def _extract():
            if staging.exists():
                shutil.rmtree(staging)
            staging.mkdir(parents=True)
            with tarfile.open(tar_path, "r:gz") as tf:
                tf.extractall(staging, filter="data")

        await asyncio.to_thread(_extract)

        inner = _find_single_child_dir(staging)

        # On Linux, write a shell script that replaces dirs after shutdown
        script = self._update_dir / "_apply_update.sh"
        dirs_to_swap = ["app", "venv"]
        lines = [
            "#!/bin/bash",
            "# Auto-generated update script — replaces app dirs and restarts",
            f'APP_ROOT="{app_root}"',
            f'STAGING="{inner}"',
            "sleep 3  # wait for server to exit",
        ]
        for d in dirs_to_swap:
            lines.append(f'[ -d "$STAGING/{d}" ] && rm -rf "$APP_ROOT/{d}" && mv "$STAGING/{d}" "$APP_ROOT/{d}"')
        # Copy scripts/ and run.sh if present
        lines.append('[ -f "$STAGING/run.sh" ] && cp "$STAGING/run.sh" "$APP_ROOT/run.sh"')
        lines.append(f'rm -rf "{staging}"')
        lines.append(f'rm -f "{script}"')
        lines.append('echo "Update applied. Restarting..."')
        lines.append('cd "$APP_ROOT" && exec ./run.sh')
        script.write_text("\n".join(lines) + "\n", encoding="utf-8")
        os.chmod(script, 0o755)

        logger.info("Launching post-update script and shutting down")
        if self._fire_event:
            self._fire_event({"type": "server_restarting"})

        subprocess.Popen(
            ["/bin/bash", str(script)],
            start_new_session=True,
        )

        await asyncio.sleep(1)
        from wled_controller.server_ref import request_shutdown
        request_shutdown()

    # ── Public API (called from routes) ────────────────────────

    async def check_now(self) -> dict:
        """Trigger an immediate check (with debounce)."""
        elapsed = time.time() - self._last_check
        if elapsed < _MANUAL_CHECK_DEBOUNCE_S and self._available_release is not None:
            return self.get_status()
        await self._perform_check()
        return self.get_status()

    def dismiss(self, version: str) -> None:
        """Dismiss the notification for *version*."""
        self._dismissed_version = version
        self._save_state()

    def get_status(self) -> dict:
        rel = self._available_release
        can_auto = rel is not None and self._can_auto_update(rel)
        return {
            "current_version": __version__,
            "has_update": rel is not None,
            "checking": self._checking,
            "last_check": self._last_check if self._last_check else None,
            "last_error": self._last_error,
            "releases_url": self._provider.get_releases_page_url(),
            "install_type": self._install_type.value,
            "can_auto_update": can_auto,
            "downloading": self._downloading,
            "download_progress": round(self._download_progress, 3),
            "applying": self._applying,
            "release": {
                "version": rel.version,
                "tag": rel.tag,
                "name": rel.name,
                "body": rel.body,
                "prerelease": rel.prerelease,
                "published_at": rel.published_at,
            } if rel else None,
            "dismissed_version": self._dismissed_version,
        }

    def get_settings(self) -> dict:
        return {
            "enabled": self._settings["enabled"],
            "check_interval_hours": self._settings["check_interval_hours"],
            "include_prerelease": self._settings["include_prerelease"],
        }

    async def update_settings(
        self,
        enabled: bool,
        check_interval_hours: float,
        include_prerelease: bool,
    ) -> dict:
        self._settings["enabled"] = enabled
        self._settings["check_interval_hours"] = check_interval_hours
        self._settings["include_prerelease"] = include_prerelease
        self._save_settings()

        if enabled:
            self._start_loop()
            logger.info(
                "Update checker enabled (every %.1fh, prerelease=%s)",
                check_interval_hours,
                include_prerelease,
            )
        else:
            self._cancel_loop()
            logger.info("Update checker disabled")

        return self.get_settings()


# ── Helpers ──────────────────────────────────────────────────


def _find_single_child_dir(parent: Path) -> Path:
    """Return the single subdirectory inside *parent* (e.g. LedGrab/)."""
    children = [c for c in parent.iterdir() if c.is_dir()]
    if len(children) == 1:
        return children[0]
    return parent


def _build_swap_script(staging: Path, app_root: Path, dirs: list[str]) -> str:
    """Build a Windows batch script that replaces dirs after the server exits."""
    lines = [
        "@echo off",
        "REM Auto-generated update script — replaces app dirs and restarts",
        "echo Waiting for server to exit...",
        "timeout /t 5 /nobreak >nul",
    ]
    for d in dirs:
        src = staging / d
        dst = app_root / d
        lines.append(f'if exist "{src}" (')
        lines.append(f'    rmdir /s /q "{dst}" 2>nul')
        lines.append(f'    move /y "{src}" "{dst}"')
        lines.append(")")
    # Copy LedGrab.bat if present
    bat = staging / "LedGrab.bat"
    lines.append(f'if exist "{bat}" copy /y "{bat}" "{app_root / "LedGrab.bat"}"')
    # Cleanup
    lines.append(f'rmdir /s /q "{staging.parent}" 2>nul')
    lines.append('del /f /q "%~f0" 2>nul')
    lines.append('echo Update complete. Restarting...')
    # Restart via VBS launcher or bat
    vbs = app_root / "scripts" / "start-hidden.vbs"
    bat_launcher = app_root / "LedGrab.bat"
    lines.append(f'if exist "{vbs}" (')
    lines.append(f'    start "" wscript.exe "{vbs}"')
    lines.append(") else (")
    lines.append(f'    start "" "{bat_launcher}"')
    lines.append(")")
    return "\r\n".join(lines) + "\r\n"
