"""Clearhouse integration (SFTP submission, inbound polling).

SP9 ships a stub (writes files to a local staging dir).
SP13 wires the real ``paramiko``-backed SFTP.

Public API is unchanged across SP9 and SP13:
  * ``SftpClient.write_file(remote_path, content)`` — uploads bytes
  * ``SftpClient.list_inbound()`` — lists files in the inbound MFT path
  * ``SftpClient.read_file(remote_path)`` — downloads bytes
  * ``SftpClient.get_secret(name)`` — fetches the auth secret

Authentication is configured via ``SftpBlock.auth``:
  * ``{"password_keychain_account": "sftp.gainwell.password"}`` — fetch
    the password from Keychain (Gainwell's MFT model).
  * ``{"key_file": "/path/to/id_rsa", "key_passphrase_keychain_account": "..."}``
    — SSH private key (rare for MFT, but supported).

The block's ``stub`` flag still controls behavior: ``stub=true`` keeps
the SP9 staging-dir behavior (useful for tests); ``stub=false`` uses
real paramiko. There is no flag for "fail if no Keychain entry" — if
the auth dict references a missing account, ``get_secret`` returns the
stub secret and the paramiko auth will fail loudly at connect time.
"""

from __future__ import annotations

import io
import logging
import os
import shutil
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable, Iterator, Optional, Tuple

from cyclone import secrets
from cyclone.providers import SftpBlock

log = logging.getLogger(__name__)


@dataclass
class InboundFile:
    """A single file observed in the inbound MFT path."""

    name: str
    size: int
    modified_at: datetime
    local_path: Path


class SftpClient:
    """SFTP client wrapper. SP9 stub; SP13 wires paramiko.

    The interface is designed so that swapping the implementation in
    SP13 is a one-file change (just replace ``_write_bytes_stub`` and
    ``_list_inbound_stub`` with real paramiko calls).
    """

    # How long an SFTP connection may sit idle before we tear it down.
    # paramiko's default is None (no timeout); Gainwell's MFT drops
    # idle sessions after ~10 minutes so we recycle every 5.
    _IDLE_TIMEOUT_SECONDS = 5 * 60

    def __init__(self, block: SftpBlock) -> None:
        self._block = block
        self._stub = block.stub

    # ---- Public API -------------------------------------------------------

    def write_file(self, remote_path: str, content: bytes) -> Path:
        """Write bytes to the given remote path. Returns the local staging path.

        In stub mode, ``remote_path`` is preserved relative to the
        configured ``staging_dir``. In real mode, this is a paramiko
        SFTP put.
        """
        if self._stub:
            return self._write_bytes_stub(remote_path, content)
        return self._write_bytes_paramiko(remote_path, content)

    def list_inbound(self) -> list[InboundFile]:
        """List files in the inbound MFT path. Stub returns [] in stub mode.

        Real mode downloads each file into the local inbound staging
        dir and returns :class:`InboundFile` records pointing at the
        cache copy. The remote file is *not* deleted — the operator
        archives inbound files in the MFT UI.
        """
        if self._stub:
            return self._list_inbound_stub()
        return self._list_inbound_paramiko()

    def read_file(self, remote_path: str) -> bytes:
        """Read bytes from a remote path.

        Stub mode: reads from ``{staging_dir}/{remote_path}``. Used by
        the SP16 scheduler so it can exercise the same code path on a
        workstation without a real MFT connection.
        """
        if self._stub:
            return self._read_file_stub(remote_path)
        return self._read_file_paramiko(remote_path)

    def _read_file_stub(self, remote_path: str) -> bytes:
        """Read bytes from ``{staging_dir}/{remote_path}`` (SP16 stub)."""
        staging = Path(self._block.staging_dir).resolve()
        target = staging / remote_path.lstrip("/")
        if not target.is_file():
            raise FileNotFoundError(f"inbound stub file not found: {target}")
        return target.read_bytes()

    def get_secret(self, name: str) -> Optional[str]:
        """Fetch the auth secret from Keychain. Returns the stub secret if absent."""
        value = secrets.get_secret(name)
        if value is None:
            log.info("Keychain entry %r missing; using stub secret", name)
            return secrets.STUB_SECRET
        return value

    # ---- Stub implementations (SP9) -------------------------------------

    def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path:
        """Copy ``content`` to ``{staging_dir}/{remote_path}``.

        Preserves the full MFT path under staging so the operator can
        review what would be uploaded. The remote_path may use forward
        slashes (per SFTP convention); we use PurePosixPath-style split.
        """
        staging = Path(self._block.staging_dir).resolve()
        # remote_path may be absolute ("/CO XIX/...") or relative; strip
        # leading slash to avoid escaping the staging dir.
        rel = remote_path.lstrip("/")
        target = staging / rel
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(content)
        log.info("SFTP stub: wrote %d bytes to %s", len(content), target)
        return target

    def _list_inbound_stub(self) -> list[InboundFile]:
        """Return the local inbound staging dir, if it has been populated
        by a real MFT pull (e.g. operator dropped files for testing)."""
        staging = Path(self._block.staging_dir).resolve()
        inbound_rel = self._block.paths.get("inbound", "").lstrip("/")
        inbound_dir = staging / inbound_rel
        if not inbound_dir.is_dir():
            return []
        files: list[InboundFile] = []
        for entry in sorted(inbound_dir.iterdir()):
            if entry.is_file():
                stat = entry.stat()
                files.append(
                    InboundFile(
                        name=entry.name,
                        size=stat.st_size,
                        modified_at=datetime.fromtimestamp(stat.st_mtime),
                        local_path=entry,
                    )
                )
        return files

    # ---- Real implementations (SP13) -------------------------------------

    @contextmanager
    def _connect(self) -> Iterator[Tuple["object", "object"]]:
        """Open a paramiko SSHClient and yield (ssh, sftp).

        Closes the connection on context exit (caller wraps in
        ``with self._connect() as (ssh, sftp):``). Auth resolves the
        password or private key from Keychain via the ``auth`` block.

        Why we wrap the SSH client lifecycle here: paramiko caches
        host keys in ``~/.ssh/known_hosts`` by default; for MFT sites
        the operator may have a different key fingerprint than their
        workstation's. We accept the server's key on first connect
        (``AutoAddPolicy``) and warn — the operator should pin it for
        production.
        """
        import paramiko

        ssh = paramiko.SSHClient()
        ssh.load_system_host_keys()
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

        auth = self._block.auth or {}
        password_account = auth.get("password_keychain_account")
        key_file = auth.get("key_file")
        key_passphrase_account = auth.get("key_passphrase_keychain_account")

        connect_kwargs: dict = {
            "hostname": self._block.host,
            "port": self._block.port,
            "username": self._block.username,
            "timeout": 30,
            "allow_agent": False,
            "look_for_keys": False,
        }
        if password_account:
            password = self.get_secret(password_account)
            if password and password != secrets.STUB_SECRET:
                connect_kwargs["password"] = password
            else:
                # Don't attempt empty-password auth — fail loud.
                raise RuntimeError(
                    f"SFTP: Keychain entry {password_account!r} missing or stub. "
                    "Real SFTP wire-up requires the actual password."
                )
        elif key_file:
            pkey_kwargs: dict = {}
            if key_passphrase_account:
                passphrase = self.get_secret(key_passphrase_account)
                if passphrase and passphrase != secrets.STUB_SECRET:
                    pkey_kwargs["password"] = passphrase
            connect_kwargs["key_filename"] = key_file
            if pkey_kwargs:
                connect_kwargs["pkey"] = paramiko.RSAKey.from_private_key_file(
                    key_file, password=pkey_kwargs.get("password"),
                )
        else:
            raise RuntimeError(
                "SftpBlock.auth must contain either 'password_keychain_account' or 'key_file'"
            )

        log.info("SFTP: connecting to %s:%d as %s", self._block.host, self._block.port, self._block.username)
        ssh.connect(**connect_kwargs)
        sftp = ssh.open_sftp()
        try:
            yield ssh, sftp
        finally:
            try:
                sftp.close()
            except Exception:  # noqa: BLE001 — close errors are non-fatal
                pass
            try:
                ssh.close()
            except Exception:  # noqa: BLE001 — close errors are non-fatal
                pass

    def _write_bytes_paramiko(self, remote_path: str, content: bytes) -> Path:
        """Upload ``content`` to ``remote_path`` via paramiko SFTP.

        Returns ``remote_path`` as a :class:`Path` (Posix-style) for
        API symmetry with the stub. The actual write uses paramiko's
        ``SFTPFile.open(..., "wb")`` and an in-memory BytesIO buffer
        so we don't have to materialize a local temp file.
        """
        # Lazy import so the stub-only test path doesn't need paramiko.
        with self._connect() as (ssh, sftp):
            # Ensure the parent dir exists on the remote. Gainwell's
            # MFT has pre-created the FromHPE/ToHPE dirs, but creating
            # them again is harmless and idempotent.
            parent = "/".join(remote_path.rstrip("/").split("/")[:-1])
            if parent:
                try:
                    sftp.mkdir(parent)
                except IOError:
                    # Already exists — fine.
                    pass
            with sftp.open(remote_path, "wb") as f:
                f.write(content)
            log.info("SFTP: wrote %d bytes to %s", len(content), remote_path)
        # Return a PosixPath so the API response shape matches the
        # stub (which returns a real local Path).
        return Path(remote_path)

    def _list_inbound_paramiko(self) -> list[InboundFile]:
        """List inbound MFT files via paramiko; cache each into local staging."""
        with self._connect() as (ssh, sftp):
            inbound_dir = self._block.paths.get("inbound", "/")
            staging = Path(self._block.staging_dir).resolve()
            inbound_rel = inbound_dir.lstrip("/")
            cache_dir = staging / inbound_rel
            cache_dir.mkdir(parents=True, exist_ok=True)

            files: list[InboundFile] = []
            try:
                attrs = sftp.listdir_attr(inbound_dir)
            except IOError as exc:
                log.warning("SFTP: cannot list %s: %s", inbound_dir, exc)
                return []

            for attr in sorted(attrs, key=lambda a: a.filename):
                if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
                    # Directory entry — skip.
                    continue
                remote = f"{inbound_dir.rstrip('/')}/{attr.filename}"
                cache_path = cache_dir / attr.filename
                # Download into cache. We use ``prefetch`` to keep memory
                # bounded for large 999s/TA1s (rarely >100KB in practice
                # but the API supports it).
                with sftp.open(remote, "rb") as src, open(cache_path, "wb") as dst:
                    shutil.copyfileobj(src, dst, length=64 * 1024)
                files.append(InboundFile(
                    name=attr.filename,
                    size=attr.st_size or cache_path.stat().st_size,
                    modified_at=datetime.fromtimestamp(attr.st_mtime or 0),
                    local_path=cache_path,
                ))
            return files

    def _read_file_paramiko(self, remote_path: str) -> bytes:
        with self._connect() as (ssh, sftp):
            buf = io.BytesIO()
            with sftp.open(remote_path, "rb") as f:
                shutil.copyfileobj(f, buf, length=64 * 1024)
            return buf.getvalue()


# ---------------------------------------------------------------------------
# Module-level helper
# ---------------------------------------------------------------------------


def make_client(block: SftpBlock) -> SftpClient:
    """Factory used by the API layer. Kept tiny so swapping the
    implementation in SP13 is one-line."""
    return SftpClient(block)
