"""Automatic mode flow: mode switching, credits, warmup, detail/grid math.

Part of AISegmentationPlugin (see ai_segmentation_plugin.py); split out
so agents and humans can work on one concern per file. Methods here are
plain mixin members: state lives on the plugin instance (self).
"""
from __future__ import annotations

from qgis.core import (
    QgsProject,
)

from ...core.i18n import tr
from ...core.qt_compat import DistanceMeters
from ...core.telemetry import slot_guard
from .shared import (
    _WEBMERC_MUPP_Z0,
)


class AutoFlowMixin:
    """Automatic mode flow: mode switching, credits, warmup, detail/grid math."""

    def _on_mode_changed(self, mode) -> None:
        from ..ai_segmentation_dockwidget import Mode
        # Refine-in-Manual handoff: preserve both the held _auto_review and the
        # manual session across the Manual<->Auto hop, so the usual destructive
        # reset below MUST be skipped. The flag stays set for the whole round trip
        # (it is cleared only on Finish / Exit / discard).
        if self._refine_handoff_active:
            if mode == Mode.INTERACTIVE:
                self._ensure_interactive_setup()
                self._enter_manual_refine_session()
            else:  # back to Automatic review
                self._restore_auto_review_after_handoff()
            return
        # Capture the pre-reset state for telemetry (the resets below clear it).
        to_mode = "auto" if mode == Mode.AUTOMATIC else "manual"
        had_unsaved_manual = bool(
            getattr(self, "current_mask", None) is not None
            or getattr(self, "_frozen_sessions", None)
            or getattr(self, "_unfrozen_display_polygon", None) is not None
            or getattr(self, "saved_polygons", None)
        )
        auto_step = None
        try:
            auto_step = int(self.dock_widget.auto_steps.currentIndex())
        except (RuntimeError, AttributeError):
            pass
        # Switching modes returns each flow to its pre-Start base state, so no
        # mid-flow state (a drawn zone, a pending review, a running detection)
        # leaks across the switch. The Automatic reset is always safe (its
        # results are transient or already exported); the Manual reset is skipped
        # when there is unsaved manual work, to never silently discard it.
        self._reset_auto_flow_to_start()
        self._reset_manual_flow_to_start()
        if mode == Mode.AUTOMATIC and self.dock_widget:
            self._refresh_auto_credits()
        elif mode == Mode.INTERACTIVE and self.dock_widget:
            self._ensure_interactive_setup()
        try:
            from ...core import telemetry
            telemetry.track_mode_switched(
                to_mode=to_mode,
                had_unsaved_manual=had_unsaved_manual,
                auto_step=auto_step,
            )
        except Exception:
            pass  # nosec B110

    def _reset_auto_flow_to_start(self) -> None:
        """Return the Automatic flow to its pre-Start base: stop any run, drop
        the zone / review / exemplars / canvas visuals, and land on the Start
        step. Idempotent, so it is safe to call when nothing is active."""
        self._stop_auto_detection()  # hard teardown of a live worker; no-op if idle
        self._discard_auto_review()
        self._restore_maptool_after_zone()
        self._auto_zone = None
        self._auto_zone_polygon = None
        self._clear_auto_canvas()
        if self.dock_widget:
            try:
                self.dock_widget.reset_auto_to_start()
            except (RuntimeError, AttributeError):
                pass

    def _reset_manual_flow_to_start(self) -> None:
        """Return the Manual flow to its pre-Start base when switching modes.

        Skips the reset when there is unsaved manual work (a live mask, frozen
        sessions, an unfrozen polygon, or saved-but-unexported polygons), so a
        mode toggle never silently discards it; the user stops Manual explicitly
        in that case. Exported layers are always untouched."""
        # getattr defaults: the frozen/unfrozen fields only exist once a manual
        # session has started, and a mode switch can fire before that.
        has_unsaved = (
            getattr(self, "current_mask", None) is not None
            or getattr(self, "_frozen_sessions", None)  # noqa: W503
            or getattr(self, "_unfrozen_display_polygon", None) is not None  # noqa: W503
            or getattr(self, "saved_polygons", None)  # noqa: W503
        )
        if has_unsaved:
            return
        try:
            canvas = self.iface.mapCanvas()
            tool = getattr(self, "map_tool", None)
            if tool is not None and canvas.mapTool() == tool:
                canvas.unsetMapTool(tool)
        except (RuntimeError, AttributeError):
            pass
        self._reset_session()
        if self.dock_widget:
            try:
                self.dock_widget.reset_session()
            except (RuntimeError, AttributeError):
                pass

    def _refresh_auto_credits(self) -> None:
        """Fetch usage/credits off the main thread and reflect them in the dock."""
        from ...core.activation_manager import get_auth_header, is_plugin_activated
        if not self.dock_widget or not is_plugin_activated():
            return
        if self._usage_fetch_task is not None and self._usage_fetch_task.is_active():
            return
        auth = get_auth_header()
        if not auth:
            return
        from qgis.core import QgsApplication

        from ...api.terralab_client import TerraLabClient
        from ...workers.generic_request_task import GenericRequestTask
        client = TerraLabClient()
        self._usage_fetch_task = GenericRequestTask(
            tr("Refreshing credits"),
            lambda: client.get_usage(auth=auth),
            hidden=True,
        )
        self._usage_fetch_task.succeeded.connect(self._on_usage_fetched)
        # A usage error (network or server) keeps the dock's last figures rather
        # than blanking the ring; matches the old "empty dict on error" contract.
        # A lapsed subscription (failed payment) is called out clearly instead.
        self._usage_fetch_task.failed.connect(self._on_usage_failed)
        QgsApplication.taskManager().addTask(self._usage_fetch_task)

    def _on_usage_failed(self, message: str = "", code: str = "") -> None:
        """Credits refresh failed. Keep the last-known figures (never blank the
        ring on a transient error). If the server reports the subscription is no
        longer active (a failed renewal payment: the key still authenticates
        locally but every call is rejected), surface a clear one-time notice
        pointing at the account page, so the user is not left with stale credits
        and a mysterious failed run. Throttled to once per session."""
        self._usage_fetch_task = None
        if code == "SUBSCRIPTION_INACTIVE" and not self._billing_warning_shown:
            self._billing_warning_shown = True
            try:
                from qgis.core import Qgis
                self.iface.messageBar().pushMessage(
                    "AI Segmentation",
                    tr("There's a problem with your subscription. Open Settings "
                       "to update your payment method or review your plan."),
                    level=Qgis.MessageLevel.Warning,
                )
            except (RuntimeError, AttributeError):
                pass

    def _on_usage_fetched(self, usage: dict) -> None:
        """Main thread: push fetched usage data into the dock's credit display."""
        self._usage_fetch_task = None
        if not usage or not self.dock_widget:
            return
        # Cache the last-known usage so run telemetry can report credits_before /
        # is_free_tier without an extra fetch on the critical path. Capture the
        # previous free balance first so a drop can emit free_taste_consumed.
        prev_free = (self._last_usage or {}).get("free_detections_remaining")
        self._last_usage = dict(usage)
        is_free = usage.get("is_free_tier", True)
        free_left = usage.get("free_detections_remaining")
        if (is_free and prev_free is not None and free_left is not None
                and free_left < prev_free):
            try:
                from ...core import telemetry
                telemetry.track_free_taste_consumed(remaining=free_left)
            except Exception:
                pass  # nosec B110
        if is_free:
            credits = free_left if free_left is not None else 0
            # Lifetime free-taste allowance. Newer servers echo the total; older
            # deploys omit it, so fall back to the known lifetime cap (300 since
            # 2026-07-04) for the ring gauge. Keep in sync with the server cap.
            total = int(usage.get("free_detections_total") or 300)
        else:
            used = usage.get("images_used", 0) or 0
            limit = usage.get("images_limit", 0) or 0
            credits = max(0, limit - used)
            total = limit or None
        reset_date = usage.get("period_end") or ""
        self.dock_widget.set_auto_credits(credits, reset_date,
                                          is_subscriber=not is_free,
                                          total=total)

    def _auto_credit_snapshot(self) -> tuple[int | None, bool]:
        """Return (credits_before, is_free_tier) from the last fetched usage.

        Best-effort for telemetry: (None, True) when usage was never fetched."""
        usage = self._last_usage or {}
        is_free = bool(usage.get("is_free_tier", True))
        if is_free:
            credits = usage.get("free_detections_remaining")
        else:
            used = usage.get("images_used", 0) or 0
            limit = usage.get("images_limit", 0) or 0
            credits = max(0, limit - used)
        return credits, is_free

    def _auto_zone_area_km2(self) -> float:
        """Geodesic area (km2) of the active run's clip polygon; 0.0 if unknown."""
        try:
            from qgis.core import QgsCoordinateReferenceSystem, QgsDistanceArea
            poly = getattr(self, "_auto_clip_polygon", None)
            if poly is None or poly.isEmpty():
                return 0.0
            da = QgsDistanceArea()
            ctx = QgsProject.instance().transformContext()
            crs = (QgsCoordinateReferenceSystem(self._auto_crs_authid)
                   if self._auto_crs_authid else None)
            if crs is not None and crs.isValid():
                da.setSourceCrs(crs, ctx)
            da.setEllipsoid(QgsProject.instance().ellipsoid() or "WGS84")
            return max(0.0, da.measureArea(poly) / 1_000_000.0)
        except Exception:
            return 0.0

    def _auto_duration_ms(self) -> int:
        """Elapsed ms since the detection phase started (0 if not started)."""
        try:
            import time as _time
            if not self._auto_detect_t0:
                return 0
            return int((_time.monotonic() - self._auto_detect_t0) * 1000)
        except Exception:
            return 0

    @staticmethod
    def _classify_auto_error(msg: str) -> str:
        """Map an auto-detection error message to a telemetry error_class enum:
        NETWORK / AUTH / CREDITS_EXHAUSTED / SERVER / CANCELLED / TIMEOUT / UNKNOWN."""
        low = (msg or "").lower()
        if "credit" in low or "quota" in low or "402" in low:
            return "CREDITS_EXHAUSTED"
        if "auth" in low or "401" in low or "403" in low or "sign in" in low:
            return "AUTH"
        if "timeout" in low or "timed out" in low:
            return "TIMEOUT"
        if "cancel" in low:
            return "CANCELLED"
        if any(t in low for t in ("network", "connection", "connect", "ssl", "dns",
                                  "unreachable", "offline")):
            return "NETWORK"
        if any(t in low for t in ("server", "500", "502", "503", "504", "bad gateway")):
            return "SERVER"
        return "UNKNOWN"

    def _track_manual_run_failed(self) -> None:
        """Emit an unsampled segmentation_run(success=False) on a manual predict error."""
        try:
            import time as _time
            from ...core import telemetry
            start_ts = getattr(self, "_segmentation_start_ts", None)
            duration_ms = int((_time.time() - start_ts) * 1000) if start_ts else None
            telemetry.track_segmentation_run(success=False, duration_ms=duration_ms)
        except Exception:
            pass  # nosec B110

    # Minimum gap between cold-start pings: one warmup keeps the backend hot
    # well past this, so re-firing on every step/zone event is wasteful.
    _WARMUP_MIN_INTERVAL_S = 30.0

    def _maybe_warmup_auto(self) -> None:
        """Best-effort cloud cold-start ping, off the GUI thread.

        Fired when the user enters the Automatic flow (Start clicked, which
        opens the draw-zone step) and again when the zone is drawn, so the
        idle backend is warming up by the time they hit Detect.
        Debounced to at most once per ~30s and gated on cloud access (an
        activated key); every failure is silent. Never blocks the main thread.
        """
        import time

        from ...core.activation_manager import get_auth_header, is_plugin_activated

        # Only warmup for users who can actually use the cloud Automatic flow.
        if not is_plugin_activated():
            return
        # A run already keeps the backend hot (the worker hammers it); skip
        # redundant pings while one is in flight.
        if self._auto_worker is not None:
            return
        auth = get_auth_header()
        if not auth:
            return

        now = time.monotonic()
        if now - self._last_warmup_monotonic < self._WARMUP_MIN_INTERVAL_S:
            return
        # A ping already in flight covers the cold start; don't pile on.
        if self._warmup_task is not None and self._warmup_task.is_active():
            return

        self._last_warmup_monotonic = now
        try:
            from qgis.core import QgsApplication

            from ...api.terralab_client import TerraLabClient
            from ...workers.generic_request_task import GenericRequestTask
            client = TerraLabClient()
            self._warmup_task = GenericRequestTask(
                tr("Warming up AI Segmentation"),
                lambda: client.warmup(auth=auth),
                hidden=True,
            )
            self._warmup_task.succeeded.connect(self._on_warmup_finished)
            self._warmup_task.failed.connect(lambda *_a: self._on_warmup_finished(False))
            QgsApplication.taskManager().addTask(self._warmup_task)
        except Exception:
            # Warmup is purely cosmetic; never let it disrupt the flow.
            self._warmup_task = None

    def _on_warmup_finished(self, _ok: object = None) -> None:
        """Main thread: release the finished warmup task. Result is ignored
        (warmup is best-effort; the real detection reports its own outcome)."""
        self._warmup_task = None

    @slot_guard(stage="segment", user_message=tr(
        "Something went wrong starting the detection. Please try again."))
    def _on_auto_detect_requested(self) -> None:
        # Commit-time guard rail: both entry points (Detect click and Enter)
        # funnel here, so this is the single gate where an off-rails prompt
        # blocks the run and shows its guidance under the prompt box.
        dock = self.dock_widget
        if dock is not None and not dock.confirm_prompt_for_detect():
            return
        # First committed Detect seals Terms + Privacy consent (the checkbox
        # sits right above the button; Detect stays disabled until it is
        # ticked, so reaching this line means consent was given).
        if dock is not None:
            try:
                dock.seal_tos_consent()
            except (RuntimeError, AttributeError):
                pass
        # Every Detect is a fresh run. The resume/re-detect-the-same-zone flow
        # was removed (it confused more than it helped): a stopped run keeps its
        # partial results in the review instead.
        self._start_auto_detection()

    def _on_auto_library_clicked(self) -> None:
        """Open the segment library gallery; drop the chosen English token into
        the prompt box. The token is the literal cloud-model prompt (labels are
        localized, tokens are not)."""
        try:
            from ..dialogs.segment_library_dialog import SegmentLibraryDialog
        except Exception as err:  # noqa: BLE001
            from qgis.core import Qgis
            from ...core.logging_utils import log
            log(f"Segment library unavailable: {err}", Qgis.MessageLevel.Warning)
            return
        from ...core.presets import segment_history
        # While a run (or review) is in flight the library opens view-only: the
        # user can still browse, but picking / re-running is disabled (mirrors AI
        # Edit). A picked token can never overwrite the locked in-run prompt.
        view_only = bool(getattr(self.dock_widget, "_auto_run_active", False)
                         or getattr(self.dock_widget, "_auto_review_active", False))
        dlg = SegmentLibraryDialog(
            self.dock_widget, recent=segment_history.get_recent(), plugin=self,
            view_only=view_only)
        if dlg.exec() and not view_only:
            token = dlg.get_selected_prompt()
            if token:
                self.dock_widget.set_prompt_text(token)

    def _on_zone_draw_requested(self) -> None:
        """Handle the dock's 'Draw zone' button (wired by plan #76)."""
        self._activate_zone_drawing()

    def _on_auto_detail_changed(self, _value: int) -> None:
        """Detail slider moved: the grid, overlay and credit estimate change.

        This fires only on a genuine USER move: programmatic seeds go through
        set_auto_detail_value / set_auto_detail_max, which block the slider's
        signal. So a real move here means the user is overriding the auto pick;
        latch that so the object-aware re-seed stops fighting them for this zone.
        The prompt the override was made for is recorded with it: a manual
        tweak stands for THAT object only, a different prompt re-seeds.
        """
        self._auto_detail_user_locked = True
        self._auto_detail_lock_prompt = self._current_auto_object_class().lower()
        # Debounce the cost/grid recompute: each tick runs compute_grid + up to
        # MAX_TILES polygon-clip GEOS calls on the GUI thread, so dragging the
        # slider across many levels would stutter. Coalesce to ~130ms of
        # inactivity (other callers still update immediately).
        self._schedule_credit_estimate()
        # Active setup = the user is about to Detect: warm the backend (debounced
        # to ~30s, gated on the Automatic flow + no active run). This is the
        # activity-driven keep-warm: pings only while the user is actually working,
        # so the backend goes idle on its own once they stop - a dock left open
        # and idle (or a Manual-mode user) never holds the backend up.
        self._maybe_warmup_auto()
        self._schedule_detail_telemetry("user")

    def _schedule_credit_estimate(self) -> None:
        """Debounce _update_credit_estimate for rapid slider moves (~130ms of
        inactivity), so a drag recomputes the grid/cost once it settles instead
        of on every intermediate tick."""
        from qgis.PyQt.QtCore import QTimer
        timer = getattr(self, "_credit_est_timer", None)
        if timer is None:
            # The plugin controller is a plain class, not a QObject; parent the
            # timer to the dock so Qt owns (and tears down) its lifetime.
            timer = QTimer(self.dock_widget)
            timer.setSingleShot(True)
            timer.timeout.connect(self._update_credit_estimate)
            self._credit_est_timer = timer
        timer.start(130)

    def _schedule_detail_telemetry(self, source: str) -> None:
        """Debounce detail_changed telemetry to ~1s of slider inactivity."""
        from qgis.PyQt.QtCore import QTimer
        self._detail_tel_source = source
        timer = getattr(self, "_detail_tel_timer", None)
        if timer is None:
            # The plugin controller is a plain class, not a QObject; parent the
            # timer to the dock so Qt owns (and tears down) its lifetime.
            timer = QTimer(self.dock_widget)
            timer.setSingleShot(True)
            timer.timeout.connect(self._emit_detail_telemetry)
            self._detail_tel_timer = timer
        timer.start(1000)

    def _emit_detail_telemetry(self) -> None:
        try:
            from ...core import telemetry
            tiles = getattr(self, "_auto_est_tiles", -1)
            telemetry.track_detail_changed(
                detail=self._get_auto_detail_level(),
                tiles=tiles if tiles is not None else -1,
                source=getattr(self, "_detail_tel_source", "user"),
            )
        except Exception:
            pass  # nosec B110

    def _on_auto_layer_combo_changed(self, _layer) -> None:
        """Raster picked (or gone) in the Automatic combo: the cost, the
        slider visibility and the grid preview all depend on the layer."""
        self._update_credit_estimate()

    def _on_auto_step_changed(self, index: int) -> None:
        """Arm the zone drawing tool whenever the zone step opens bare.

        Drawing is the only way to pick the detection area (there is no Full
        image button), so the cross-hair tool activates by itself instead of
        waiting for a button click. Leaving the step disarms the tool unless
        a zone was actually drawn.
        """
        if self._auto_worker is not None:
            return
        if index == 1:
            # Entering the draw-zone step (Start clicked, or re-draw): warm the
            # backend now so the cold start overlaps the user picking a zone.
            self._maybe_warmup_auto()
            # Re-entering the zone step means re-choosing the zone: drop any
            # previous rectangle so the next drag starts clean.
            if self._auto_zone is not None:
                self._on_zone_cleared()
            self._activate_zone_drawing()
        else:
            # Leaving the draw step without (re)drawing: disarm the zone tool and
            # hand the pan tool back rather than leaving a bare cursor.
            self._restore_maptool_after_zone()

    # ---- Pro Auto mode: detection worker (plan #78) -------------------------

    def _get_auto_detail_level(self) -> int:
        """Current detail slider value (grid side n, 1-7). Defaults to 1."""
        if self.dock_widget is None:
            return 1
        try:
            return max(1, int(self.dock_widget.auto_detail_slider.value()))
        except (RuntimeError, AttributeError):
            return 1

    def _online_native_mupp(self, layer) -> float:
        """Finest native resolution (layer-CRS units per pixel) of an online tiled
        source, so the detail slider never renders finer than what the source
        actually serves (no upsampled blurry tiles, no wasted credits). Returns
        0.0 (no clamp) when the native resolution is genuinely unknown, which
        preserves the prior behaviour for non-tiled sources and, by failing OPEN,
        can never over-restrict legitimate detail or make the plugin crash.

        Covered:
          - XYZ basemaps (Google, OSM, ...), WITH or WITHOUT an explicit zmax.
          - WMTS (its tile matrix set defines the finest level's resolution).
          - Any raster provider that reports native resolutions, any CRS.
        Returns 0.0 for non-tiled WMS / WCS / anything with no fixed native grid.
        """
        if layer is None:
            return 0.0
        # Primary path: ask the provider for its native resolutions. QGIS builds
        # this from the tile pyramid for XYZ (derived from zmin/zmax, so it works
        # even when the URI carries no explicit zmax) and from the tile matrix set
        # for WMTS. The values are already in the provider/layer CRS units per
        # pixel - exactly what `_grid_for_detail` compares against - so no CRS math
        # is needed here (a Web Mercator zoom's resolution is latitude-independent
        # in projected units, and any other matrix set reports its own CRS units).
        # The finest level = the smallest value; clamping the render to it only
        # blocks upsampling, never real detail.
        try:
            provider = layer.dataProvider()
            if provider is not None:
                # nativeResolutions(): QGIS >= 3.8 (our floor is 3.22).
                native = provider.nativeResolutions()
                finest = self._finest_native_resolution(native)
                if finest > 0:
                    return finest
        except (RuntimeError, AttributeError, TypeError, ValueError):
            pass  # provider does not expose it; fall through to the URI fallback

        # Fallback: derive the deepest-zoom resolution from an XYZ URI's zmax using
        # the Web Mercator slippy-map pyramid. Only valid when the tiling is Web
        # Mercator (EPSG:3857); for any other CRS we cannot assume that formula, so
        # we fail open (0.0) rather than risk an over- or under-clamp.
        try:
            if self._layer_is_web_mercator(layer):
                return self._xyz_zmax_mupp_from_source(layer.source() or "")
        except (RuntimeError, AttributeError, ValueError, TypeError):
            pass
        return 0.0

    @staticmethod
    def _finest_native_resolution(resolutions) -> float:
        """Finest (smallest positive) resolution, in layer-CRS units per pixel,
        from a provider's ``nativeResolutions()`` list. Skips non-numeric or
        non-positive entries individually and returns 0.0 for an empty/invalid
        list (fail open: no clamp)."""
        best = 0.0
        try:
            items = list(resolutions or [])
        except TypeError:
            return 0.0
        for r in items:
            try:
                v = float(r)
            except (TypeError, ValueError):
                continue
            if v > 0 and (best == 0.0 or v < best):
                best = v
        return best

    @staticmethod
    def _layer_is_web_mercator(layer) -> bool:
        """True when the layer's CRS is Web Mercator, so the slippy-map zoom
        formula (``_WEBMERC_MUPP_Z0 / 2**z``) yields correct EPSG:3857 units/px.
        Covers the common historical aliases for spherical Web Mercator."""
        try:
            authid = (layer.crs().authid() or "").upper()
        except (RuntimeError, AttributeError):
            return False
        return authid in (
            "EPSG:3857", "EPSG:900913", "EPSG:102100", "EPSG:102113",
        )

    @staticmethod
    def _xyz_zmax_mupp_from_source(source: str) -> float:
        """Deepest-zoom resolution (EPSG:3857 units per pixel) parsed from an XYZ
        URI's ``zmax``, via the Web Mercator tile pyramid. Returns 0.0 when there
        is no usable zmax: a plain WMS/WMTS URI has none, and an XYZ without an
        explicit zmax is left unclamped on purpose (QGIS's default max zoom is
        provider-internal and version-dependent, so guessing one would over- or
        under-clamp; the runtime nativeResolutions() path already covers it).
        Never raises."""
        try:
            import re  # noqa: PLC0415 - cheap, cached; keeps the top imports lean
            # QGIS raster URIs are '&'-delimited key=value pairs; the tile 'url'
            # value carries its own ampersands URL-encoded (%26), so a top-level
            # '&'/'?' split never cuts through it. Match zmax as a standalone key.
            m = re.search(r"(?:^|[?&])zmax=(\d+)", source or "")
            if not m:
                return 0.0
            zmax = int(m.group(1))
            if zmax <= 0:
                return 0.0
            return _WEBMERC_MUPP_Z0 / float(2 ** zmax)
        except (ValueError, TypeError):
            return 0.0

    def _grid_for_detail(self, layer, zone_in_layer, detail_n: int):
        """Pixel grid for one detail level. Single source of truth for the
        detail-slider math, shared by `_compute_auto_grid` (the real run),
        `_max_useful_detail` (the slider cap) and the m/px hint.

        The zone's longer side is rendered as exactly ``detail_n`` tiles
        (snap formula: TILE_SIZE + (n-1) * stride pixels). Local rasters AND
        online Web Mercator XYZ basemaps are clamped to their native resolution
        so the render never upsamples past the detail the source actually has.

        ``zone_in_layer`` is the zone already reprojected to the layer CRS.

        Returns ``(pixel_w, pixel_h, mupp, tile_count)`` where ``mupp`` is the
        rendered ground resolution in layer-CRS units per pixel (identical on
        both axes) and ``tile_count`` is the credit cost (``-1`` when the grid
        exceeds MAX_TILES). Returns None when the zone has no extent or the
        layer cannot be read.
        """
        from ...core.tile_manager import OVERLAP_FRACTION, TILE_SIZE

        longer_side = max(zone_in_layer.width(), zone_in_layer.height())
        if longer_side <= 0:
            return None
        try:
            layer_w = layer.width()
            layer_h = layer.height()
            ext = layer.extent()
        except (RuntimeError, AttributeError):
            return None

        use_online = layer_w <= 0 or layer_h <= 0 or self._is_online_provider(layer)
        # Same integer stride as TileManager. compute_grid() emits one tile at
        # offset 0 then one per full stride, so a span of TILE_SIZE + k * stride
        # pixels yields exactly k + 1 tiles per axis.
        stride = int(TILE_SIZE * (1.0 - OVERLAP_FRACTION))
        target_px = TILE_SIZE + (max(1, detail_n) - 1) * stride
        mupp = longer_side / target_px
        if not use_online and ext.width() > 0 and ext.height() > 0:
            native_mupp = max(ext.width() / layer_w, ext.height() / layer_h)
            mupp = max(mupp, native_mupp)
        elif use_online:
            # Online XYZ basemap: clamp to its deepest-zoom native resolution so a
            # finer detail level does not just upsample blurry tiles (and burn
            # extra credits for no real detail). No-op for WMS / non-Mercator.
            online_mupp = self._online_native_mupp(layer)
            if online_mupp > 0:
                mupp = max(mupp, online_mupp)

        pixel_w = max(1, int(zone_in_layer.width() / mupp))
        pixel_h = max(1, int(zone_in_layer.height() / mupp))
        # Do NOT snap up to clean tile boundaries: snapping grew the grid past
        # the drawn zone, so the preview and the tiles spilled into whatever lay
        # outside the selection (e.g. desert). Tile the zone exactly instead;
        # compute_grid's last row/column is a partial edge tile that stays fully
        # inside the zone, and the 20% overlap means the adjacent full tile
        # already covers most of that strip.
        tile_count = self._tile_manager.estimate_credits(pixel_w, pixel_h)
        return pixel_w, pixel_h, mupp, tile_count

    def _max_useful_detail(self, layer, zone_in_layer) -> int:
        """Largest detail level (1-MAX_DETAIL_LEVEL) that still changes the grid.

        Two regimes bound the slider, and crossing either makes higher levels
        inert (the cursor moves with no visible effect):
          - the MAX_TILES cap (a finer grid would exceed it), and
          - the native-resolution clamp: on a local raster, past some level
            the render is pinned to native pixels, so a higher level repeats
            the exact same mupp and the same tiles.
        Returns the largest n whose grid fits MAX_TILES and whose mupp is
        still strictly finer than n-1's. Always >= 1.
        """
        from ...core.tile_manager import MAX_DETAIL_LEVEL, MAX_TILES

        best = 1
        prev_mupp = None
        for n in range(1, MAX_DETAIL_LEVEL + 1):
            sized = self._grid_for_detail(layer, zone_in_layer, n)
            if sized is None:
                break
            _pw, _ph, mupp, tiles = sized
            if tiles == -1 or tiles > MAX_TILES:
                break  # grids only grow with n; nothing finer will fit
            if prev_mupp is not None and mupp >= prev_mupp:
                break  # clamped to native: this level renders no finer
            best = n
            prev_mupp = mupp
        return best

    def _default_detail_for_zone(self, layer, zone_in_layer) -> int:
        """Pick the optimal starting detail from the cloud model's GSD sweet spot.

        The model detects best when each tile's effective ground resolution sits
        in the 0.30-0.45 m/px sweet spot: coarser than ~0.5 m/px quality
        collapses, finer than ~0.30 m/px only adds tiles (= credits) with no
        quality gain. So walk detail levels coarse -> fine and return the FIRST
        (cheapest) whose ground resolution reaches mid-band
        (<= DEFAULT_SEED_MUPP_M): the coarse edge (0.45) seeded too low and
        missed small objects, the fine edge (0.30) spent too much of the small
        free-credit allowance. The object-aware seed refines this per prompt.

        DEFAULT_AUTO_TILE_BUDGET is a soft preference, not a wall: when a zone
        is so large the band is unreachable within it, keep walking finer (the
        credit cost is displayed live and Detect is the user's confirmation)
        and seed the CHEAPEST level that reaches the COARSE edge of the band
        (<= SWEET_SPOT_MAX_MUPP_M): adequate quality at the least cost. Only
        when even the slider maximum stays coarser does the seed fall back to
        the cheapest in-budget level (spending big on a still-inadequate grid
        helps nobody; the zone warning then tells the user to draw smaller).
        Always >= 1, so a fresh zone never sits at a too-coarse 1x1 grid when
        a finer level is the genuinely better default.
        """
        from ...core.tile_manager import (
            AUTO_SEED_TILE_CAP,
            DEFAULT_AUTO_TILE_BUDGET,
            DEFAULT_SEED_MUPP_M,
            SWEET_SPOT_MAX_MUPP_M,
        )

        best_within_budget = 1
        for n in range(1, self._max_useful_detail(layer, zone_in_layer) + 1):
            sized = self._grid_for_detail(layer, zone_in_layer, n)
            if sized is None:
                break
            _pw, _ph, mupp, tiles = sized
            if tiles == -1:
                break
            if tiles > AUTO_SEED_TILE_CAP:
                break  # the default never spends past the hard seed cap
            ground_mupp = self._mupp_to_meters(layer, zone_in_layer, mupp)
            if tiles > DEFAULT_AUTO_TILE_BUDGET:
                if 0 < ground_mupp <= SWEET_SPOT_MAX_MUPP_M:
                    return n  # cheapest ADEQUATE level beyond the soft budget
                continue
            best_within_budget = n
            if 0 < ground_mupp <= DEFAULT_SEED_MUPP_M:
                return n  # cheapest level reaching mid-band (0.35 m/px)
        return best_within_budget

    def _object_detail_profile(self, object_class: str) -> tuple[float, float]:
        """(typical ground size m, target ground resolution m/px) for the
        object named by ``object_class``.

        Word-boundary prefix match against the real-demand object list, so
        plurals match ("trees" -> "tree") but embedded letters never do
        ("street" is not a tree). Finest tier first: the smallest object
        named in a multi-word prompt drives the resolution ("solar farm"
        needs panel-level detail, not farm-level). English tokens only
        (prompts are always English). Unknown or empty prompts fall back to
        a generic ~10 m object at the sweet spot's fine edge, matching the
        resolution-only zone default.

        The targets bracket the cloud model's 0.30-0.45 m/px sweet spot, leaning to the
        thrifty side of each object's adequate range (free credits are a small
        allowance): a large object is already sharp at the coarse edge (a
        stadium spans ~180 px there), a typical building at the band's coarse
        half, and small objects (trees, cars, solar panels) get the coarsest
        grid that still renders them at a detectable ~25+ px.
        """
        import re  # noqa: PLC0415 - cheap, cached; keeps the top imports lean

        from ...core.tile_manager import DEFAULT_SEED_MUPP_M

        text = (object_class or "").lower()
        tiers = (
            (2.0, 0.10, ("solar", "panel", "pv")),
            (4.0, 0.19, ("car", "vehicle", "van", "caravan")),
            (8.0, 0.25, ("tree", "boat", "ship", "bus", "truck", "trailer",
                         "tank", "hedge")),
            (15.0, 0.34, ("house", "pool", "swimming", "court", "tennis",
                          "barn", "silo", "road", "street", "river",
                          "canal")),
            (35.0, 0.42, ("building", "warehouse", "factory", "hangar",
                          "mall", "greenhouse", "parking", "block", "field",
                          "parcel", "farm")),
            (80.0, 0.45, ("stadium", "lake", "quarry", "dam", "reservoir",
                          "golf", "airport", "runway", "roundabout")),
        )
        for size_m, target_mupp, keywords in tiers:
            if any(re.search(r"\b" + re.escape(kw), text) for kw in keywords):
                return size_m, target_mupp
        return 10.0, DEFAULT_SEED_MUPP_M

    def _auto_detail_for_object(self, layer, zone_in_layer, object_class) -> int:
        """Cheapest detail level whose ground resolution suits the OBJECT.

        Each object class maps to a target ground resolution
        (`_object_detail_profile`): a building is already sharp mid-sweet-spot
        while a tree or a car needs a finer grid to render at a detectable
        pixel size. Walk detail levels coarse -> fine and return the CHEAPEST
        level reaching the target, past the soft tile budget when needed (the
        credit cost is displayed live and Detect is the user's confirmation)
        but NEVER past AUTO_SEED_TILE_CAP: free credits are a small lifetime
        allowance, so the default must not propose a run that drains them.
        Distinct objects thus land on distinctly different levels: the old
        tile-footprint scoring converged on the finest in-budget level for
        nearly every prompt, which read as "the slider never adapts".

        Fallbacks, in order, when no level reaches the target (a huge zone
        hitting the MAX_TILES bound, or a raster whose native resolution is
        coarser): the cheapest level where the object still renders at
        AUTO_OBJECT_MIN_PX (resolvable, if not ideal), else the finest level
        within the soft tile budget (when even resolvability is out of reach,
        spending hundreds of credits on a still-blind grid helps nobody); on
        any layer read error, the resolution-only zone default. Always >= 1.
        """
        from ...core.tile_manager import (
            AUTO_OBJECT_MIN_PX,
            AUTO_SEED_TILE_CAP,
            DEFAULT_AUTO_TILE_BUDGET,
        )

        try:
            obj_m, target_mupp = self._object_detail_profile(object_class)
            cap = self._max_useful_detail(layer, zone_in_layer)
            resolvable_n = None
            finest_in_budget = 1
            for n in range(1, cap + 1):
                sized = self._grid_for_detail(layer, zone_in_layer, n)
                if sized is None:
                    break
                _pw, _ph, mupp, tiles = sized
                if tiles == -1:
                    break
                if tiles > AUTO_SEED_TILE_CAP:
                    break  # tiles grow with n: the seed never spends past the cap
                ground_mupp = self._mupp_to_meters(layer, zone_in_layer, mupp)
                if ground_mupp <= 0:
                    continue
                if ground_mupp <= target_mupp:
                    return n
                if tiles <= DEFAULT_AUTO_TILE_BUDGET:
                    finest_in_budget = n
                if (resolvable_n is None
                        and obj_m / ground_mupp >= AUTO_OBJECT_MIN_PX):
                    resolvable_n = n
            if resolvable_n is not None:
                return resolvable_n
            return max(1, finest_in_budget)
        except (RuntimeError, AttributeError, ValueError):
            return self._default_detail_for_zone(layer, zone_in_layer)

    def _current_auto_object_class(self) -> str:
        """The object class currently entered in the Automatic prompt box."""
        if self.dock_widget is None:
            return ""
        try:
            return self.dock_widget.auto_prompt_input.text().strip()
        except (RuntimeError, AttributeError):
            return ""

    def _apply_default_detail(self, zone_rect) -> None:
        """Seed the detail slider with a good default for a freshly drawn zone.

        A freshly drawn zone is a fresh start, so the manual-override lock is
        cleared: the object-aware re-seed may drive the slider again until the
        user next moves it themselves. When an object class is already entered,
        seed with the object-aware pick; otherwise fall back to the
        resolution-only default.
        """
        # New zone = fresh default: let the auto picker own the slider again.
        self._auto_detail_user_locked = False
        self._auto_detail_lock_prompt = ""
        if not self.dock_widget:
            return
        layer = self._get_active_raster_layer()
        if layer is None:
            return
        try:
            zone_in_layer = self._reproject_zone_to_layer_crs(zone_rect, layer)
            object_class = self._current_auto_object_class()
            if object_class:
                detail = self._auto_detail_for_object(
                    layer, zone_in_layer, object_class)
            else:
                detail = self._default_detail_for_zone(layer, zone_in_layer)
            self.dock_widget.set_auto_detail_value(detail)
        except (RuntimeError, AttributeError):
            pass

    def _reseed_auto_detail_for_object(self, object_class: str = "") -> None:
        """Re-pick the detail level when the object class changes (debounced).

        Respects a manual override for the SAME prompt: once the user has
        moved the slider, their value stands for the object it was tuned for.
        A different prompt is a new sizing problem, so it releases the
        override and re-seeds (the user can still re-adjust afterwards).
        Requires a drawn zone and a non-empty object class. Sets the slider
        programmatically (signal free via set_auto_detail_value's
        blockSignals), so it never trips the user-lock itself.
        """
        if not self.dock_widget or self._auto_zone is None:
            return
        # Never fight a run/review or override while one is in flight.
        if self._auto_worker is not None or self._auto_review is not None:
            return
        object_class = (object_class or "").strip()
        if not object_class:
            return
        if self._auto_detail_user_locked:
            locked_for = getattr(self, "_auto_detail_lock_prompt", "")
            if object_class.lower() == locked_for:
                return
            self._auto_detail_user_locked = False
            self._auto_detail_lock_prompt = ""
        layer = self._get_active_raster_layer()
        if layer is None:
            return
        try:
            zone_in_layer = self._reproject_zone_to_layer_crs(self._auto_zone, layer)
            detail = self._auto_detail_for_object(layer, zone_in_layer, object_class)
            self.dock_widget.set_auto_detail_value(detail)
            self._update_credit_estimate()
        except (RuntimeError, AttributeError):
            pass

    def _mupp_to_meters(self, layer, zone_in_layer, mupp: float) -> float:
        """Convert a layer-CRS resolution (CRS units per pixel) to meters per
        pixel, measured at the zone center.

        An ellipsoidal measurement (WGS84) handles every CRS uniformly:
        projected layers in metres pass through, projected feet convert, and
        geographic layers in degrees get a real ground distance instead of a
        meaningless degree value. Returns 0.0 when it cannot be computed.
        """
        try:
            from qgis.core import (
                QgsDistanceArea,
                QgsPointXY,
                QgsProject,
            )

            da = QgsDistanceArea()
            da.setSourceCrs(layer.crs(), QgsProject.instance().transformContext())
            da.setEllipsoid("WGS84")
            cx = (zone_in_layer.xMinimum() + zone_in_layer.xMaximum()) / 2.0
            cy = (zone_in_layer.yMinimum() + zone_in_layer.yMaximum()) / 2.0
            # Measure BOTH axes and take the coarser ground distance. For a
            # geographic CRS (EPSG:4326) a pixel is square in degrees but a
            # degree of longitude shrinks with latitude, so a horizontal-only
            # measurement underestimates the effective GSD at high latitude and
            # skews the detail seed. Seeding on the coarser axis stays conservative
            # (never over-tiles). Projected metric CRSes are isotropic, so both
            # axes agree and this is a no-op there.
            dist_x = da.measureLine(QgsPointXY(cx, cy), QgsPointXY(cx + mupp, cy))
            dist_y = da.measureLine(QgsPointXY(cx, cy), QgsPointXY(cx, cy + mupp))
            dist = max(dist_x, dist_y)
            return da.convertLengthMeasurement(dist, DistanceMeters)
        except (RuntimeError, AttributeError, ValueError):
            return 0.0

    def _compute_auto_grid(self, layer) -> dict | None:
        """Compute the pixel grid for one automatic-detection run.

        Returns a dict with keys:
            pixel_w  (int)  -- total image width in pixels
            pixel_h  (int)  -- total image height in pixels
            zone_x   (int)  -- always 0 (bbox carries the extent for both paths)
            zone_y   (int)  -- always 0
            bbox     (tuple) -- (minx, miny, maxx, maxy) in layer CRS
            online   (bool) -- True when the map renderer path must be used
                               (always True once a zone is drawn, regardless of
                               layer type; False only for the no-zone local path)

        Returns None when a grid cannot be computed (e.g. no zone for an online layer).

        Slider-driven path (zone set, any layer type):
            Resolution is driven by the detail slider: the zone's longer side is
            rendered as exactly n tiles (snap formula: TILE_SIZE + (n-1) * stride
            pixels), so the user directly chooses how finely the zone is divided
            and therefore the credit cost. Local rasters are clamped to their
            native resolution so the render never upsamples the source pixels.
            online=True. zone_x and zone_y are 0 (the bbox IS the zone extent).

        Native path (no zone, local raster only):
            Derives pixel dimensions from the layer's own pixel grid. online=False.
            Online providers cannot run without a zone and return None here.
        """
        if self._tile_manager is None:
            self._setup_auto_mode()

        try:
            layer_w = layer.width()
            layer_h = layer.height()
            ext = layer.extent()
        except (RuntimeError, AttributeError):
            return None

        use_online = layer_w <= 0 or layer_h <= 0 or self._is_online_provider(layer)

        if self._auto_zone is not None:
            # Slider-driven path for EVERY layer once a zone is drawn: the user
            # picks the grid subdivision n (zone's longer side = n tiles), and
            # therefore the credit cost. The actual sizing lives in
            # _grid_for_detail so the slider cap and the m/px hint share it.
            zone_in_layer = self._reproject_zone_to_layer_crs(self._auto_zone, layer)
            detail_n = self._get_auto_detail_level()
            sized = self._grid_for_detail(layer, zone_in_layer, detail_n)
            if sized is None:
                return None
            pixel_w, pixel_h, mupp, _tiles = sized

            # Extend the geo bbox so it matches the snapped pixel grid at the
            # SAME mupp on both axes. snap_dimensions() rounds each axis up
            # independently, so the snapped pixel aspect no longer equals the
            # zone aspect; rendering the raw zone into that grid stretches the
            # image (up to ~1.8x) and the model sees deformed objects. Anchor
            # at the top-left corner (image origin) and grow right/down. The
            # forward render and the worker's inverse mapping both read this
            # bbox, so detections stay correctly georeferenced; the only
            # visible effect is the preview grid spilling slightly past the
            # drawn zone (blank margin at a raster edge renders harmlessly).
            minx = zone_in_layer.xMinimum()
            maxy = zone_in_layer.yMaximum()
            maxx = minx + pixel_w * mupp
            miny = maxy - pixel_h * mupp

            return {
                "pixel_w": pixel_w,
                "pixel_h": pixel_h,
                "zone_x": 0,
                "zone_y": 0,
                "bbox": (minx, miny, maxx, maxy),
                "online": True,
            }

        # No zone (MCP / full-raster path): keep the native pixel grid. Online
        # providers cannot run without a zone.
        if use_online:
            return None
        if ext.width() <= 0:
            return None
        pixel_w, pixel_h = layer_w, layer_h
        zone_x, zone_y = 0, 0
        bbox = (ext.xMinimum(), ext.yMinimum(), ext.xMaximum(), ext.yMaximum())
        return {
            "pixel_w": pixel_w, "pixel_h": pixel_h,
            "zone_x": zone_x, "zone_y": zone_y,
            "bbox": bbox, "online": False,
        }
