# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_remote.py
-------------
Remote exchange-set staging: fetch a chart source from a URL into a local
staging cache, then hand the LOCAL folder/file to the existing scanner — no
downstream code ever learns about protocols.

Supported schemes and their security posture (INSECURE = REFUSED, ALWAYS):

  https://  TLS via the QGIS network stack (proxy + auth manager aware).
  ftps://   FTP over IMPLICIT TLS (TLS from the first byte, default port 990).
  ftpes://  FTP over EXPLICIT TLS (AUTH TLS upgrade on the control channel,
            default port 21).
  ftp://    treated as "explicit TLS REQUIRED": the connection is upgraded
            with AUTH TLS and REFUSED if the server cannot. No exceptions.
  sftp://   SSH file transfer via the system OpenSSH client (in-box on
            Windows 10+, everywhere on Linux/macOS). Key-based auth only
            (OpenSSH batch mode); host keys are pinned first-use.
  http://   plain HTTP — refused. Use https://.

TLS rules (not configurable, by design): certificates are verified against
the system trust store, hostnames are checked, TLS >= 1.2, and the FTP data
channel is encrypted (PROT P). There is deliberately NO "skip verification"
switch and NO insecure opt-in anywhere in this module — a server that cannot
speak TLS in 2026 should up its game, not lower ours.

Credentials come from the QGIS authentication database (an auth config of
method "Basic"), never from plugin settings. A username/password embedded in
the URL works but is warned about.

The staging cache lives in the active QGIS profile
(``<profile>/enc_remote_stage/<host>_<hash>/``) with a small JSON manifest per
source so unchanged remote files are not downloaded again (size + timestamp).
"""

# The FTP client below speaks TLS ONLY (FTP_TLS with a verifying
# ssl.create_default_context, PROT P data channel); there is no plaintext
# code path in this module at all.
import ftplib                                           # nosec B402
import hashlib
import json
import os
import posixpath
import re
import shutil
import socket
import ssl
import subprocess                                       # nosec B404 — OpenSSH sftp
import tempfile
from urllib.parse import urlsplit, unquote

try:
    from qgis.core import Qgis, QgsMessageLog
except (ImportError, ValueError):                       # headless import guard
    Qgis = None
    QgsMessageLog = None


def _log(msg, level=None):
    if QgsMessageLog is None:
        print(f"enc_remote: {msg}")
        return
    QgsMessageLog.logMessage(str(msg), "ENCore GIS",
                             level if level is not None else Qgis.Info)


class RemotePolicyError(ValueError):
    """The URL is refused by the security policy (insecure or unsupported
    scheme). There is no override."""


class RemoteFetchError(RuntimeError):
    """The transfer itself failed (network, auth, server)."""


_DEFAULT_PORTS = {"https": 443, "http": 80, "ftp": 21, "ftpes": 21,
                  "ftps": 990, "sftp": 22}
_MAX_DEPTH = 16                                          # FTP mirror recursion cap


class RemotePlan:
    """A parsed + policy-checked remote source (no I/O yet)."""

    __slots__ = ("url", "scheme", "host", "port", "path", "username",
                 "password", "secure", "warnings")

    def __init__(self, url, scheme, host, port, path, username, password,
                 secure, warnings):
        self.url = url
        self.scheme = scheme
        self.host = host
        self.port = port
        self.path = path
        self.username = username
        self.password = password
        self.secure = secure
        self.warnings = warnings


def classify_url(url):
    """Parse + policy-check a remote URL → RemotePlan, or raise
    RemotePolicyError. Insecure transports are REFUSED — plain http:// always,
    and ftp:// later refuses at connect time if the server can't do AUTH TLS."""
    url = (url or "").strip()
    parts = urlsplit(url)
    scheme = (parts.scheme or "").lower()
    if scheme not in _DEFAULT_PORTS:
        raise RemotePolicyError(
            f"Unsupported URL scheme '{scheme or '(none)'}' — use https://, "
            "ftps:// (implicit TLS), ftpes:// (explicit TLS), ftp:// "
            "(TLS-upgraded) or sftp://.")
    if scheme == "http":
        raise RemotePolicyError(
            "Plain http:// is unencrypted and is refused — use https://. "
            "(Insecure transfers are not supported, by design.)")
    if not parts.hostname:
        raise RemotePolicyError("The URL has no host name.")
    warnings = []
    if parts.password:
        warnings.append(
            "The URL embeds a password; prefer a QGIS authentication "
            "configuration (the URL may end up in logs and settings).")
    return RemotePlan(
        url=url, scheme=scheme, host=parts.hostname,
        port=parts.port or _DEFAULT_PORTS[scheme],
        path=unquote(parts.path or "/"),
        username=parts.username or "", password=parts.password or "",
        secure=True, warnings=warnings)


# ── staging cache ─────────────────────────────────────────────────────────────
def _profile_dir():
    try:
        from qgis.core import QgsApplication
        base = QgsApplication.qgisSettingsDirPath()
        if base:
            return base
    except Exception:                                   # noqa: BLE001
        pass
    return os.path.join(tempfile.gettempdir(), "encoregis_profile")


def cache_root():
    """The staging-cache root inside the active QGIS profile."""
    return os.path.join(_profile_dir(), "enc_remote_stage")


def cache_dir_for(url):
    """A stable, filesystem-safe cache folder for one remote source URL."""
    parts = urlsplit((url or "").strip())
    host = re.sub(r"[^A-Za-z0-9.-]+", "_", parts.hostname or "host")[:40]
    tag = hashlib.sha1((parts.path or "/").encode("utf-8"),
                       usedforsecurity=False).hexdigest()[:12]
    return os.path.join(cache_root(), f"{host}_{tag}")


def _manifest_path(root):
    return os.path.join(root, "_manifest.json")


def read_manifest(root):
    try:
        with open(_manifest_path(root), "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:                                   # noqa: BLE001
        return {"files": {}}


def write_manifest(root, manifest):
    os.makedirs(root, exist_ok=True)
    with open(_manifest_path(root), "w", encoding="utf-8") as f:
        json.dump(manifest, f, indent=1)


def is_unchanged(manifest, relpath, size, stamp, local_path):
    """True when the manifest says this remote file was already fetched with
    the same size + timestamp AND the local copy still exists at that size."""
    ent = (manifest.get("files") or {}).get(relpath)
    if not ent or size in (None, "") or not stamp:
        return False
    try:
        return (int(ent.get("size", -1)) == int(size)
                and str(ent.get("stamp", "")) == str(stamp)
                and os.path.isfile(local_path)
                and os.path.getsize(local_path) == int(size))
    except (TypeError, ValueError, OSError):
        return False


def record_file(manifest, relpath, size, stamp):
    manifest.setdefault("files", {})[relpath] = {
        "size": int(size) if size not in (None, "") else -1,
        "stamp": str(stamp or "")}


# ── credentials (QGIS auth database) ─────────────────────────────────────────
def resolve_credentials(plan, authcfg):
    """(username, password) for the transfer. URL userinfo wins (with a
    warning, added at classify time); otherwise the QGIS auth config (method
    'Basic') is decrypted via the auth manager; otherwise anonymous."""
    if plan.username:
        return plan.username, plan.password
    if not authcfg:
        return "", ""
    try:
        from qgis.core import QgsApplication, QgsAuthMethodConfig
        am = QgsApplication.authManager()
        cfg = QgsAuthMethodConfig()
        if not am.loadAuthenticationConfig(authcfg, cfg, True):
            raise RemoteFetchError(
                f"Could not load QGIS auth configuration '{authcfg}' "
                "(is the master password set?).")
        if cfg.method() and cfg.method() != "Basic":
            raise RemoteFetchError(
                f"Auth configuration '{authcfg}' uses method "
                f"'{cfg.method()}'; a Basic (username/password) "
                "configuration is required here.")
        return (cfg.config("username", ""), cfg.config("password", ""))
    except RemoteFetchError:
        raise
    except Exception as e:                              # noqa: BLE001
        raise RemoteFetchError(f"Auth configuration lookup failed: {e}")


# ── HTTPS (QGIS network stack: proxy + auth manager integration) ─────────────
def _https_headers(url, authcfg):
    """HEAD the URL → (size, last_modified) for the change check; ('', '') on
    any failure (then we simply re-download)."""
    try:
        from qgis.core import QgsBlockingNetworkRequest
        from qgis.PyQt.QtCore import QUrl
        from qgis.PyQt.QtNetwork import QNetworkRequest
        br = QgsBlockingNetworkRequest()
        if authcfg:
            br.setAuthCfg(authcfg)
        if br.head(QNetworkRequest(QUrl(url))) != QgsBlockingNetworkRequest.NoError:
            return "", ""
        reply = br.reply()
        size = bytes(reply.rawHeader(b"Content-Length")).decode() or ""
        stamp = bytes(reply.rawHeader(b"Last-Modified")).decode() or ""
        return size, stamp
    except Exception:                                   # noqa: BLE001
        return "", ""


def fetch_http(plan, dest_root, authcfg="", progress=None):
    """Download one file over HTTP(S) via the QGIS network stack into the
    cache → local file path. Skips the download when size + Last-Modified
    match the manifest."""
    from qgis.core import QgsBlockingNetworkRequest
    from qgis.PyQt.QtCore import QUrl
    from qgis.PyQt.QtNetwork import QNetworkRequest

    say = progress or (lambda s: None)
    name = posixpath.basename(plan.path.rstrip("/")) or "download.bin"
    local = os.path.join(dest_root, name)
    manifest = read_manifest(dest_root)
    size, stamp = _https_headers(plan.url, authcfg)
    if is_unchanged(manifest, name, size, stamp, local):
        say(f"unchanged on server — using cached {name}")
        return local
    say(f"downloading {name} …")
    br = QgsBlockingNetworkRequest()
    if authcfg:
        br.setAuthCfg(authcfg)
    err = br.get(QNetworkRequest(QUrl(plan.url)), forceRefresh=True)
    if err != QgsBlockingNetworkRequest.NoError:
        raise RemoteFetchError(
            f"HTTP download failed: {br.errorMessage() or err}")
    data = bytes(br.reply().content())
    if not data:
        raise RemoteFetchError("HTTP download returned no data.")
    os.makedirs(dest_root, exist_ok=True)
    with open(local, "wb") as f:
        f.write(data)
    record_file(manifest, name, size or len(data), stamp)
    write_manifest(dest_root, manifest)
    say(f"downloaded {name} ({len(data):,} bytes)")
    return local


# ── FTP / FTPS (stdlib ftplib, TLS enforced) ─────────────────────────────────
def _tls_context():
    """Verifying TLS context: system trust store, hostname check, TLS >= 1.2.
    Deliberately no insecure variant."""
    ctx = ssl.create_default_context()
    ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    return ctx


class _ImplicitFTPTLS(ftplib.FTP_TLS):                  # nosec B321
    """ftps:// — TLS from the very first byte (implicit mode, port 990): the
    socket is wrapped before the server greeting is read."""

    def connect(self, host="", port=0, timeout=60, source_address=None):
        self.host = host or self.host
        self.port = port or self.port or 990
        self.timeout = timeout
        sock = socket.create_connection((self.host, self.port), timeout,
                                        source_address)
        self.af = sock.family
        self.sock = self.context.wrap_socket(sock, server_hostname=self.host)
        self.file = self.sock.makefile("r", encoding=self.encoding)
        self.welcome = self.getresp()
        return self.welcome


def connect_ftp(plan, user, password, timeout=60):
    """A logged-in, TLS-protected FTP client per the plan. ftp:// is upgraded
    with AUTH TLS and REFUSED if the server can't — there is no plaintext
    fallback. The data channel is always encrypted (PROT P)."""
    ctx = _tls_context()
    user = user or "anonymous"
    password = password if user != "anonymous" else (password or "anonymous@")
    if plan.scheme == "ftps":                           # implicit TLS
        ftp = _ImplicitFTPTLS(context=ctx)              # nosec B321
        ftp.connect(plan.host, plan.port, timeout=timeout)
        ftp.login(user, password)
        ftp.prot_p()
        return ftp, True
    # explicit TLS (ftpes://, and the mandatory upgrade for ftp://)
    ftp = ftplib.FTP_TLS(context=ctx)                   # nosec B321
    ftp.connect(plan.host, plan.port, timeout=timeout)
    try:
        ftp.auth()                                      # AUTH TLS upgrade
        tls = True
    except ftplib.all_errors as e:
        try:
            ftp.quit()
        except Exception:                               # noqa: BLE001
            pass
        raise RemoteFetchError(
            f"The server does not support TLS (AUTH TLS failed: {e}). "
            "Unencrypted transfers are refused — this server needs to "
            "offer FTPS (or serve the data over HTTPS/SFTP instead).")
    ftp.login(user, password)
    ftp.prot_p()
    return ftp, tls


def _ftp_is_dir(ftp, path):
    here = ftp.pwd()
    try:
        ftp.cwd(path)
        ftp.cwd(here)
        return True
    except ftplib.all_errors:
        return False


def _ftp_listing(ftp, path):
    """[(name, kind, size, stamp)] for a directory — MLSD when the server has
    it (machine-readable), NLST + probes otherwise."""
    entries = []
    try:
        for name, facts in ftp.mlsd(path):
            kind = facts.get("type", "")
            if kind in ("cdir", "pdir") or name in (".", ".."):
                continue
            entries.append((name, "dir" if kind == "dir" else "file",
                            facts.get("size", ""), facts.get("modify", "")))
        return entries
    except ftplib.all_errors:
        pass
    for name in ftp.nlst(path):                         # LIST fallback
        base = posixpath.basename(name.rstrip("/")) or name
        if base in (".", ".."):
            continue
        full = posixpath.join(path, base)
        if _ftp_is_dir(ftp, full):
            entries.append((base, "dir", "", ""))
        else:
            size = stamp = ""
            try:
                size = ftp.size(full) or ""
            except ftplib.all_errors:
                pass
            try:
                resp = ftp.voidcmd(f"MDTM {full}")      # "213 YYYYMMDDHHMMSS"
                stamp = resp.split()[-1] if resp.startswith("213") else ""
            except ftplib.all_errors:
                pass
            entries.append((base, "file", size, stamp))
    return entries


def _ftp_retr(ftp, remote, local, say):
    os.makedirs(os.path.dirname(local), exist_ok=True)
    try:
        ftp.voidcmd("TYPE I")
    except ftplib.all_errors:
        pass
    with open(local, "wb") as f:
        ftp.retrbinary(f"RETR {remote}", f.write)
    say(f"fetched {posixpath.basename(remote)} "
        f"({os.path.getsize(local):,} bytes)")


def mirror_ftp(ftp, plan, dest_root, progress=None):
    """Mirror the plan's path (file OR directory tree) into the cache;
    unchanged files (size + MDTM vs the manifest) are skipped. Returns the
    local path handed to the scanner: the mirrored folder, or the single
    downloaded file."""
    say = progress or (lambda s: None)
    manifest = read_manifest(dest_root)
    root = plan.path.rstrip("/") or "/"
    if not _ftp_is_dir(ftp, root):                      # single file
        name = posixpath.basename(root)
        local = os.path.join(dest_root, name)
        size = stamp = ""
        try:
            size = ftp.size(root) or ""
        except ftplib.all_errors:
            pass
        try:
            resp = ftp.voidcmd(f"MDTM {root}")
            stamp = resp.split()[-1] if resp.startswith("213") else ""
        except ftplib.all_errors:
            pass
        if is_unchanged(manifest, name, size, stamp, local):
            say(f"unchanged on server — using cached {name}")
            return local
        _ftp_retr(ftp, root, local, say)
        record_file(manifest, name, size or os.path.getsize(local), stamp)
        write_manifest(dest_root, manifest)
        return local

    mirror_root = os.path.join(dest_root, "mirror")
    n_new = n_same = 0

    def _walk(remote_dir, rel, depth):
        nonlocal n_new, n_same
        if depth > _MAX_DEPTH:
            say(f"skipping {remote_dir}: deeper than {_MAX_DEPTH} levels")
            return
        for name, kind, size, stamp in _ftp_listing(ftp, remote_dir):
            r_full = posixpath.join(remote_dir, name)
            l_rel = posixpath.join(rel, name) if rel else name
            if kind == "dir":
                _walk(r_full, l_rel, depth + 1)
                continue
            local = os.path.join(mirror_root, *l_rel.split("/"))
            if is_unchanged(manifest, l_rel, size, stamp, local):
                n_same += 1
                continue
            _ftp_retr(ftp, r_full, local, say)
            record_file(manifest, l_rel,
                        size or os.path.getsize(local), stamp)
            n_new += 1

    _walk(root, "", 0)
    write_manifest(dest_root, manifest)
    say(f"mirror complete: {n_new} file(s) fetched, {n_same} unchanged")
    if n_new == 0 and n_same == 0:
        raise RemoteFetchError("The remote directory is empty.")
    return mirror_root


def fetch_ftp(plan, dest_root, authcfg="", progress=None):
    say = progress or (lambda s: None)
    user, password = resolve_credentials(plan, authcfg)
    ftp, _tls = connect_ftp(plan, user, password)
    try:
        say(f"connected to {plan.host}:{plan.port} "
            "(TLS, data channel encrypted)")
        return mirror_ftp(ftp, plan, dest_root, progress=say)
    finally:
        try:
            ftp.quit()
        except Exception:                               # noqa: BLE001
            try:
                ftp.close()
            except Exception:                           # noqa: BLE001
                pass


# ── SFTP (system OpenSSH client) ─────────────────────────────────────────────
def _sftp_exe():
    exe = shutil.which("sftp")
    if not exe:
        raise RemoteFetchError(
            "No OpenSSH 'sftp' client found on this system. On Windows, "
            "enable the built-in 'OpenSSH Client' optional feature; on "
            "Linux/macOS install openssh-client.")
    return exe


def fetch_sftp(plan, dest_root, authcfg="", progress=None):
    """Fetch the plan's path (file or directory) with the system OpenSSH sftp
    in batch mode. Key-based auth only (an agent or ~/.ssh key); the QGIS auth
    config may supply the USERNAME. Unknown host keys are pinned on first use
    (accept-new), changed host keys are refused."""
    say = progress or (lambda s: None)
    user, _pw = resolve_credentials(plan, authcfg)
    target = f"{user}@{plan.host}" if user else plan.host
    dest = os.path.join(dest_root, "mirror")
    os.makedirs(dest, exist_ok=True)
    cmd = [_sftp_exe(), "-r",
           "-P", str(plan.port),
           "-o", "BatchMode=yes",
           "-o", "StrictHostKeyChecking=accept-new",
           f"{target}:{plan.path}", dest]
    say(f"sftp {target}:{plan.path} …")
    proc = subprocess.run(cmd, capture_output=True, text=True,  # nosec B603
                          timeout=3600)
    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "").strip()
        if "Permission denied" in detail or "publickey" in detail:
            detail += ("  (sftp runs in batch mode: key-based authentication "
                       "only — load a key into ssh-agent or ~/.ssh)")
        raise RemoteFetchError(f"sftp failed: {detail}")
    # A single-file fetch lands as mirror/<name>; hand back the file itself.
    entries = os.listdir(dest)
    if len(entries) == 1 and os.path.isfile(os.path.join(dest, entries[0])):
        return os.path.join(dest, entries[0])
    say(f"sftp complete: {len(entries)} top-level entr(y/ies)")
    return dest


# ── front door ────────────────────────────────────────────────────────────────
def fetch(url, authcfg="", progress=None):
    """Fetch a remote exchange-set source into the staging cache.

    Returns (local_path, warnings): ``local_path`` is a local FOLDER (mirrored
    tree) or FILE (single .zip / cell) ready for the normal scanner;
    ``warnings`` are policy notes to surface in the process log. Raises
    RemotePolicyError (refused by security policy) or RemoteFetchError."""
    plan = classify_url(url)
    say = progress or (lambda s: None)
    for w in plan.warnings:
        say(w)
    dest_root = cache_dir_for(plan.url)
    os.makedirs(dest_root, exist_ok=True)
    if plan.scheme == "https":
        local = fetch_http(plan, dest_root, authcfg=authcfg, progress=say)
    elif plan.scheme in ("ftp", "ftps", "ftpes"):
        local = fetch_ftp(plan, dest_root, authcfg=authcfg, progress=say)
    else:                                               # sftp
        local = fetch_sftp(plan, dest_root, authcfg=authcfg, progress=say)
    _log(f"remote source staged: {url} -> {local}")
    return local, list(plan.warnings)
