"""macOS Keychain secret accessor for Cyclone.

SP9. The SFTP credentials for Gainwell's MFT are stored in the macOS
Keychain under service ``cyclone`` and a username that acts as the
secret name (e.g. ``sftp.gainwell.password``). This module fetches
them by name.

Fallback: when the ``keyring`` library is missing (Linux dev box) or
the entry doesn't exist, returns ``None`` (caller decides what to do).
A stub secret ``<stub-secret>`` is provided for the SP9 stub flow.

Setup (one-time, by the operator):
    security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'

Verification:
    security find-generic-password -s cyclone -a sftp.gainwell.password -w

SP25 + SP26: the lookup now resolves the secret in four tiers, in
this order:

    1. ``<env_name>_FILE`` env var set — highest priority. Reads the
       file at that path, strips whitespace, returns the contents.
       This is the standard Docker-secrets pattern: mount a file at
       ``/run/secrets/<name>`` and point the env var at it. An
       operator who explicitly sets ``_FILE`` is making a positive
       statement about where the secret lives, so a missing file
       surfaces as a ``RuntimeError`` rather than silently falling
       through. Empty string is treated as unset.
    2. Plain env var named exactly ``<env_name>`` — second priority.
       Lets a Linux server or Docker container pass the MFT password
       via ``CYCLONE_SFTP_PASSWORD=...`` without touching the Keychain
       or a file mount. Trailing/leading whitespace (including the
       ``\\n`` that .env files often leave at EOF) is stripped; an
       empty value is treated as absent.
    3. macOS Keychain via ``keyring`` — third priority. Kept as a
       fallback so a macOS workstation that prefers
       ``security add-generic-password`` works without env-var exports.
    4. ``None`` — caller decides what to do (``SftpClient._connect``
       raises a precise ``RuntimeError`` on real-mode auth).
"""

from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import Optional

log = logging.getLogger(__name__)

SERVICE_NAME = "cyclone"
STUB_SECRET = "<stub-secret>"

# Try to import keyring lazily — it's an optional dep so the rest of
# the codebase doesn't fail on Linux dev boxes without it.
try:
    import keyring  # type: ignore[import-untyped]
    _HAS_KEYRING = True
except ImportError:
    keyring = None  # type: ignore[assignment]
    _HAS_KEYRING = False


def _env_var_for(name: str) -> str:
    """Resolve the operator-facing env-var name for a secret.

    Returns the entry from ``_ENV_NAME_FOR`` if present, otherwise
    returns ``name`` verbatim.
    """
    return _ENV_NAME_FOR.get(name, name)


def get_secret(name: str) -> Optional[str]:
    """Fetch a secret by name. Four-tier lookup: _FILE, env var, Keychain, None.

    Args:
        name: The secret name. Matches the Keychain account name
            (e.g. ``"sftp.gainwell.password"``). The operator-facing
            env-var form is mapped via the ``_ENV_NAME_FOR`` table at
            the bottom of this module.

    Returns:
        The secret string (stripped), or ``None`` if no tier produced
        a value.

    Raises:
        RuntimeError: if ``<env_name>_FILE`` is set but the file at
            that path is missing or unreadable. The operator made a
            positive statement about where the secret lives; silent
            fall-through would mask a misconfiguration.
    """
    env_name = _env_var_for(name)
    file_env = env_name + "_FILE"
    file_path_raw = os.environ.get(file_env)
    if file_path_raw:
        # An empty string is treated as unset (matches the SP25 rule
        # for plain env vars).
        stripped_path = file_path_raw.strip()
        if stripped_path:
            try:
                value = Path(stripped_path).read_text().strip()
            except OSError as exc:
                raise RuntimeError(
                    f"failed to read {file_env}={stripped_path}: {exc}"
                ) from exc
            if value:
                log.debug("Secret %r resolved from file %r", name, stripped_path)
                return value

    raw = os.environ.get(env_name)
    if raw is not None:
        stripped = raw.strip()
        if stripped:
            log.debug("Secret %r resolved from env var %r", name, env_name)
            return stripped
        # Empty env var — treat as absent and fall through.

    if _HAS_KEYRING:
        try:
            value = keyring.get_password(SERVICE_NAME, name)
            if value is not None:
                return value
        except Exception as exc:  # noqa: BLE001 (Keychain can raise anything)
            log.warning("Keychain get_secret(%r) failed: %s", name, exc)
    return None


def set_secret(name: str, value: str) -> bool:
    """Set a secret in macOS Keychain. Returns True on success.

    Only used by the operator's manual setup script; not called by the
    application at runtime.
    """
    if not _HAS_KEYRING:
        log.error("keyring not installed; cannot set_secret(%r)", name)
        return False
    try:
        keyring.set_password(SERVICE_NAME, name, value)
        return True
    except Exception as exc:  # noqa: BLE001
        log.error("Keychain set_secret(%r) failed: %s", name, exc)
        return False


def has_keyring() -> bool:
    """True if the ``keyring`` library is importable (regardless of whether
    the Keychain entry actually exists)."""
    return _HAS_KEYRING


# Mapping from Keychain account name (used in ``SftpBlock.auth``) to
# the operator-facing env var. Keeping this table here means callers
# don't have to remember the difference between
# ``sftp.gainwell.password`` (Keychain account) and
# ``CYCLONE_SFTP_PASSWORD`` (env var).
_ENV_NAME_FOR: dict[str, str] = {
    "sftp.gainwell.password": "CYCLONE_SFTP_PASSWORD",
}
