from __future__ import annotations

import html

from ...core.auth.activation_manager import (
    build_utm_url,
    get_dashboard_url,
    get_server_url,
)
from ...core.config_store import (
    ServerDialSet,
    get_export_dial_list,
    get_export_text,
)
from ...core.errors import NETWORK_ERROR_CODES, PROMPT_BLOCKED_CODES
from ...core.i18n import tr

# An error message is one or two sentences, a CTA is a link label or one line
# of guidance. Both caps are deliberately tighter than the copy channel's.
_MAX_ERROR_CHARS = 240
_MAX_CTA_CHARS = 120

# Shipped fallbacks. The links themselves resolve through the accessors below,
# so the two places a failed generation sends someone to buy or to fix their
# account can be repointed from the server. The UTM tags here are what a user
# keeps seeing as long as the server says nothing.
DASHBOARD_ERROR_URL = build_utm_url("/dashboard", "dashboard_error")
SUBSCRIBE_ERROR_URL = build_utm_url("/dashboard/ai-edit", "subscribe")


def dashboard_error_url() -> str:
    """Where an account-problem message sends the user."""
    return get_server_url("dashboard_error_url", DASHBOARD_ERROR_URL)


def subscribe_error_url() -> str:
    """Where an out-of-credits message sends the user."""
    return get_server_url("subscribe_error_url", SUBSCRIBE_ERROR_URL)


def content_policy_url() -> str:
    """Where a refused prompt sends the user to read the rules, empty until a
    page exists.

    Shipped empty on purpose: the refusal message is complete without a link,
    and a link to a page that is not published yet is worse than none. The day
    the rules have a URL, one deploy adds it for every installed version."""
    return get_server_url("content_policy_url", "")


def _localize_server_error(error: str, code: str) -> str:
    """Replace an English server error string with its localized equivalent
    when the server's `code` is one we know about. Keeps the original English
    text for unknown codes so users still see something actionable.

    Three sources, in order:

      1. A served message for this code (error_messages), already written in
         the language the plugin asked for. It wins over the shipped table on
         purpose: that is how a message that reads badly, or a code invented
         after this release, gets fixed without waiting for one.
      2. The shipped table below, which is what every user sees today and what
         a served entry falls back to, per code, on anything unusable.
      3. The raw server string, for a code nothing knows about.

    Everything that did not come from tr() is html-escaped: the status label
    is AutoText, so unescaped markup would render.
    """
    if not code:
        return html.escape(error or "")
    served = get_export_text("error_messages", code, _MAX_ERROR_CHARS)
    if served is not None:
        return html.escape(served, quote=False)
    mapping = {
        # Connectivity codes (client-generated by terralab_client). The base
        # sentence stays short here; _enrich_error_message appends the matching
        # actionable hint so the two never duplicate "check your connection".
        "NO_NETWORK": tr("No internet connection."),
        "DNS_ERROR": tr("Cannot reach the server."),
        "TIMEOUT": tr("The request timed out."),
        "SSL_ERROR": tr("Secure connection failed."),
        "PROXY_ERROR": tr("Proxy connection failed."),
        "CONNECTION_REFUSED": tr("Could not connect to the service."),
        "AUTH_ERROR": tr("Authentication failed. Check your activation key."),
        # Account / key codes (server-generated). Translated here so a French
        # user never sees the raw English server string.
        "NO_KEY": tr("No activation key. Enter your key to use AI Edit."),
        "INVALID_KEY": tr("Invalid activation key."),
        "KEY_REVOKED": tr("This activation key has been revoked."),
        "SUBSCRIPTION_EXPIRED": tr("Your subscription has expired."),
        "SUBSCRIPTION_INACTIVE": tr("Your subscription is inactive."),
        "FREE_TIER_EXPIRED": tr("Your free trial has ended."),
        "DEVICE_LIMIT_EXCEEDED": tr(
            "This license is already in use on the maximum number of computers."
            " Free one in your account, or wait for an inactive one to expire."
        ),
        "RATE_LIMITED": tr("Too many requests, please wait a moment."),
        "RATE_LIMITER_DOWN": tr("Service temporarily unavailable, please retry shortly."),
        "STORAGE_UNAVAILABLE": tr("Storage temporarily unavailable, please retry shortly."),
        "SIGN_FAILED": tr("Could not prepare upload, please retry shortly."),
        "UPLOAD_TOKEN_INVALID": tr("Upload session expired, please retry."),
        "UPLOAD_TOKEN_MISMATCH": tr("Upload session does not match your account."),
        "WRONG_PRODUCT": tr("This activation key is for a different product."),
        "WRONG_REQUEST": tr("Unknown or unauthorized request."),
        "AUTH_MIGRATION_REQUIRED": tr("Account migration required, please re-login from the website."),
        "NOT_READY": tr("Result not ready yet."),
        "NOT_AVAILABLE": tr("Result not available."),
        "UPSTREAM_UNAVAILABLE": tr("Result temporarily unavailable, please retry shortly."),
        "UPSTREAM_EMPTY": tr("Result temporarily unavailable, please retry shortly."),
        "PROVIDER_BAD_RESPONSE": tr("The generation service returned an unexpected response, please retry."),
        "PROVIDER_ERROR": tr("Generation failed, please try again."),
        "MISCONFIGURED": tr("Service not configured. Please contact support."),
        "SERVER_ERROR": tr("Service temporarily unavailable, please retry shortly."),
        "DB_ERROR": tr("Database error, please retry shortly."),
        "BAD_REQUEST": tr("Invalid request. Check your prompt and the selected area, then try again."),
        "BAD_INPUT": tr("Invalid input. Check your prompt and the selected area."),
        "INVALID_INPUT": tr("Invalid input. Try a different image or selection."),
        "PAYLOAD_TOO_LARGE": tr("Image too large. Try selecting a smaller area or lowering the resolution."),
        "RESOLUTION_NOT_ALLOWED": tr(
            "This detail level is not available on your plan."
            " Upgrade to unlock more detail."
        ),
        "NOT_FOUND": tr("Resource not found."),
        "NOT_SEEDED": tr("Catalog not yet available, please retry shortly."),
        "DEMO_FETCH_FAILED": tr("Could not load the demo preview."),
        "UNKNOWN_TEMPLATE": tr("Unknown template."),
    }
    localized = mapping.get(code)
    if localized is not None:
        return localized
    # A content refusal, in any of its spellings, never falls through to the
    # raw server string: a rule the user is being held to is the last sentence
    # to show half-translated. The server can still say exactly which rule was
    # broken, in the user's language, through error_messages above.
    if code.upper() in PROMPT_BLOCKED_CODES:
        return tr("This prompt is not allowed: its content goes against our rules.")
    return html.escape(error or "")


# Three-tier failure-report policy, decided by error code:
#
#  Tier 1 (_USER_FIXABLE_CODES)  -> plain inline message, no log prompt.
#     The user owns the fix (their link, key, zone, plan). Not our bug.
#  Tier 2 (_TRANSIENT_CODES)     -> inline message + an optional "Report a
#     problem" link. Retryable and not clearly a fault (rate limit, a stale
#     upload session, a result not ready yet); the user can send a log if it
#     persists, but we never force a modal.
#  Tier 3 (everything else)       -> auto-open the log-report dialog. A network
#     timeout or a server-side fault is terminal for that generation either
#     way, so we want to hear about it right away rather than only see it as a
#     dip in completion telemetry (same tier the download/write/empty-response
#     and truly unknown codes already used).
_USER_FIXABLE_CODES = frozenset({
    "AUTH_ERROR",
    "NO_KEY", "INVALID_KEY", "KEY_REVOKED", "AUTH_LOCKED",
    "INVALID_CRS", "ANTIMERIDIAN", "POLAR", "TOO_LARGE",
    "MAP_ROTATED", "ZONE_TOO_SMALL", "PAYLOAD_TOO_LARGE",
    "BAD_REQUEST", "BAD_INPUT", "INVALID_INPUT", "RESOLUTION_NOT_ALLOWED",
    "QUOTA_EXCEEDED", "LIMIT_REACHED", "USAGE_LIMIT_REACHED",
    "MONTHLY_LIMIT_REACHED", "TRIAL_EXHAUSTED",
    "SUBSCRIPTION_EXPIRED", "SUBSCRIPTION_INACTIVE", "FREE_TIER_EXPIRED",
    "DEVICE_LIMIT_EXCEEDED",
    "GENERATION_CANCELLED",
})

# Pure-connectivity codes: the request never reached us at all because of
# something in the USER's own network path (no link, DNS, a corporate proxy,
# SSL inspection). Still not our bug, but not silent either: an optional
# report link lets someone who cannot self-diagnose their network send us a
# log. TIMEOUT is deliberately excluded: unlike these, it does not point at
# the user's own setup (it is just as often our server being slow or
# overloaded), so it is treated as a server-fault code below instead.
_CONNECTIVITY_CODES = NETWORK_ERROR_CODES - frozenset({"TIMEOUT"})

_TRANSIENT_CODES = _CONNECTIVITY_CODES | frozenset({
    "RATE_LIMITED", "NOT_READY", "NOT_AVAILABLE",
    "UPLOAD_TOKEN_INVALID", "UPLOAD_TOKEN_MISMATCH",
})

# Server-fault codes: our own infrastructure, or the upstream model provider,
# failed to answer at all (not merely busy or throttling). These route to
# "dialog", which is also the default; the set is explicit so _report_policy
# can treat them as a SHIPPED classification and refuse to let a server extra
# silence them. Losing this signal during an incident is precisely when we can
# least afford it.
_SERVER_FAULT_CODES = frozenset({
    "TIMEOUT", "GENERATION_TIMED_OUT",
    "SERVER_ERROR", "RATE_LIMITER_DOWN", "STORAGE_UNAVAILABLE", "SIGN_FAILED",
    "UPSTREAM_UNAVAILABLE", "UPSTREAM_EMPTY", "PROVIDER_ERROR", "PROVIDER_BAD_RESPONSE",
})

# Failures where a credit is never kept: the generation either never reached the
# charge point, or the server auto-refunds the failed job. We append a short
# "Aucun crédit utilisé." so the user is reassured on EVERY such error. The
# messages that already spell out the refund (download/write/timeout) and the
# model-failure branch carry their own wording and are intentionally excluded.
_CREDIT_REASSURE_CODES = frozenset({
    "GENERATION_FAILED", "SERVER_ERROR", "PROVIDER_ERROR", "PROVIDER_BAD_RESPONSE",
    "UPSTREAM_UNAVAILABLE", "UPSTREAM_EMPTY", "EMPTY_RESPONSE",
    "IMAGE_FORMAT_UNSUPPORTED", "STORAGE_UNAVAILABLE", "RATE_LIMITED",
    "RATE_LIMITER_DOWN", "MISCONFIGURED", "DB_ERROR", "NOT_AVAILABLE",
    "NOT_READY",
})


def _is_prompt_blocked(normalized_code: str) -> bool:
    """True when the server refused the prompt on content grounds.

    Code only, never the message: a substring rule is how the model-failure
    hints work, and it is exactly what must not decide this one. "blocked" and
    "policy" turn up in provider text for failures that are not verdicts, and
    a user prompt containing the word would end up quoted back inside the
    server's error string. A refusal is a decision the server states."""
    return normalized_code in PROMPT_BLOCKED_CODES


def _prompt_blocked_message(error: str, code: str) -> str:
    """The whole message shown for a refused prompt: what happened, that it
    cost nothing, and where the rules are once that page exists."""
    parts = [_localize_server_error(error, code), tr("You have not been charged.")]
    url = content_policy_url()
    if url:
        parts.append(f'<a href="{url}">{_served_cta(code) or tr("Read our content rules")}</a>')
    return " ".join(parts)


def _report_policy(normalized_code: str) -> str:
    """Return 'none' | 'link' | 'dialog' for a failure code (see tier comment).

    Shipped classifications are authoritative and are resolved BEFORE the
    server extras (error_policy.*_extra, uppercased). A deploy can therefore
    classify a code the plugin does not classify yet, but can never relax one
    it already ships: naming a shipped code in an extra is ignored, not
    honoured.

    Order matters. Testing the extras first (as this did) let a purely ADDITIVE
    user_fixable_extra silence a code shipped as transient or as a server
    fault - DNS_ERROR dropped from "link" to "none", SERVER_ERROR from "dialog"
    to "none" - which is the one outcome the union-only rule exists to
    prevent."""
    # A verdict on the prompt: nothing to report, and a modal asking the user
    # to send us a log about a rule we enforced would be absurd.
    if _is_prompt_blocked(normalized_code):
        return "none"
    if normalized_code in _USER_FIXABLE_CODES:
        return "none"
    if normalized_code in _TRANSIENT_CODES:
        return "link"
    if normalized_code in _SERVER_FAULT_CODES:
        return "dialog"
    if normalized_code in get_export_dial_list(
        "error_policy.user_fixable_extra", (), normalize=str.upper
    ):
        return "none"
    if normalized_code in get_export_dial_list(
        "error_policy.transient_extra", (), normalize=str.upper
    ):
        return "link"
    return "dialog"


# Codes whose CTA is a link to the account page. The SHAPE of a CTA (a link,
# and which link) stays shipped: a served entry replaces the words, never the
# destination, so an error message can never send a click somewhere new.
_DASHBOARD_CTA_CODES = (
    "INVALID_KEY", "KEY_REVOKED",
    "SUBSCRIPTION_EXPIRED", "SUBSCRIPTION_INACTIVE", "FREE_TIER_EXPIRED",
)


def _served_cta(code: str) -> str:
    """Server replacement for the CTA words of ``code`` (error_ctas), escaped
    for the status label. Empty means keep the shipped CTA."""
    if not code:
        return ""
    served = get_export_text("error_ctas", code, _MAX_CTA_CHARS)
    return html.escape(served, quote=False) if served is not None else ""


def _enrich_error_message(error: str, code: str = "") -> str:
    """Translate the server-supplied error (via code), then append actionable
    guidance (deep links, network hints) based on the same code.

    Both halves are replaceable per code (error_messages, error_ctas) and both
    fall back on their own, so a bad entry costs one sentence rather than the
    whole message."""
    localized = _localize_server_error(error, code)
    cta = _served_cta(code)
    if code in _DASHBOARD_CTA_CODES:
        return f'{localized}. <a href="{dashboard_error_url()}">{cta or tr("Check your dashboard")}</a>'
    if code == "TRIAL_EXHAUSTED":
        # upgrade_url has always been the server's say on where to buy; routed
        # through the same validation now, so a malformed one falls back to a
        # working link instead of rendering into the anchor.
        upgrade = get_server_url("upgrade_url", get_dashboard_url())
        return f'{localized}. <a href="{upgrade}">{cta or tr("Subscribe")}</a>'
    if code == "DEVICE_LIMIT_EXCEEDED":
        return f'{localized} <a href="{dashboard_error_url()}">{cta or tr("Manage your computers")}</a>'
    if code == "PROXY_ERROR":
        return f"{localized}. {cta or tr('Check QGIS proxy settings: Settings > Options > Network')}"
    if code == "SSL_ERROR":
        ssl_hint = cta or tr(
            "If you are on a corporate network, ask your IT team about SSL inspection settings"
        )
        return f"{localized}. {ssl_hint}"
    if code in ("DNS_ERROR", "NO_NETWORK"):
        return f"{localized}. {cta or tr('Check your internet connection')}"
    if code == "TIMEOUT":
        return f"{localized}. {cta or tr('Try again, or check your internet speed')}"
    if code == "CONNECTION_REFUSED":
        return f"{localized}. {cta or tr('The service may be temporarily unavailable')}"
    if code == "AUTH_ERROR":
        return f'{localized}. <a href="{dashboard_error_url()}">{cta or tr("Check your dashboard")}</a>'
    # A code the plugin ships no CTA for. A served one is appended as plain
    # guidance, never as a link: nothing shipped decided where it would point.
    if cta:
        return f"{localized}. {cta}"
    return localized


# Substrings that mark a generation that the model couldn't produce (no image,
# safety block, server catch-all "couldn't complete"). The server marks these
# jobs failed and auto-refunds the credit, so they are a user-facing outcome,
# not a bug. Matched on the message because the failed-status response carries
# no specific error code (status route returns {status, error} only).
_MODEL_FAILURE_HINTS = (
    "no image",
    "no_image",
    "finish_reason",
    "no candidates",
    "block",
    "safety",
    "couldn't complete",
    "could not complete",
    "rephrasing your prompt",
    "no credit was charged",
    # Content-filter refusals. The server normalizes these into wording the
    # entries above already match, so these are a second line of defence for a
    # provider phrasing that reaches the plugin unmapped: a refusal is never a
    # bug and must not open the report dialog.
    "content_policy",
    "content policy",
    "content checker",
    "flagged",
    # No-output refusals: the model produced no image for the input (an essay /
    # role prompt pasted into the edit box, an incompatible request, a missing
    # attachment). The server marks the job failed and refunds, so this is a
    # user-facing "try rephrasing" outcome, not a bug. These match the current
    # provider phrasing that reaches the plugin as a bare failed poll:
    #   "no_media_generated: The model did not generate the expected output..."
    #   "invalid_request: Could not generate images with the given prompts..."
    "no_media_generated",
    "did not generate",
    "invalid_request",
    "could not generate",
    "cannot be processed",
)

_NON_MODEL_FAILURE_CODES = NETWORK_ERROR_CODES | frozenset({
    "QUOTA_EXCEEDED",
    "LIMIT_REACHED",
    "USAGE_LIMIT_REACHED",
    "MONTHLY_LIMIT_REACHED",
    "TRIAL_EXHAUSTED",
    "NO_KEY",
    "INVALID_KEY",
    "KEY_REVOKED",
    "AUTH_LOCKED",
    "SUBSCRIPTION_EXPIRED",
    "SUBSCRIPTION_INACTIVE",
    "FREE_TIER_EXPIRED",
    "AUTH_ERROR",
    "GENERATION_CANCELLED",
})


# The refusal subset of the hints above: the model declined the request rather
# than failing to draw it. It selects the "blocked by a safety filter" wording
# over the generic "returned no image" one, so it has to move when a
# provider rephrases its refusal, which is what the union with
# error_hints.safety_block is for.
_SAFETY_BLOCK_HINTS = (
    "block",
    "safety",
    "content_policy",
    "content policy",
    "content checker",
    "flagged",
)


def _is_safety_block(message: str) -> bool:
    """True when a model failure reads as a refusal rather than a miss. Call
    only on a message _is_model_failure already matched."""
    text = (message or "").lower()
    hints = get_export_dial_list(
        "error_hints.safety_block", _SAFETY_BLOCK_HINTS, normalize=str.lower
    )
    return any(needle in text for needle in hints)


def _is_model_failure(message: str, normalized_code: str) -> bool:
    """True when the model couldn't produce an image (no-output / safety block /
    server catch-all). Network / auth / quota / cancel codes are excluded so
    only genuine generation failures (which the server auto-refunds) match."""
    if normalized_code in _NON_MODEL_FAILURE_CODES:
        return False
    # A refusal never reached the model. Its message says "blocked" and
    # "content policy", which the hints below match word for word, so without
    # this the two are indistinguishable and the caller shows the wrong one.
    if _is_prompt_blocked(normalized_code):
        return False
    text = (message or "").lower()
    # Server-extendable (error_hints.model_failure): when the provider changes
    # phrasing, ops adds the new substring without a plugin release. Lowercased
    # defensively so a cased config entry still matches.
    hints = get_export_dial_list(
        "error_hints.model_failure", _MODEL_FAILURE_HINTS, normalize=str.lower
    )
    return any(needle in text for needle in hints)


# Transient capacity errors: the image service is momentarily overloaded. The
# user was not charged (the server refunds), so show a calm "try again" message
# inline rather than the bug-report dialog.
_BUSY_HINTS = (
    "resource exhausted",
    "resource_exhausted",
    "too many requests",
    "rate limit",
    "rate-limit",
    "service is busy",
    "busy right now",
    "try again in a moment",
)


# Codes that mean the same thing as the hints above. Read-through like its
# siblings in core.errors, so a capacity code the provider starts sending is
# one deploy away from a calm retry message instead of a bug-report dialog.
# Union-only: a deploy adds a code, never drops one.
_BUSY_CODES = ServerDialSet(
    "error_codes.busy_extra",
    {"RATE_LIMITED", "RESOURCE_EXHAUSTED", "PROVIDER_BUSY", "SERVICE_BUSY"},
    normalize=str.upper,
)


def _is_service_busy(message: str, normalized_code: str) -> bool:
    if normalized_code in _BUSY_CODES:
        return True
    text = (message or "").lower()
    # Server-extendable (error_hints.busy), lowercased defensively.
    hints = get_export_dial_list("error_hints.busy", _BUSY_HINTS, normalize=str.lower)
    return any(needle in text for needle in hints)


def _resolve_class_label(
    vector_color: str | None, vector_classes: list[dict] | None
) -> str:
    """Look up the semantic class name for ``vector_color`` in the multi-class
    catalog. Empty string when the template only declares a color, leaving
    the field blank in the table for the user to fill in (autocomplete kicks
    in after the first value is typed).
    """
    if not vector_color or not isinstance(vector_classes, list):
        return ""
    target = vector_color.upper().lstrip("#")
    for entry in vector_classes:
        if not isinstance(entry, dict):
            continue
        color = (entry.get("color") or "").upper().lstrip("#")
        if color and color == target:
            label = entry.get("label")
            if isinstance(label, str):
                return label.strip()
    return ""
