"""Background QThread workers for dependency install, model download, and verification.

Completion is reported via a `done` signal, deliberately NOT named `finished`:
QThread already owns a built-in `finished` signal that fires when the OS thread
actually returns. Shadowing it would hide that lifecycle signal, which unload()
relies on to safely park a still-running worker (see park_orphaned_worker).
"""
from __future__ import annotations

from qgis.PyQt.QtCore import QThread, pyqtSignal

from ..core.i18n import tr


# Worker threads have no dock to talk to, so anything they need to explain goes
# to the QGIS log. Imported inside the call: this module loads at plugin start.
def _log_worker_warning(message: str) -> None:
    from qgis.core import Qgis, QgsMessageLog
    QgsMessageLog.logMessage(message, "AI Segmentation",
                             level=Qgis.MessageLevel.Warning)


class DepsInstallWorker(QThread):
    progress = pyqtSignal(int, str)
    done = pyqtSignal(bool, str)

    def __init__(self, predictor=None, parent=None, include_local_model: bool = True):
        super().__init__(parent)
        self._cancelled = False
        self._predictor = predictor
        # Public: the review's env gate reads it to tell an install that will
        # end in a usable local model from one that never fetched it.
        self.include_local_model = bool(include_local_model)

    def cancel(self):
        self._cancelled = True

    def _shutdown_predictor(self) -> bool:
        """Stop the loaded model before the install touches its environment.

        Same reason RemoveAiDataWorker does it, and the same shape: the
        predictor subprocess runs the venv's python.exe with native libraries
        mapped, and Windows refuses to delete a running executable or a loaded
        DLL. A rebuild reaches _cleanup_partial_venv, whose rmtree is
        ignore_errors, so without this the pure-Python files go, the binaries
        stay, and the install writes new code over stale libraries. Broken
        until QGIS restarts. On the worker thread, so the UI never freezes on
        a slow shutdown; a stuck cleanup must not block the install.
        """
        predictor = self._predictor
        self._predictor = None
        if predictor is None:
            return True
        self.progress.emit(0, tr("Stopping the local AI..."))
        try:
            import threading
            thread = threading.Thread(target=predictor.cleanup, daemon=True)
            thread.start()
            thread.join(timeout=8)
            if thread.is_alive():
                # Proceeding here would delete the environment while that
                # thread is still killing a process inside it, which is the
                # exact half-deleted tree this shutdown exists to prevent.
                _log_worker_warning(
                    "Model shutdown did not finish in time; the install was "
                    "stopped rather than delete files still held open")
                return False
        except Exception as err:  # noqa: BLE001
            # Never silent: the install is about to delete this environment,
            # and a shutdown that did not happen is the reason a later delete
            # comes back partial.
            _log_worker_warning(f"Model shutdown failed: {type(err).__name__}")
            return False
        return True

    def run(self):
        from ..core.power_inhibit import begin_keep_awake, end_keep_awake
        # A multi-GB torch install over a slow link can be interrupted by
        # system sleep if the user walks away; hold a keep-awake activity for
        # the whole install. Best-effort, always released.
        activity = begin_keep_awake("AI Segmentation dependency install")
        try:
            if not self._shutdown_predictor():
                self.done.emit(False, tr(
                    "The local AI did not stop in time, so the install was "
                    "not started. Close and reopen QGIS, then try again."))
                return
            from ..core.venv_manager import create_venv_and_install
            success, message = create_venv_and_install(
                progress_callback=lambda percent, msg: self.progress.emit(percent, msg),
                cancel_check=lambda: self._cancelled,
                include_local_model=self.include_local_model,
            )
            self.done.emit(success, message)
        except Exception as e:
            import traceback
            error_msg = f"{str(e)}\n{traceback.format_exc()}"
            self.done.emit(False, error_msg)
        finally:
            end_keep_awake(activity)


class DownloadWorker(QThread):
    progress = pyqtSignal(int, str)
    done = pyqtSignal(bool, str)

    def __init__(self, parent=None):
        super().__init__(parent)

    def run(self):
        from ..core.power_inhibit import begin_keep_awake, end_keep_awake
        # Same reason as the dependency install: a few hundred MB over a slow
        # link outlives the idle timer, and a machine that sleeps mid-transfer
        # costs the user the whole download.
        activity = begin_keep_awake("AI Segmentation model download")
        try:
            from ..core.checkpoint_manager import download_checkpoint
            success, message = download_checkpoint(
                progress_callback=lambda p, m: self.progress.emit(p, m)
            )
            self.done.emit(success, message)
        except Exception as e:
            self.done.emit(False, str(e))
        finally:
            end_keep_awake(activity)


class SetImageWorker(QThread):
    """Runs SamPredictor.set_image() off the GUI thread (PERF-01).

    Only the encode round-trip moves here. Crop extraction and everything that
    touches QGIS/canvas objects stays on the main thread; this worker just
    carries the ~3-8s stdin/stdout round-trip to the SAM subprocess off the UI
    thread so QGIS never freezes on a new crop.

    Transport safety: the predictor is a subprocess-over-pipes object whose
    stdin is written from exactly one place at a time. While this worker runs,
    the main thread holds the `_encoding_in_progress` lock and never touches the
    predictor pipe, so the JSON-RPC stream is never interleaved. The `done`
    signal carries the generation the encode started with so a stale completion
    (after a teardown that bumped the counter) is dropped by the main thread.
    """
    done = pyqtSignal(int, bool, str)  # (generation, ok, error_message)

    def __init__(self, predictor, image_np, generation: int, parent=None):
        super().__init__(parent)
        self._predictor = predictor
        self._image_np = image_np
        self._generation = generation

    def run(self):
        try:
            # set_image classifies its own transport errors and raises; the
            # main thread reproduces the sync error handling from the message.
            self._predictor.set_image(self._image_np)
            self.done.emit(self._generation, True, "")
        except Exception as e:
            # Pass the message only (matching the sync path's str(e), which the
            # corrupt-checkpoint / broken-venv classifiers read on the main
            # thread). A traceback would pollute the classifier match.
            self.done.emit(self._generation, False, str(e))


class PredictorLoadWorker(QThread):
    """Initializes the SAM predictor off the UI thread (#34)."""
    done = pyqtSignal(object, str)  # (predictor_or_None, err_msg)

    def run(self):
        try:
            from ..core.checkpoint_manager import get_checkpoint_path
            from ..core.sam_predictor import SamPredictor, build_sam_predictor_config
            sam_config = build_sam_predictor_config(checkpoint=get_checkpoint_path())
            predictor = SamPredictor(sam_config)
            self.done.emit(predictor, "")
        except Exception as e:
            import traceback
            self.done.emit(None, f"{e}\n{traceback.format_exc()}")


class StartupCheckWorker(QThread):
    """First-open environment checks, off the UI thread.

    The dock must paint instantly when the toolbar icon is clicked. Venv
    status (filesystem walks, hashing, sometimes a subprocess probe on
    Windows) and checkpoint presence run here and report back when done.
    """
    done = pyqtSignal(bool, str, bool)  # (venv_ready, message, checkpoint_ok)

    def run(self):
        try:
            from ..core.checkpoint_manager import cleanup_legacy_sam1_data
            cleanup_legacy_sam1_data()
        except Exception:
            # Logged internally; never block startup on legacy cleanup.
            pass  # nosec B110

        try:
            # Scratch files an interrupted install left in the cache. One pass
            # per start, and it never blocks the check below.
            from ..core.cache_paths import PLUGIN_CACHE_DIR
            from ..core.install_temp_sweep import sweep_stale_install_temp_files
            sweep_stale_install_temp_files(PLUGIN_CACHE_DIR)
        except Exception:
            pass  # nosec B110 - a leftover file is never worth a failed start

        try:
            from ..core.venv_manager import cleanup_old_libs, get_venv_status
            cleanup_old_libs()
            venv_ready, message = get_venv_status()
        except Exception as e:
            import traceback
            # This crash otherwise only reaches a status label + a log line
            # (no telemetry), so capture it: a startup-check failure blocks the
            # whole Manual path. Off the GUI thread; the next flush ships it.
            try:
                from ..core.telemetry_errors import report_exception
                report_exception(e, stage="install", module="background_workers")
            except Exception:  # noqa: BLE001
                pass  # nosec B110
            self.done.emit(
                False, f"startup_error: {e}\n{traceback.format_exc()}", False)
            return

        checkpoint_ok = False
        if venv_ready:
            try:
                from ..core.checkpoint_manager import checkpoint_exists
                checkpoint_ok = checkpoint_exists()
            except Exception:
                checkpoint_ok = False
        self.done.emit(venv_ready, message, checkpoint_ok)


class DeviceInfoWorker(QThread):
    """Logs compute-device info off the UI thread (diagnostics only).

    Emits ok=False with the error text when PyTorch cannot load (Windows
    DLL error) so the plugin can show the fix-it dialog on the main thread.
    """
    done = pyqtSignal(bool, str)  # (ok, info_or_error)

    def run(self):
        try:
            from ..core.venv_manager import ensure_venv_packages_available
            ensure_venv_packages_available()
            from ..core.device_manager import get_device_info
            info = get_device_info()
            # Pre-import the crop stack while we are already off-thread: the
            # first Manual crop extraction otherwise pays the cold rasterio
            # import (0.5-2s of native-lib loading, worse behind antivirus) ON
            # the GUI thread at session start. Importing here makes that first
            # extraction a plain windowed read. Best-effort: GDAL-only setups
            # extract through GDAL instead, so a missing rasterio is fine.
            try:
                import rasterio  # noqa: F401
            except Exception:  # noqa: BLE001
                pass  # nosec B110
            self.done.emit(True, str(info or ""))
        except RuntimeError as e:
            self.done.emit(False, str(e))
        except Exception as e:
            self.done.emit(True, f"device_info_unavailable: {e}")


class RemoveAiDataWorker(QThread):
    """Deletes the downloaded AI data (isolated environment + weights).

    The tree is multi-GB and holds tens of thousands of files, so both the size
    walk and the delete run here: on the GUI thread they froze the window for
    the whole removal. Nothing Qt-owned is touched. The caller takes the
    cross-process install lock BEFORE starting this worker and releases it only
    after `done`, so the tree cannot be installed into while it disappears.

    The loaded model subprocess is shut down first: its open handles keep the
    environment's files locked on Windows, which would leave the delete partial.
    """
    progress = pyqtSignal(str)
    # (nothing_left, freed_label, error_detail)
    done = pyqtSignal(bool, str, str)

    def __init__(self, predictor=None, parent=None):
        super().__init__(parent)
        self._cancelled = False
        self._predictor = predictor

    def cancel(self):
        self._cancelled = True

    def run(self):
        import os

        predictor = self._predictor
        self._predictor = None
        if predictor is not None:
            self.progress.emit(tr("Stopping the local AI..."))
            try:
                import threading
                thread = threading.Thread(target=predictor.cleanup, daemon=True)
                thread.start()
                thread.join(timeout=8)
                if thread.is_alive():
                    # Same stop as DepsInstallWorker._shutdown_predictor, for
                    # the same reason: that thread is still killing a process
                    # whose executable and libraries live in the tree below.
                    # Windows refuses to delete either, so the delete comes back
                    # half done and reports leftovers the user cannot clear.
                    _log_worker_warning(
                        "Model shutdown did not finish in time; the removal was "
                        "stopped rather than delete files still held open")
                    # The detail is a log and a flag, never shown as written:
                    # the caller turns any error into its own sentence.
                    self.done.emit(
                        False, "", "the local AI did not stop; nothing deleted")
                    return
            except Exception:  # noqa: BLE001
                pass  # nosec B110 - a stuck cleanup must not block the delete

        freed = ""
        try:
            from ..core.cache_paths import PLUGIN_CACHE_DIR
            from ..core.venv_manager import purge_cache_dir
            from .plugin.shared import dir_size_label
            if not os.path.isdir(PLUGIN_CACHE_DIR):
                self.done.emit(True, "", "")
                return
            self.progress.emit(tr("Measuring the downloaded data..."))
            freed = dir_size_label(PLUGIN_CACHE_DIR)
            self.progress.emit(tr("Deleting the downloaded data..."))
            nothing_left = purge_cache_dir(cancel_check=lambda: self._cancelled)
        except Exception as e:  # noqa: BLE001
            self.done.emit(False, freed, str(e)[:80])
            return
        if self._cancelled:
            # Stopped part way, so the tree is half gone: no size is claimed as
            # freed, and the detail flags the removal as unfinished. Same as the
            # shutdown stop above, it is a log line the caller never shows as
            # written.
            self.done.emit(
                False, "", "the removal was stopped; the data is partly deleted")
            return
        self.done.emit(bool(nothing_left), freed, "")


class VerifyWorker(QThread):
    """Runs venv verification + device detection off the main thread.

    ``include_local_model`` must match the install that just ran. False skips
    both the local-model package checks and the device probe, which reads the
    same packages: on an environment that never fetched them the probe can
    only fail, and its failure would be reported as a broken install.
    """
    done = pyqtSignal(bool, str)  # (is_valid, message)
    progress = pyqtSignal(int, str)   # (percent, message)

    def __init__(self, include_local_model: bool = True, parent=None):
        super().__init__(parent)
        self.include_local_model = bool(include_local_model)
        self._cancelled = False

    def cancel(self):
        """Ask the verification to stop at its next check."""
        self._cancelled = True

    def run(self):
        try:
            from ..core.venv_manager import verify_venv
            is_valid, msg = verify_venv(
                progress_callback=lambda pct, m: self.progress.emit(pct, m),
                include_local_model=self.include_local_model,
                cancel_check=lambda: self._cancelled)
            if not is_valid:
                self.done.emit(False, msg)
                return
            if self._cancelled:
                self.done.emit(False, "Installation cancelled")
                return
            if not self.include_local_model:
                self.done.emit(True, "")
                return
            self.progress.emit(100, tr("Detecting device..."))
            try:
                from ..core.venv_manager import ensure_venv_packages_available
                ensure_venv_packages_available()
                from ..core.device_manager import get_device_info
                info = get_device_info()
                self.done.emit(True, info or "")
            except Exception as e:
                self.done.emit(True, f"device_error: {str(e)}")
        except Exception as e:
            self.done.emit(False, str(e))
