# SPDX-License-Identifier: GPL-2.0-only
#
# llamachat - a small native chat client for a local llama.cpp router
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
"""Provider definitions, model-id namespacing and API key resolution."""

import math
import os
import subprocess
import sys
import threading
from dataclasses import dataclass, field

# The local llama.cpp router. Its models are shown and stored without a
# prefix, so an existing session pointing at a local model still resolves.
LOCAL = "local"

# A stuck pinentry must not freeze the worker thread forever. Generous
# because a legitimate unlock is slow: a graphical pinentry plus a hardware
# token the user has to physically find and touch is a normal minute. This
# bounds the pathological case, it is not a deadline for the human.
KEY_TIMEOUT = 120


class KeyResolutionError(Exception):
    """An api_key that could not be resolved, phrased for the user."""


@dataclass
class Provider:
    name: str
    base_url: str
    api_key: str = ""
    filter: list[str] = field(default_factory=list)
    # None rather than 0: unset must stay distinguishable from "zero".
    ctx_size: int | None = None
    vision: bool | None = None
    price_in: float | None = None
    price_out: float | None = None

    @property
    def is_local(self) -> bool:
        return self.name == LOCAL


def _number(raw, cast):
    if raw is None or raw == "":
        return None
    try:
        value = cast(raw)
    except (TypeError, ValueError):
        return None
    # float() accepts "nan" and "inf", which would survive every later check
    # and surface as a "$nan" or "$-inf" cost. Caught here rather than at each
    # cost call site, so the arithmetic downstream can assume a real number.
    # Kept textually parallel to models._get, which guards the other writer.
    if cast is float and not math.isfinite(value):
        return None
    return value


def _not_a_table(name: str, entry) -> str:
    """Why this entry is not a provider table, phrased for what was written.

    A list is almost always the [[providers.x]] double-bracket slip, so the
    message names the single-bracket fix directly: "is not a table" alone does
    not tell the user which character to change. Any other scalar was written
    as `x = 5`, where suggesting a bracket fix would point at a line the user
    never wrote, so that case just describes the shape.
    """
    if isinstance(entry, list):
        return f"use [providers.{name}], not [[providers.{name}]]"
    return f"a provider must be a [providers.{name}] table"


def _skipped(name: str, reason: str, warnings: list[str] | None = None) -> None:
    """Say that a provider was dropped, so the loss is not silent.

    A skipped provider is invisible: its models are simply absent from the
    picker, which looks identical to the provider being down. One stderr line
    gives a user running from a terminal something to act on.

    When `warnings` is provided, the message is also collected there so the
    UI can surface it in the status bar — a desktop launch has no terminal,
    so stderr alone is invisible to the user.

    ponytail: stderr only, so a launch from a .desktop file still says
    nothing. Surfacing this in the window is Task 12's job; there is no
    window to put it in yet.
    """
    msg = f"ignoring provider {name!r}: {reason}"
    print(f"llamachat: {msg}", file=sys.stderr)
    if warnings is not None:
        warnings.append(msg)


def parse(values: dict, warnings: list[str] | None = None) -> dict[str, Provider]:
    """Build the provider table from already-loaded config values.

    Takes the raw dict rather than a path so config.py owns file reading and
    this stays testable without touching disk.

    When `warnings` is provided, skipped-provider messages are collected
    there so the UI can surface them in the status bar.
    """
    raw = values.get("providers")
    # `providers = "oops"` is valid TOML, and coercing it raises: ValueError
    # for a string or list, TypeError for a number, which is why this is an
    # isinstance test rather than a try/except. A providers key that is not a
    # table is not a partial table, it is a different shape entirely, so fall
    # through to the bare base_url path below and keep local working.
    table = dict(raw) if isinstance(raw, dict) else {}

    # An old config has only a bare base_url. Fill it in as the local
    # provider's URL so nothing needs migrating, but never override an
    # explicit one. The test is the URL rather than the key: a
    # [providers.local] that only sets an api_key is adding detail to the
    # provider the user already has, not replacing it, and treating it as a
    # replacement would silently delete local entirely.
    #
    # The local entry is read through isinstance too, not just truthiness: a
    # [[providers.local]] is a truthy list, and merging into it would raise
    # here, before the loop below ever gets a chance to skip it.
    bare = values.get("base_url")
    raw_local = table.get(LOCAL)
    if raw_local is not None and not isinstance(raw_local, dict):
        # Warned about here rather than left to the loop, which never sees it:
        # the merge below replaces it with a synthesized entry, so every field
        # the user set on it is dropped. That makes this the case where saying
        # something matters most, not least. The app comes up working, local
        # answers, and an api_key or ctx_size they set is simply gone, with a
        # healthy-looking window as the only feedback.
        _skipped(LOCAL, _not_a_table(LOCAL, raw_local), warnings)
    local = raw_local if isinstance(raw_local, dict) else {}
    if bare and not local.get("base_url"):
        table[LOCAL] = {**local, "base_url": bare}

    out: dict[str, Provider] = {}
    for name, entry in table.items():
        name = str(name)
        if not isinstance(entry, dict):
            # Most likely [[providers.x]] written for [providers.x], which
            # TOML reads as a list of tables. Skipping keeps every other
            # provider working, including local.
            _skipped(name, _not_a_table(name, entry), warnings)
            continue
        if not name or ":" in name:
            # The id scheme splits on the first colon, so a name containing
            # one builds ids that split back to something else entirely, and
            # an empty name builds ":model". Both route to local under a
            # nonsense name, silently. Skipping is the only honest option.
            _skipped(name, "a provider name cannot be empty or contain ':'", warnings)
            continue
        base_url = str(entry.get("base_url") or "").rstrip("/")
        if not base_url:
            # ponytail: a provider with no URL is misconfigured, not a
            # partial one. Skipping beats inventing a default endpoint.
            _skipped(name, "it has no base_url", warnings)
            continue
        # filter = "qwen" is an easy TOML slip for filter = ["qwen"], and
        # iterating the string would turn it into four single-character
        # needles that match nearly every model id. The same goes for every
        # other non-sequence shape: a number would raise on iteration and a
        # dict would quietly degrade into its keys, so wrap anything that is
        # not a list as a single needle and let it simply match nothing.
        needles = entry.get("filter") or []
        if not isinstance(needles, (list, tuple)):
            needles = [needles]
        vision = entry.get("vision")
        out[name] = Provider(
            name=name,
            base_url=base_url,
            api_key=str(entry.get("api_key") or ""),
            filter=[str(f) for f in needles],
            ctx_size=_number(entry.get("ctx_size"), int),
            vision=None if vision is None else bool(vision),
            price_in=_number(entry.get("price_in"), float),
            price_out=_number(entry.get("price_out"), float),
        )
    return out


def qualify(provider: str, model: str) -> str:
    """The stored, displayed id for one model. Local models stay bare."""
    if provider == LOCAL:
        return model
    return f"{provider}:{model}"


def split(model_id: str, table: dict[str, Provider]) -> tuple[str, str]:
    """Inverse of qualify, resolved against the configured providers.

    A prefix that is not a configured provider is treated as part of the
    model name, which keeps a bare local model containing a colon working.
    """
    # ponytail: this is not injective, and cannot be while local ids stay
    # bare. A local model actually named "together:x" is indistinguishable
    # from together's model "x", so it resolves to local only until the user
    # configures a provider called "together", at which point the stored
    # session id rebinds to the cloud model and starts billing without a
    # word. Unlikely, since local names are usually filenames, and the bare
    # local id is the feature's premise. The upgrade path is to qualify local
    # as "local:<model>" too and migrate the sessions.model column.
    prefix, sep, rest = model_id.partition(":")
    if sep and prefix in table and prefix != LOCAL:
        return prefix, rest
    return LOCAL, model_id


def apply_filter(provider: Provider, listed: list[str]) -> list[str]:
    """Keep models matching any of the provider's substrings.

    Case-insensitive, because provider ids capitalise inconsistently:
    "qwen" has to match "Qwen/Qwen2.5-72B-Instruct-Turbo".
    """
    # Every path returns a fresh list on purpose: callers hold onto the
    # result, and handing back "listed" itself would let them mutate the
    # caller's own list. Not a redundant copy, do not simplify it away.
    if provider.is_local or not provider.filter:
        return list(listed)
    # An empty needle is a typo, not a request to hide every model, so a
    # filter of only empty strings means no filter.
    needles = [f.lower() for f in provider.filter if f]
    if not needles:
        return list(listed)
    return [m for m in listed if any(n in m.lower() for n in needles)]


def _run_pass(cmd: list[str], timeout: int) -> str:
    """Run `pass show NAME` and return its stdout."""
    result = subprocess.run(
        cmd, capture_output=True, text=True, timeout=timeout, check=True
    )
    return result.stdout


class KeyResolver:
    """Resolves api_key fields, lazily and once per process.

    Lazy matters: `pass` needs the GPG key, so a session that only touches
    local models must never trigger a pinentry. Resolved values stay in
    memory and are never written anywhere.
    """

    def __init__(self, runner=_run_pass):
        self._runner = runner
        # Locked: resolve() now runs on worker threads (StreamWorker.run and
        # TitleWorker.run can overlap), so the lock serializes the entire
        # resolve body, including the subprocess call. Guarding only the dict
        # would leave both threads outside the lock during `pass show`, which
        # is the double-prompt window: two threads miss the cache together,
        # spawn two `pass` processes and raise two pinentry prompts for one
        # hardware token.
        self._cache: dict[tuple[str, str], str] = {}
        self._lock = threading.Lock()

    def resolve(self, provider: Provider) -> str:
        """The bearer token for this provider, or '' when it needs none."""
        spec = provider.api_key
        if not spec:
            return ""
        # Keyed by the spec as well as the name: the config can be reloaded
        # onto the same resolver, and keying by name alone would keep serving
        # the old key after the user edits the entry to fix it, so the fix
        # would look like it did nothing until a restart.
        cache_key = (provider.name, spec)
        with self._lock:
            if cache_key in self._cache:
                return self._cache[cache_key]

            if spec.startswith("env:"):
                value = os.environ.get(spec[4:], "")
                if not value:
                    raise KeyResolutionError(
                        f"{provider.name}: environment variable {spec[4:]} is not set"
                    )
            elif spec.startswith("pass:"):
                value = self._from_pass(provider, spec[5:])
            else:
                value = spec

            self._cache[cache_key] = value
            return value

    def _from_pass(self, provider: Provider, entry: str) -> str:
        try:
            out = self._runner(["pass", "show", entry], timeout=KEY_TIMEOUT)
        # Every raise below is `from None`. It suppresses the chained-traceback
        # display only, __context__ still references the original with its
        # .stdout, so the real guarantee is that no handler here puts stdout
        # in the message.
        except subprocess.TimeoutExpired:
            # The likely cause is a pinentry that never appeared, not one
            # sitting in front of the user: no $DISPLAY inherited, no
            # gpg-agent, or pinentry-qt failing to open and falling back to a
            # curses prompt on a terminal a GUI app has no stdin for.
            raise KeyResolutionError(
                f"{provider.name}: `pass show {entry}` timed out after "
                f"{KEY_TIMEOUT}s. If no pinentry appeared, check that "
                f"gpg-agent is running and that pinentry-qt can open a window."
            ) from None
        except FileNotFoundError:
            # Errno 2 alone does not say which file is missing, and this is
            # the common "pass was never installed" case.
            raise KeyResolutionError(
                f"{provider.name}: `pass` is not installed or not on PATH"
            ) from None
        except subprocess.CalledProcessError as exc:
            # stderr is captured, so without this it is swallowed and the user
            # gets an exit code where gpg told them exactly what was wrong.
            # Only stderr is quoted, never stdout, which holds the secret.
            detail = (exc.stderr or "").strip().splitlines()
            reason = detail[-1] if detail else f"exit status {exc.returncode}"
            raise KeyResolutionError(
                f"{provider.name}: `pass show {entry}` failed: {reason}"
            ) from None
        except Exception as exc:
            raise KeyResolutionError(
                f"{provider.name}: `pass show {entry}` failed: {exc}"
            ) from None
        # A password store entry keeps the secret on the first line and
        # metadata below it.
        first = (out or "").strip().splitlines()
        if not first or not first[0].strip():
            raise KeyResolutionError(
                f"{provider.name}: `pass show {entry}` returned nothing"
            )
        return first[0].strip()
