#!/usr/bin/env python3
"""Parallel FTP mirror of the local project to cPanel (/public_html/MX).

Reads connection info from .vscode/sftp.json (same file the VSCode SFTP
extension uses) — passwords are never duplicated into this repository.

How it stays fast on slow shared-hosting FTP:
  * Remote directory listings are NOT used on the happy path.
  * A local manifest (`.deploy_manifest.json`) stores (size, mtime) per file
    from the last successful deploy, so unchanged files are skipped.
  * Changed files are uploaded over several parallel FTP connections.
  * Nothing is ever deleted unless --prune deletes -- at which point it only
    removes files inside the paths we mirror.
"""

from __future__ import annotations

import argparse
import concurrent.futures as cf
import json
import os
import sys
import threading
from fnmatch import fnmatch
from ftplib import FTP, FTP_TLS, error_perm
from pathlib import Path

ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / ".deploy_manifest.json"

# Same ignore list as .vscode/sftp.json, plus hardening so we never clobber
# server-managed files (passenger entrypoint, env, media uploads, DB, logs).
IGNORE = [
    ".vscode", ".hypothesis", ".git", "__pycache__", ".pytest_cache",
    ".cache", "node_modules", "venv", ".venv", "media", "tmp", "public",
    ".env", ".env.production", ".deploy_manifest.json",
    ".htaccess", "passenger_wsgi.py", "db.sqlite3", "db.sqlite3.*",
    "*.sqlite3", "media.zip", "*.log", "*.pyc", ".DS_Store", ".gitignore",
    "tailwind.config.js", "pytest.ini", "requirements-dev.txt",
    "requirements-performance.txt", "package.json", "package-lock.json",
]

KEEP_TOP_LEVEL_FILES = {"manage.py", "requirements.txt"}
KEEP_TOP_LEVEL_DIRS = {"cms", "backend", "templates", "static", "staticfiles"}


def load_config() -> dict:
    cfg_path = ROOT / ".vscode" / "sftp.json"
    if not cfg_path.exists():
        sys.exit("No .vscode/sftp.json found. Aborting.")
    cfg = json.loads(cfg_path.read_text())
    return {
        "host": cfg.get("host", ""),
        "port": int(cfg.get("port", 21)),
        "user": cfg.get("username", ""),
        "password": cfg.get("password", ""),
        "remote": cfg.get("remotePath", "/"),
    }


def is_ignored(rel: str) -> bool:
    for pattern in IGNORE:
        if fnmatch(rel, pattern) or fnmatch(rel.split("/", 1)[0], pattern):
            return True
        if "/" in rel and any(fnmatch(part, pattern) for part in rel.split("/")):
            return True
    return False


def walk_local(include_db: bool, include_htaccess: bool) -> dict[str, tuple[Path, int, float]]:
    """{relpath: (abspath, size, mtime)} for the managed file set."""
    files: dict[str, tuple[Path, int, float]] = {}
    for p in ROOT.iterdir():
        if p.is_file() and p.name in KEEP_TOP_LEVEL_FILES:
            st = p.stat()
            files[p.name] = (p, st.st_size, st.st_mtime)
    for sub in KEEP_TOP_LEVEL_DIRS:
        base = ROOT / sub
        if not base.exists():
            continue
        for dirpath, dirnames, filenames in os.walk(base):
            dirnames[:] = [d for d in dirnames if not (d.startswith(".") or d == "__pycache__") and not is_ignored(d)]
            for fname in filenames:
                abspath = Path(dirpath) / fname
                rel = abspath.relative_to(ROOT).as_posix()
                if is_ignored(rel):
                    continue
                st = abspath.stat()
                files[rel] = (abspath, st.st_size, st.st_mtime)
    if include_db:
        db = ROOT / "db.sqlite3"
        if db.exists():
            st = db.stat()
            files["db.sqlite3"] = (db, st.st_size, st.st_mtime)
    if include_htaccess:
        ht = ROOT / ".htaccess"
        if ht.exists():
            st = ht.stat()
            files[".htaccess"] = (ht, st.st_size, st.st_mtime)
    return files


def load_manifest() -> dict:
    if MANIFEST.exists():
        try:
            return json.loads(MANIFEST.read_text())
        except Exception:
            return {}
    return {}


def save_manifest(local: dict):
    payload = {rel: [int(size), int(mtime)] for rel, (_, size, mtime) in local.items()}
    MANIFEST.write_text(json.dumps(payload))


def connect(cfg: dict):
    try:
        ftp = FTP_TLS()
        ftp.connect(cfg["host"], cfg["port"], timeout=45)
        ftp.login(cfg["user"], cfg["password"])
        try:
            ftp.prot_p()
        except Exception:
            pass
        return ftp
    except Exception:
        ftp = FTP()
        ftp.connect(cfg["host"], cfg["port"], timeout=45)
        ftp.login(cfg["user"], cfg["password"])
        return ftp


_MKD_CACHE: set[str] = set()


def ensure_remote_dir(ftp: FTP, remote_root: str, rel_dir: str):
    """Create remote dir and parents, each MKD attempted once per run."""
    if not rel_dir:
        return
    acc = remote_root.rstrip("/")
    for part in rel_dir.split("/"):
        if not part:
            continue
        acc += "/" + part
        if acc in _MKD_CACHE:
            continue
        try:
            ftp.mkd(acc)
        except error_perm:
            pass
        _MKD_CACHE.add(acc)


def upload_one(worker_cfg: dict, rel: str, abspath: Path, remote_path: str):
    """Upload one file over the worker thread's persistent connection."""
    ftp = get_thread_ftp(worker_cfg)
    ensure_remote_dir(ftp, worker_cfg["remote"], os.path.dirname(rel))
    for attempt in range(3):
        try:
            with abspath.open("rb") as fh:
                ftp.storbinary(f"STOR {remote_path}", fh)
            return rel, None
        except Exception as exc:
            if attempt == 2:
                return rel, exc
            _THREAD_LOCAL.ftp = connect(worker_cfg)
            ftp = _THREAD_LOCAL.ftp


# One persistent connection per worker thread (avoids 1400 login round-trips).
_THREAD_LOCAL = threading.local()
_OPEN_FTPS: list = []
_OPEN_LOCK = threading.Lock()


def get_thread_ftp(cfg: dict):
    ftp = getattr(_THREAD_LOCAL, "ftp", None)
    if ftp is None:
        ftp = connect(cfg)
        _THREAD_LOCAL.ftp = ftp
        with _OPEN_LOCK:
            _OPEN_FTPS.append(ftp)
    return ftp


def close_all_ftps():
    for ftp in _OPEN_FTPS:
        try:
            ftp.quit()
        except Exception:
            pass
    _OPEN_FTPS.clear()


def main() -> int:
    ap = argparse.ArgumentParser(description="cPanel FTP deploy")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--force", action="store_true")
    ap.add_argument("--prune", action="store_true")
    ap.add_argument("--htaccess", action="store_true")
    ap.add_argument("--no-db", action="store_true")
    ap.add_argument("--workers", type=int, default=8)
    args = ap.parse_args()

    cfg = load_config()
    local = walk_local(include_db=not args.no_db, include_htaccess=args.htaccess)
    if not local:
        sys.exit("Nothing to upload (managed file set is empty).")

    remote_root = cfg["remote"].rstrip("/")

    # Decide which files actually changed since the last deploy.
    manifest = load_manifest()
    if args.force:
        changed = dict(local)
    else:
        changed = {
            rel: v for rel, v in local.items()
            if rel not in manifest or manifest[rel] != [int(v[1]), int(v[2])]
        }

    uploaded = skipped = len(local) - len(changed)

    # --- prune -----------------------------------------------------------------
    # Safe, listing-free: remove remote files that a previous deploy created but
    # that no longer exist locally (tracked by the manifest). Never touches DB,
    # env, .htaccess or the passenger entrypoint.
    NEVER_DELETE = ("db.sqlite3", ".env", ".env.production", ".htaccess", "passenger_wsgi.py")

    def prune_safe(rel: str) -> bool:
        return rel not in (None, "") and not any(rel == n or rel.startswith(n + ".") for n in NEVER_DELETE)

    if args.prune and not args.dry_run:
        stale = {r for r in set(manifest) - set(local) if prune_safe(r)}
        if stale:
            print(f"Pruning {len(stale)} file(s) no longer present locally…")
            pruned = 0
            for rel in sorted(stale):
                try:
                    ftp = connect(cfg)
                    try:
                        ftp.delete(f"{remote_root}/{rel}")
                        pruned += 1
                        print(f"  deleted   {rel}")
                    finally:
                        try:
                            ftp.quit()
                        except Exception:
                            pass
                except error_perm:
                    print(f"  skip      {rel} (not found)")
            print(f"Prune done: {pruned} deleted.")

    if args.dry_run:
        print(f"{len(changed)} file(s) would be uploaded, {skipped} unchanged.")
        if args.prune:
            stale = {r for r in set(manifest) - set(local) if prune_safe(r)}
            for rel in sorted(stale):
                print(f"  WOULD DELETE  {rel}")
        for rel in sorted(changed):
            print(f"  WOULD UPLOAD  {rel}")
        return 0

    if not changed:
        print(f"Nothing to do — {skipped} file(s) already deployed (use --force to re-upload all).")
        save_manifest(local)
        return 0

    print(f"Uploading {len(changed)} changed file(s) with {args.workers} parallel connections…")
    remote_paths = {rel: f"{remote_root}/{rel}" for rel in changed}

    failures: list[tuple[str, Exception]] = []
    with cf.ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = [
            pool.submit(upload_one, cfg, rel, abspath, remote_paths[rel])
            for rel, (abspath, _, _) in changed.items()
        ]
        done = 0
        for fut in cf.as_completed(futures):
            rel, err = fut.result()
            done += 1
            if err:
                failures.append((rel, err))
                print(f"  FAIL      {rel}  ({err})")
            else:
                print(f"  uploaded  {rel}")
            if done % 20 == 0:
                print(f"  ...{done}/{len(changed)}")

    if failures:
        print(f"\n{len(failures)} file(s) FAILED, {len(changed)-len(failures)} succeeded.")
        close_all_ftps()
        return 1

    close_all_ftps()
    save_manifest(local)
    print(f"\nDone: {len(changed)} uploaded, {skipped} unchanged, 0 failed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())