from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any, Mapping, Protocol

from relationalai.client.errors.handlers import collect_error_messages
from rich.markup import escape as rich_escape


@dataclass(frozen=True)
class CliErrorContext:
    """Generic context for formatting CLI errors.

    Keep this intentionally generic so new services/commands don't require adding
    fields here; pass per-command values via `params`.
    """

    command: str
    params: Mapping[str, Any]


class CliErrorHandler(Protocol):
    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool: ...
    def format_details(self, error: Exception, ctx: CliErrorContext) -> str: ...


def _contains_any(messages: list[str], needles: tuple[str, ...]) -> bool:
    def _norm(s: str) -> str:
        # Snowflake/DA wrappers can inject newlines/indentation/box-drawing chars.
        # Normalize to a whitespace-collapsed lowercase string for robust matching.
        return " ".join(str(s).split()).lower()

    ns = tuple(_norm(n) for n in needles if isinstance(n, str) and n)
    if not ns:
        return False

    for msg in messages:
        if not isinstance(msg, str) or not msg:
            continue
        m = _norm(msg)
        if any(n in m for n in ns):
            return True
    return False


def _extract_service_message(text: str) -> str | None:
    """Best-effort extract of the backend 'message' field."""
    if not text:
        return None

    m = re.search(r'"message"\s*:\s*"([^"]+)"', text, flags=re.IGNORECASE)
    if m:
        return m.group(1).strip()

    m = re.search(r"bad request\s*-\s*([^\(\n]+)", text, flags=re.IGNORECASE)
    if m:
        return m.group(1).strip()

    return None


def _replace_engine_words(text: str) -> str:
    # We may need to *match* backend strings containing "engine", but we should not
    # show that word to users in CLI output.
    out = re.sub(r"\bengine\b", "reasoner", text, flags=re.IGNORECASE)
    out = re.sub(r"\bengines\b", "reasoners", out, flags=re.IGNORECASE)
    return out


def _param(ctx: CliErrorContext, key: str) -> str | None:
    v = ctx.params.get(key)
    if v is None:
        return None
    s = str(v).strip()
    return s or None


def _first_message_matching(messages: list[str], needles: tuple[str, ...]) -> str | None:
    """Return the first collected message containing any needle (case-insensitive)."""
    ns = tuple(str(n).strip().lower() for n in needles if isinstance(n, str) and n.strip())
    if not ns:
        return None
    for msg in messages:
        if not isinstance(msg, str) or not msg.strip():
            continue
        m = " ".join(msg.split()).lower()
        if any(n in m for n in ns):
            return msg.strip()
    return None


def _normalize_equivalent_message(text: str) -> str:
    """Normalize an error message for loose equivalence checks.

    Used to suppress redundant "Details:" lines when the backend repeats the same
    text with minor punctuation/casing/whitespace differences.
    """
    s = str(text or "")
    # Collapse whitespace (newlines, tabs, repeated spaces).
    s = " ".join(s.split()).strip()
    # Case-insensitive comparisons; `casefold` handles more unicode edge cases than `lower`.
    s = s.casefold()
    # Treat common trailing punctuation as non-semantic.
    s = re.sub(r"[.!?,;:]+$", "", s).strip()
    return s


def _escape_detail(text: str) -> str:
    """Escape user/backend-provided text for use in Rich markup strings."""
    return rich_escape("" if text is None else str(text))


def _reasoner_type_name(ctx: CliErrorContext) -> tuple[str | None, str | None]:
    reasoner_type = _param(ctx, "job_type") or _param(ctx, "reasoner_type") or _param(ctx, "type")
    reasoner_name = _param(ctx, "reasoner_name") or _param(ctx, "name")
    return reasoner_type, reasoner_name


def _reasoner_type_label(reasoner_type: str | None) -> str | None:
    """Prefer CLI-facing reasoner type labels (Logic/Prescriptive/Predictive)."""
    if reasoner_type is None:
        return None
    s = str(reasoner_type).strip()
    if not s:
        return None
    # Jobs service uses SOLVER/ML as type discriminators; keep CLI labels stable.
    try:
        from relationalai.tools.cli.job_types import job_type_label

        jl = job_type_label(s)
        if jl:
            return jl
    except Exception:
        pass
    try:
        from relationalai.services.reasoners.models import ReasonerType

        return ReasonerType.get_label(ReasonerType.normalize(s))
    except Exception:
        return s


def _green(cmd: str) -> str:
    return f"[green]{cmd}[/green]"


class SnowflakeAuthenticationHandler:
    # Common Snowflake auth/SSO failures (SAML / invalid user mapping).
    _NEEDLES = (
        "unable to authenticate to snowflake",
        "saml response is invalid",
        "matching user is not found"
    )

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:  # noqa: ARG002
        messages = collect_error_messages(error)
        detail = _first_message_matching(messages, self._NEEDLES) or (str(error) or repr(error))
        detail = detail.replace('\\"', '"').strip()

        lines: list[str] = ["Problem: unable to authenticate to Snowflake."]
        if detail and _normalize_equivalent_message(detail) != _normalize_equivalent_message("unable to authenticate to snowflake"):
            lines.append(f"Details: {_escape_detail(detail)}")
        lines.append("")
        lines.append("Fix: verify your Snowflake login/SSO works (try the Snowflake web UI).")
        lines.append("If you see a SAML error there too, contact your Snowflake admin to resolve the user/IdP mapping.")
        return "\n".join(lines)


class TransactionEventsNotFoundForUserHandler:
    _NEEDLES = ("GET_TRANSACTION_EVENTS", "not found for user", '"status":"Not Found"')

    _TX_NOT_FOUND_RE = re.compile(
        r'transaction\s+"(?P<transaction_id>[^"]+)"\s+not found for user\s+"(?P<user>[^"]+)"',
        flags=re.IGNORECASE,
    )

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        # Only relevant for the jobs events command (LOGIC transaction events).
        if str(getattr(ctx, "command", "") or "").strip() != "jobs:events":
            return False
        raw_norm = (str(error) or repr(error)).replace('\\"', '"')
        if self._TX_NOT_FOUND_RE.search(raw_norm):
            return True
        hay = " ".join(" ".join(str(m).split()).lower() for m in messages if isinstance(m, str))
        return ("get_transaction_events" in hay) and ("not found for user" in hay)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)
        # Snowflake wrapper errors often escape quotes as `\"` inside an embedded JSON string.
        raw_norm = raw.replace('\\"', '"')
        m = self._TX_NOT_FOUND_RE.search(raw_norm)
        transaction_id = m.group("transaction_id").strip() if m else None
        user = m.group("user").strip() if m else None

        job_id = _param(ctx, "job_id") or _param(ctx, "id")
        reasoner_type, _reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = ["Problem: job events were not found for the current user."]
        if job_id:
            lines.append(f"Job ID: {_escape_detail(job_id)}")
        if reasoner_type_label:
            lines.append(f"Type: {_escape_detail(reasoner_type_label)}")
        if transaction_id:
            lines.append(f"Transaction: {_escape_detail(transaction_id)}")
        if user:
            lines.append(f"User: {_escape_detail(user)}")

        lines.append("")
        lines.append("Note: job IDs are only valid for the user that created the job.")
        lines.append("Fix: choose a job you created, then retry.")
        if user:
            lines.append("  " + _green(f"rai jobs:list --created-by {_escape_detail(user)}"))
        else:
            lines.append("  " + _green("rai jobs:list"))

        return "\n".join(lines)


class InvalidJobTypeForReasonerHandler:
    _NEEDLES = ("CREATE_JOB", "invalid job type")

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        # Keep this narrow: many failures may mention CREATE_JOB, but we only want
        # the explicit "invalid job type" case.
        hay = " ".join(" ".join(str(m).split()).lower() for m in messages if isinstance(m, str))
        if "invalid job type" not in hay:
            return False
        if "create_job" not in hay:
            return False
        # Don't overtake more specific reasoner resource errors.
        if ("engine not found" in hay) or ("reasoner not found" in hay):
            return False
        return True

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)
        service_msg = _extract_service_message(raw) or "invalid job type"

        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = ["Problem: job type does not match the reasoner type."]
        lines.append(f"Details: {_escape_detail(service_msg)}.")
        if reasoner_name:
            lines.append(f"Reasoner: {_escape_detail(reasoner_name)}")
        if reasoner_type_label:
            lines.append(f"Job type: {_escape_detail(reasoner_type_label)}")

        lines.append("")
        lines.append("Fix: use the reasoner's actual type with `--type`.")
        if reasoner_name:
            lines.append("  " + _green(f"rai reasoners:list --name {_escape_detail(reasoner_name)}"))
        lines.append("  " + _green("rai jobs:create --type <Logic|Prescriptive|Predictive> --reasoner-name <name> ..."))

        return "\n".join(lines)


class SnowflakeAuthErrorHandler:
    _NEEDLES = (
        "saml response is invalid",
        "matching user is not found",
        "unable to authenticate to snowflake",
        "authentication",
        "sso",
    )

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        # Only target Snowflake/SQL-like auth failures (common in reasoners/list on SQL backend).
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:  # noqa: ARG002
        raw = str(error) or repr(error)
        raw = _replace_engine_words(raw)
        lines: list[str] = ["Problem: unable to authenticate to Snowflake."]
        if raw:
            lines.append(f"Details: {raw}")
        return "\n".join(lines)


class TransactionPayloadMissingRequiredFieldsHandler:
    _NEEDLES = (
        "transaction create requires payload",
    )

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        if str(getattr(ctx, "command", "") or "").strip() != "jobs:create":
            return False
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = ["Problem: you selected Logic jobs (transactions) but the payload is missing required fields."]
        if reasoner_name:
            lines.append(f"Reasoner: {reasoner_name}")
        if reasoner_type_label:
            lines.append(f"Job type: {reasoner_type_label}")

        lines.append("")
        lines.append("Fix:")
        lines.append("- If you meant a Prescriptive job, use `--type Prescriptive`")
        lines.append("- If you meant a Logic transaction, provide `--database` and `--raw-code` (or a payload with dbname/query).")

        if reasoner_name:
            lines.append("")
            lines.append("Examples:")
            lines.append("  " + _green(f"rai jobs:create --type Prescriptive --name {reasoner_name} --payload-json ..."))
            lines.append("  " + _green(f"rai jobs:create --type Logic --name {reasoner_name} --database DB --raw-code '...'"))

        return "\n".join(lines)


class JobCreateNotReadyHandler:
    """Format backend "not ready" job-create failures into a CLI-facing hint.

    This handler is intentionally narrow:

    - It only activates for ``rai jobs:create`` failures.
    - It matches a small set of backend phrases that have been observed across
      jobs/transactions responses (for example "worker is not ready to accept
      jobs" or "job request could not be completed yet").
    - It rewrites backend implementation words like ``engine`` / ``worker`` to
      the user-facing ``reasoner`` terminology before showing details.

    The goal is to keep the CLI robust to small variations in backend wording
    while still avoiding broad matches that could hide unrelated failures behind
    a misleading "reasoner is not ready" message.
    """
    _NEEDLES = (
        "not ready to accept jobs",
        "job request could not be completed yet",
    )

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:
        if str(getattr(ctx, "command", "") or "").strip() != "jobs:create":
            return False
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)
        # Prefer structured backend details when available, then fall back to a
        # best-effort extraction from wrapped HTTP error strings, and finally to
        # a stable generic message if the backend did not provide anything useful.
        service_msg = getattr(error, "details", None) or _extract_service_message(raw) or "reasoner is not ready to accept jobs"
        service_msg = _replace_engine_words(service_msg)
        service_msg = re.sub(r"\bworker\b", "reasoner", service_msg, flags=re.IGNORECASE)

        operation = getattr(error, "operation", None) or _param(ctx, "operation") or "create"
        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = ["Problem: the reasoner is not ready to accept jobs yet."]
        lines.append(f"Details: {_escape_detail(service_msg)}.")
        if reasoner_name:
            lines.append(f"Reasoner: {_escape_detail(reasoner_name)}")
        if reasoner_type_label:
            lines.append(f"Type: {_escape_detail(reasoner_type_label)}")
        if operation:
            lines.append(f"Operation: {_escape_detail(operation)}")

        lines.append("")
        lines.append("Fix: check whether the reasoner is still starting or suspended, then retry the job.")
        if reasoner_type_label and reasoner_name:
            lines.append(
                "  "
                + _green(
                    f"rai reasoners:get --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                )
            )
            lines.append(
                "  "
                + _green(
                    f"rai reasoners:resume --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                )
            )

        return "\n".join(lines)


class ReasonerAlreadyExistsHandler:
    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        # Be tolerant: backend/wrappers may change punctuation/casing.
        hay = " ".join(m for m in messages if isinstance(m, str)).lower()
        return ("reasoner already exists" in hay) or ("already exists but does not match requested" in hay)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)

        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)
        requested_size = _param(ctx, "reasoner_size") or _param(ctx, "size")

        # Best-effort state extraction from common conflict string.
        state = None
        m = re.search(r"\bstate=([A-Z_]+)\b", raw)
        if m:
            state = m.group(1).strip()

        lines: list[str] = ["Problem: a reasoner with the same name and type already exists."]
        if reasoner_name:
            lines.append(f"Reasoner: {_escape_detail(reasoner_name)}")
        if reasoner_type_label:
            lines.append(f"Type: {_escape_detail(reasoner_type_label)}")
        if state:
            lines.append(f"State: {_escape_detail(state)}")

        lines.append("")
        if requested_size:
            lines.append(f"Requested size: {_escape_detail(requested_size)}")
        lines.append("Note: reasoner size cannot be changed in-place.")
        lines.append("Fix: delete the existing reasoner, then create it again with the desired size.")

        if reasoner_type_label and reasoner_name:
            lines.append(
                "  "
                + _green(
                    f"rai reasoners:delete --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                )
            )
            if requested_size:
                lines.append(
                    "  "
                    + _green(
                        f"rai reasoners:create --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)} --size {_escape_detail(requested_size)}"
                    )
                )
            else:
                lines.append(
                    "  "
                    + _green(
                        f"rai reasoners:create --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                    )
                )

        return "\n".join(lines)


class SuspendedReasonerHandler:
    _NEEDLES = ("engine is suspended", "reasoner is suspended")

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:  # noqa: ARG002
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)
        service_msg = _extract_service_message(raw) or "reasoner is suspended"
        service_msg = _replace_engine_words(service_msg)

        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = [f"Problem: {_escape_detail(service_msg)}."]
        if reasoner_name:
            lines.append(f"Reasoner: {_escape_detail(reasoner_name)}")
        if reasoner_type_label:
            lines.append(f"Type: {_escape_detail(reasoner_type_label)}")

        if reasoner_type_label and reasoner_name:
            lines.append("")
            lines.append("Fix: resume the reasoner, then retry.")
            lines.append(
                "  "
                + _green(
                    f"rai reasoners:resume --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                )
            )

        return "\n".join(lines)


class ReasonerNotFoundHandler:
    _NEEDLES = ("engine not found", "reasoner not found")

    def matches(self, error: Exception, messages: list[str], ctx: CliErrorContext) -> bool:
        return _contains_any(messages, self._NEEDLES)

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        raw = str(error) or repr(error)
        service_msg = _extract_service_message(raw) or "reasoner not found"
        service_msg = _replace_engine_words(service_msg)

        reasoner_type, reasoner_name = _reasoner_type_name(ctx)
        reasoner_type_label = _reasoner_type_label(reasoner_type)

        lines: list[str] = [f"Problem: {_escape_detail(service_msg)}."]
        if reasoner_name:
            lines.append(f"Reasoner: {_escape_detail(reasoner_name)}")
        if reasoner_type_label:
            lines.append(f"Type: {_escape_detail(reasoner_type_label)}")

        if reasoner_type_label and reasoner_name:
            lines.append("")
            lines.append("Fix: create the reasoner, then retry.")
            lines.append(
                "  "
                + _green(
                    f"rai reasoners:create --type {_escape_detail(reasoner_type_label)} --name {_escape_detail(reasoner_name)}"
                )
            )
        else:
            lines.append("")
            lines.append("Fix: create the reasoner, then retry.")
            lines.append(f"  {_green('rai reasoners:create')}")

        return "\n".join(lines)


@dataclass(frozen=True)
class CliErrorHandlerChain:
    handlers: list[CliErrorHandler]

    def format_details(self, error: Exception, ctx: CliErrorContext) -> str:
        # Use the SDK's robust message collector (includes args/cause/context and
        # extracts embedded JSON message fields in Snowflake wrapper errors).
        messages = collect_error_messages(error)

        for h in self.handlers:
            try:
                if h.matches(error, messages, ctx):
                    return h.format_details(error, ctx)
            except Exception:
                continue

        return str(error) or repr(error)


DEFAULT_CLI_ERROR_HANDLER_CHAIN = CliErrorHandlerChain(
    handlers=[
        SnowflakeAuthenticationHandler(),
        TransactionEventsNotFoundForUserHandler(),
        InvalidJobTypeForReasonerHandler(),
        SnowflakeAuthErrorHandler(),
        TransactionPayloadMissingRequiredFieldsHandler(),
        JobCreateNotReadyHandler(),
        ReasonerAlreadyExistsHandler(),
        SuspendedReasonerHandler(),
        ReasonerNotFoundHandler(),
    ]
)


# ── Connection validation helpers ─────────────────────────────────────────────

def validate_session(session: Any, warehouse: str | None = None) -> None:
    """Best-effort validation across session types."""
    if hasattr(session, "execute"):
        cur = session.execute("select 1")
        if hasattr(cur, "fetchall"):
            cur.fetchall()
        if warehouse:
            session.execute(f"USE WAREHOUSE {warehouse}")
        return
    if hasattr(session, "sql"):
        out = session.sql("select 1")
        if hasattr(out, "collect"):
            out.collect()
        elif hasattr(out, "fetchall"):
            out.fetchall()
        if warehouse:
            session.sql(f"USE WAREHOUSE {warehouse}").collect()
        return


# Error codes sourced from Snowflake docs and snowflake-connector-python
_SNOWFLAKE_ERROR_SUGGESTIONS: list[tuple[str, str]] = [
    (
        # 390100: Incorrect username or password
        r"390100|Incorrect username or password",
        "Wrong username or password (error 390100). Check 'user' and 'password' in your raiconfig.yaml.",
    ),
    (
        # 390144: JWT token is invalid (key-pair auth)
        r"390144|JWT token is invalid",
        "JWT authentication failed (error 390144). Common causes:\n"
        "  • Wrong 'account' identifier — must match the 'iss' claim in your JWT\n"
        "  • Public key fingerprint mismatch — re-register your public key in Snowflake\n"
        "  • Clock skew > 60s — check your system clock is synced (NTP)\n"
        "  Check 'private_key_path' and 'private_key_passphrase' in raiconfig.yaml.",
    ),
    (
        # 390114: Authentication token expired (OAuth/PAT)
        r"390114|Authentication token has expired",
        "Authentication token has expired (error 390114). Refresh your token and update 'token' or 'token_file_path' in raiconfig.yaml.",
    ),
    (
        # MFA required (exact message from Snowflake)
        r"MFA authentication is required",
        "Your Snowflake account requires MFA but the current authenticator doesn't support it.\n"
        "Switch authenticator in raiconfig.yaml:\n"
        "  connections:\n"
        "    snowflake:\n"
        "      authenticator: programmatic_access_token\n"
        "      token: <your token>\n"
        "  or\n"
        "      authenticator: externalbrowser",
    ),
    (
        # 250001: Network / account identifier issue
        r"250001|Failed to connect to DB|Could not connect to Snowflake backend",
        "Could not reach Snowflake (error 250001). Check:\n"
        "  • 'account' identifier in raiconfig.yaml (e.g. myorg-myaccount)\n"
        "  • Network connectivity and firewall rules\n"
        "  • VPN if required by your organization",
    ),
    (
        # 002043: USE WAREHOUSE fails — warehouse doesn't exist or role lacks USAGE privilege
        r"002043|Object does not exist, or operation cannot be performed",
        "Warehouse not found or not accessible. Check the 'warehouse' value in raiconfig.yaml — it must exist and your role must have USAGE privilege on it.",
    ),
    (
        # SSL errors
        r"CERTIFICATE_VERIFY_FAILED|SSL|bad handshake",
        "SSL/TLS error. Your network proxy or firewall may be intercepting Snowflake traffic. Check your SSL certificates and proxy settings.",
    ),
]


def get_connection_suggestion(error_msg: str) -> str | None:
    """Return an actionable suggestion for known Snowflake error patterns."""
    for pattern, suggestion in _SNOWFLAKE_ERROR_SUGGESTIONS:
        if re.search(pattern, error_msg, re.IGNORECASE):
            return suggestion
    return None


def format_connection_info(connection: Any) -> list[tuple[str, str]]:
    """Extract key connection fields for display."""
    rows = []
    for label, attr in [
        ("Type", "type"),
        ("Authenticator", "authenticator"),
        ("Account", "account"),
        ("User", "user"),
        ("Warehouse", "warehouse"),
        ("Role", "role"),
    ]:
        value = getattr(connection, attr, None)
        if value:
            rows.append((label, str(value)))
    return rows

