"""Which predictor answers the Automatic review's AI fix: on-device, or remote.

This is the ONE place that decides. Everything else asks
``_correct_ai_route_is_remote()`` or checks whether the remote predictor is in
place; nothing else reads the switch.

The decision is deliberately narrow. It says yes only inside the Automatic
review, only with the server switch explicitly on, and only for an account the
route will accept. Every other answer is no, and no means the plugin behaves
exactly as it did before this route existed: the on-device install, the model
download, the subprocess.

Semi-Auto mode does not get here on its own: the review check below keeps it
out, and its own choice lives in ``manual_cloud_predictor.py``, where the two
engine cards offer Cloud AI first and the card says where the work runs and
what leaves the machine. The on-device predictor stays behind it either way.

There is one door between the two. Refine-in-Manual hands a review session
over to Semi-Auto and keeps whatever predictor is in the slot, so that handoff
runs on the route the review was already using.

Part of AISegmentationPlugin (see ai_segmentation_plugin.py); methods here are
plain mixin members and state lives on the plugin instance.
"""
from __future__ import annotations

import time

from qgis.core import Qgis, QgsMessageLog
from qgis.PyQt.QtCore import QTimer

from ...core.i18n import tr
from ...core.interaction_dials import route_memo_ms

# How long the served switch and the account store may be answered from the
# last look. Same name and value as the Semi-Auto hover preview's own memo
# (manual_hover_preview.py): short enough that a sign-out or a key change
# stops the route in the same breath, long enough that a cursor resting on a
# polygon does not read the auth store on every mouse move.
_ROUTE_MEMO_MS = 3000.0


class CorrectAiRouteMixin:
    """Picks the predictor behind the review's AI fix and Add lanes."""

    def _correct_ai_route_is_remote(self) -> bool:
        """True when the review's AI lanes should answer over the network.

        Fails closed on anything unexpected: a missing switch, an unreadable
        configuration or no account all land on the on-device path. The
        switch and the account store are read at most once per
        ``_ROUTE_MEMO_MS``: this is asked on every hover, not only on a
        click.
        """
        if getattr(self, "_auto_review", None) is None:
            return False
        now = time.monotonic() * 1000.0
        memo = getattr(self, "_correct_route_memo", None)
        if memo is not None and now - memo[0] < route_memo_ms(_ROUTE_MEMO_MS):
            return memo[1]
        try:
            from ...core.server_dials import correct_ai_cloud_enabled

            if not correct_ai_cloud_enabled():
                answer = False
            else:
                from ...core.activation_manager import get_auth_token

                answer = bool(get_auth_token())
        except Exception:  # noqa: BLE001 -- the on-device path is the fallback
            answer = False
        self._correct_route_memo = (now, answer)
        return answer

    def _cloud_correct_predictor_active(self) -> bool:
        """Whether the predictor in hand answers over the network.

        Read by the local-install machinery, which must not start a model load
        under a session the network is already serving.

        Both remote routes count: the review's AI fix, and the Semi-Auto
        option that puts its own clicks on the network. The two never hold the
        slot at once, and the machinery that reads this needs the same answer
        from either.
        """
        predictor = getattr(self, "predictor", None)
        if predictor is None:
            return False
        try:
            from ...core.cloud_first_predictor import CloudFirstPredictor
            from ...core.cloud_sam_predictor import CloudSamPredictor

            return isinstance(predictor, (CloudSamPredictor, CloudFirstPredictor))
        except Exception:  # noqa: BLE001 -- an unimportable module is not active
            return False

    def _ensure_cloud_correct_predictor(self) -> bool:
        """Put the remote predictor in place, and say whether it is there.

        True means the click path is served over the network, so the caller
        skips every on-device install check. False leaves the plugin untouched.

        An on-device predictor already loaded is set aside rather than dropped,
        so a Manual session started later gets its own model back without
        reloading it.
        """
        if not self._correct_ai_route_is_remote():
            return False
        if self._cloud_correct_predictor_active():
            return True
        try:
            from ...core.activation_manager import get_auth_header
            from ...core.cloud_sam_predictor import CloudSamPredictor

            held = getattr(self, "predictor", None)
            self._local_predictor_held = held
            # Auth read here, on the GUI thread, and not on first use. The crop
            # now goes out from set_image, which the caller runs on a worker
            # thread, and the key store is not something to reach for from
            # there.
            # The ANSWER, not the callable, although the predictor accepts both.
            # A callable is asked again per request, and the first request is
            # sent from set_image, which the caller runs on a worker thread.
            # Reaching into the key store from there is the deadlock this
            # plugin already carries a scar from. The cost is that a key
            # rotated mid-session is only picked up by the next session.
            # The answer callback is what makes the lane billable: the ledger
            # charges an object only once a click on it came back from the
            # network, so a fix the machine never got an answer for is free.
            # A ledger already open (Start opened it before this predictor is
            # built) hands its id straight over; a later one reaches this
            # predictor through ``_start_manual_credit_session`` instead.
            ledger = getattr(self, "_manual_credit_ledger", None)
            self.predictor = CloudSamPredictor(
                auth=get_auth_header(),
                on_remote_answer=self._note_manual_cloud_answer,
                session_id=getattr(ledger, "session_id", None),
            )
        except Exception as err:  # noqa: BLE001 -- fall back to the on-device path
            QgsMessageLog.logMessage(
                f"Remote fix route unavailable, staying on-device: {err}",
                "AI Segmentation", level=Qgis.MessageLevel.Warning)
            return False
        QgsMessageLog.logMessage(
            "Correct step: AI fix answered off the machine",
            "AI Segmentation", level=Qgis.MessageLevel.Info)
        return True

    def _degrade_correct_ai_to_manual(self, reason: str, failure_class: str = "") -> bool:
        """The off-machine fix refused a click: hand the step to Manual.

        Returns True when the fallback took the failure, so the caller drops
        its own report. A network that will not answer is not something the
        user can act on, and a failure dialog on the first click of a step that
        opened itself on AI reads as the plugin breaking. One quiet line and
        the other method, which needs no network and no install, is the honest
        answer.

        Only ever fires inside a review served off the machine. Manual mode
        holds a different predictor, so it never reaches here.

        The switch itself waits one event-loop turn: the click that failed is
        still unwinding on this stack, and folding its session from under it
        would rewrite the very state the caller is about to roll back.

        ``failure_class`` is for a caller that already knows why, such as the
        Save gate refusing an empty balance. Left empty, the reason is read off
        the message the route answered with.
        """
        if not self._cloud_correct_predictor_active():
            return False
        if getattr(self, "_auto_review", None) is None:
            return False
        # The route just proved unreliable, so the cached answer must not
        # outlive it: a retry on the AI method has to read the store again
        # rather than trust a memo taken before the refusal.
        self._correct_route_memo = None
        QgsMessageLog.logMessage(
            f"Correct step: AI fix refused a click, handing it to Manual: {reason}",
            "AI Segmentation", level=Qgis.MessageLevel.Warning)
        self._correct_ai_failure_class = (
            failure_class or self._correct_ai_failure_kind(reason))
        QTimer.singleShot(0, self._switch_correct_to_manual_after_failure)
        return True

    def _correct_ai_failure_kind(self, reason: str) -> str:
        """Why the click was refused, as one of ``error_policy``'s classes.

        The classifier reads the message the route answered with, and the
        server can teach it a new marker without a plugin release, which is
        why the branching below asks it rather than matching text here.
        """
        try:
            from ...core.error_policy import classify_run_error

            return classify_run_error(reason or "")
        except Exception:  # noqa: BLE001 -- an unreadable class is the generic one
            return "UNKNOWN"

    def _correct_ai_failure_line(self) -> str:
        """The one sentence the user reads, for the class of the refusal.

        Credits and sign-in are things they can act on, and each has its own
        fix; telling them "not reachable right now" sends them to look at their
        network instead. Everything else keeps the generic line, because a
        service that will not answer is not theirs to fix.

        All three are served copy, so a line that reads badly at the worst
        moment of the flow is one deploy away from fixed. The shipped wording
        stands whenever nothing is served."""
        from ...core.server_dials import dial_copy

        kind = getattr(self, "_correct_ai_failure_class", "") or "UNKNOWN"
        if kind == "CREDITS_EXHAUSTED":
            return dial_copy(
                "correct.ai_credits_exhausted",
                tr("Your cloud allowance for this month is used, so the AI "
                   "fix cannot answer. Switched to editing by hand, which "
                   "is free."))
        if kind == "AUTH":
            return dial_copy(
                "correct.ai_auth",
                tr("Sign in again to fix with the AI. Switched to editing "
                   "by hand, which needs no account."))
        return dial_copy(
            "correct.ai_unreachable",
            tr("AI fixing is not reachable right now. Switched to editing "
               "by hand, which works offline."))

    def _switch_correct_to_manual_after_failure(self) -> None:
        """Second half of the fallback, one turn after the failed click.

        Gives the predictor slot back, moves the switch, and tells the user in
        one line. Never raises: this runs off a timer with nothing above it.
        """
        try:
            self._drop_cloud_correct_predictor()
        except Exception:  # noqa: BLE001 -- teardown must never raise  # nosec B110
            pass
        if getattr(self, "_auto_review", None) is None:
            return
        try:
            self.dock_widget.set_correct_method("manual")
        except (RuntimeError, AttributeError):
            pass
        try:
            self._on_correct_method_changed("manual")
        except Exception:  # noqa: BLE001 -- the switch already reads Manual  # nosec B110
            pass
        try:
            self.iface.messageBar().pushMessage(
                "AI Segmentation",
                self._correct_ai_failure_line(),
                level=Qgis.MessageLevel.Info,
                duration=6,
            )
        except (RuntimeError, AttributeError):
            pass
        self._correct_ai_failure_class = ""

    def _drop_cloud_correct_predictor(self) -> None:
        """Give the predictor slot back to whatever held it before. Never raises.

        Called wherever a review-owned session ends, and at the top of a
        Semi-Auto start so that session decides its own route from a clean
        slot.
        """
        if not self._cloud_correct_predictor_active():
            return
        try:
            self.predictor.cleanup()
        except Exception:  # noqa: BLE001 -- teardown must never raise  # nosec B110
            pass
        self.predictor = getattr(self, "_local_predictor_held", None)
        self._local_predictor_held = None
        # The route this session used is going away with the predictor: the
        # next review or Semi-Auto session must not inherit a cached answer.
        self._correct_route_memo = None
