#!/usr/bin/env python3
"""
ftp_sync.py

RO: Sincronizeaza recursiv directorul curent cu un director remote FTP.
    Parcurge TOATE subdirectoarele de pe server si le recreeaza local identic,
    pornind mereu din directorul din care rulezi scriptul (LOCAL_DIR = ".").
    Tine un index JSON (.ftp_index.json) in care fiecare fisier are: folderul in
    care se afla, dimensiune remote, mtime remote, data descarcarii locale si
    optional hash. Astfel ai oricand o imagine clara a structurii remote si o
    poti reconstrui exact in alta parte (foldere + fisiere).
    La fiecare rulare: scaneaza recursiv serverul, recreeaza folderele local,
    descarca ce lipseste sau ce nu corespunde (size / mtime / hash) si marcheaza
    in index fisierele disparute de pe remote.

EN: Recursively syncs the current directory with a remote FTP directory.
    Walks EVERY subdirectory on the server and recreates it locally, always
    starting from the directory the script is run in (LOCAL_DIR = ".").
    Keeps a JSON index (.ftp_index.json) where each file records: its folder,
    remote size, remote mtime, local download timestamp and optional hash. This
    gives a clear picture of the remote structure so it can be rebuilt exactly
    elsewhere (folders + files).
    Each run: recursively scans the server, recreates folders locally, downloads
    what is missing or mismatched (size / mtime / hash) and flags files removed
    from remote in the index.

RO: Editeaza blocul CONFIG de mai jos si ruleaza:  python ftp_sync.py
EN: Edit the CONFIG block below and run:            python ftp_sync.py
"""

import ftplib
import hashlib
import json
import logging
import os
import posixpath
import sys
from datetime import datetime, timezone

# ================== CONFIG ==================
# RO: host / IP server        EN: server host / IP
HOST        = "ftp.exemplu.ro"
PORT        = 21
USER        = "utilizator"
# RO: lasa "" ca sa o ceara la rulare   EN: leave "" to be prompted at runtime
PASSWORD    = "parola_aici"
# RO: directorul radacina de pe server  EN: remote root directory
REMOTE_DIR  = "/pub/date"
# RO: mereu directorul curent (de aici incepe oglinda)
# EN: always the current directory (mirror starts here)
LOCAL_DIR   = "."

USE_TLS        = False   # RO: True = FTPS                 | EN: True = FTPS
PASSIVE        = True    # RO: False = mod activ           | EN: False = active mode
VERIFY_HASH    = False   # RO: sha256 la fiecare fisier    | EN: sha256 per file (slower)
DELETE_ORPHANS = False   # RO: sterge local ce a disparut  | EN: delete local files gone from remote
PRUNE_EMPTY    = False   # RO: sterge folderele goale      | EN: remove leftover empty folders
DRY_RUN        = False   # RO: doar raporteaza, nu descarca| EN: report only, do not download
VERBOSE        = False
TIMEOUT        = 30
# ============================================

INDEX_NAME = ".ftp_index.json"
log = logging.getLogger("ftp_sync")


# ---------------------------------------------------------------- index

def load_index(path):
    # RO: incarca indexul existent sau porneste unul gol.
    # EN: load the existing index or start an empty one.
    if not os.path.exists(path):
        return {"root": REMOTE_DIR, "files": {}, "dirs": [], "last_sync": None}
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        data.setdefault("files", {})
        data.setdefault("dirs", [])
        return data
    except (json.JSONDecodeError, OSError) as e:
        # RO: index corupt -> se reconstruieste de la zero.
        # EN: corrupt index -> rebuild from scratch.
        log.warning("Index corupt / corrupt index (%s), rebuilding.", e)
        return {"root": REMOTE_DIR, "files": {}, "dirs": [], "last_sync": None}


def save_index(path, index):
    # RO: scriere atomica (tmp + replace) ca sa nu corupem indexul.
    # EN: atomic write (tmp + replace) to avoid corrupting the index.
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(index, f, indent=2, ensure_ascii=False)
    os.replace(tmp, path)


def sha256_file(path, chunk=1 << 20):
    # RO: calculeaza sha256 pe bucati (fisiere mari).
    # EN: compute sha256 in chunks (large files).
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for block in iter(lambda: f.read(chunk), b""):
            h.update(block)
    return h.hexdigest()


# ---------------------------------------------------------------- ftp

def connect():
    # RO: deschide conexiunea (FTP sau FTPS) si autentifica.
    # EN: open the connection (FTP or FTPS) and log in.
    cls = ftplib.FTP_TLS if USE_TLS else ftplib.FTP
    ftp = cls(timeout=TIMEOUT)
    ftp.connect(HOST, PORT)
    ftp.login(USER, PASSWORD)
    if USE_TLS:
        ftp.prot_p()  # RO: cripteaza si canalul de date | EN: encrypt data channel too
    ftp.set_pasv(PASSIVE)
    return ftp


def walk_remote(ftp, root):
    """
    RO: Parcurge recursiv 'root' pe server. Intoarce (files, dirs):
          files = { cale_relativa_posix: {"dir": folder_rel, "name": nume,
                                          "size": int, "mtime": str|None} }
          dirs  = [ cale_relativa_folder, ... ]  (toate folderele gasite)
        Caile relative sunt fata de 'root', cu separator '/'.
    EN: Recursively walk 'root' on the server. Returns (files, dirs):
          files = { posix_relative_path: {"dir": rel_folder, "name": name,
                                          "size": int, "mtime": str|None} }
          dirs  = [ rel_folder_path, ... ]  (all folders found)
        Relative paths are against 'root', using '/' separator.
    """
    files = {}
    dirs = []
    # RO: stiva (cale_absoluta_remote, cale_relativa) - evita recursia adanca.
    # EN: stack (absolute_remote_path, relative_path) - avoids deep recursion.
    stack = [(root, "")]

    while stack:
        abs_dir, rel_dir = stack.pop()
        try:
            ftp.cwd(abs_dir)
        except ftplib.all_errors as e:
            # RO: folder inaccesibil -> il sarim.  EN: unreachable folder -> skip.
            log.warning("Nu pot intra / cannot enter %s: %s", abs_dir, e)
            continue

        if rel_dir:
            dirs.append(rel_dir)

        for name, typ, size, mtime in _list_dir(ftp):
            if name in (".", ".."):
                continue
            rel = posixpath.join(rel_dir, name) if rel_dir else name
            abs_child = posixpath.join(abs_dir, name)
            if typ == "dir":
                # RO: pune folderul in stiva spre procesare.
                # EN: push the folder onto the stack for processing.
                stack.append((abs_child, rel))
            elif typ == "file":
                files[rel] = {
                    "dir": rel_dir,               # RO: "" = radacina | EN: "" = root
                    "name": name,
                    "size": size if size is not None else -1,
                    "mtime": mtime,
                }
    return files, dirs


def _list_dir(ftp):
    """
    RO: Listeaza directorul curent. Intoarce [(name, type, size, mtime), ...],
        type in {"file","dir"}. Foloseste MLSD; daca lipseste, euristica NLST.
    EN: List the current directory. Returns [(name, type, size, mtime), ...],
        type in {"file","dir"}. Uses MLSD; falls back to NLST heuristics.
    """
    out = []
    try:
        # RO: MLSD ofera tip + dimensiune + data direct (cand e suportat).
        # EN: MLSD gives type + size + date directly (when supported).
        for name, facts in ftp.mlsd():
            typ = facts.get("type", "")
            if typ == "file":
                out.append((name, "file",
                            _to_int(facts.get("size")), facts.get("modify")))
            elif typ == "dir":
                out.append((name, "dir", None, None))
            # RO: cdir/pdir/other ignorate | EN: cdir/pdir/other ignored
        return out
    except (ftplib.error_perm, ftplib.error_proto):
        pass  # RO: MLSD indisponibil | EN: MLSD unavailable

    # RO: fallback NLST - deducem tipul din SIZE / posibilitatea de a intra.
    # EN: NLST fallback - infer type from SIZE / ability to CWD in.
    for entry in ftp.nlst():
        base = posixpath.basename(entry.rstrip("/"))
        if base in (".", ".."):
            continue
        size = None
        try:
            size = ftp.size(base)  # RO: eroare/None -> posibil folder | EN: err/None -> maybe folder
        except (ftplib.error_perm, ftplib.error_reply):
            size = None

        if size is None:
            # RO: test - daca putem intra, e director.
            # EN: test - if we can CWD into it, it is a directory.
            cur = ftp.pwd()
            try:
                ftp.cwd(base)
                ftp.cwd(cur)
                out.append((base, "dir", None, None))
                continue
            except ftplib.all_errors:
                try:
                    ftp.cwd(cur)
                except ftplib.all_errors:
                    pass

        # RO: fisier - incercam sa luam data prin MDTM.
        # EN: file - try to get the timestamp via MDTM.
        mtime = None
        try:
            resp = ftp.sendcmd("MDTM " + base)
            if resp.startswith("213"):
                mtime = resp[4:].strip()[:14]
        except (ftplib.error_perm, ftplib.error_reply):
            pass
        out.append((base, "file", size, mtime))
    return out


def _to_int(v):
    # RO: conversie sigura la int (sau None).  EN: safe int conversion (or None).
    try:
        return int(v)
    except (TypeError, ValueError):
        return None


def download(ftp, abs_remote_path, local_path):
    # RO: descarca in .part apoi redenumeste (atomic).
    # EN: download to .part then rename (atomic).
    os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
    tmp = local_path + ".part"
    with open(tmp, "wb") as f:
        ftp.retrbinary("RETR " + abs_remote_path, f.write, blocksize=1 << 16)
    os.replace(tmp, local_path)


# ---------------------------------------------------------------- logica / logic

def needs_download(meta, local_path, entry):
    # RO: decide daca fisierul trebuie (re)descarcat si de ce.
    # EN: decide whether the file must be (re)downloaded and why.
    if not os.path.exists(local_path):
        return "lipseste local / missing locally"
    local_size = os.path.getsize(local_path)
    if meta["size"] >= 0 and local_size != meta["size"]:
        return f"size diferit / size mismatch (local {local_size} != remote {meta['size']})"
    if entry is None:
        return "lipseste din index / missing from index"
    if entry.get("remote_size") != meta["size"]:
        return "size din index difera de remote / index size differs from remote"
    if meta.get("mtime") and entry.get("remote_mtime") != meta["mtime"]:
        return "mtime remote schimbat / remote mtime changed"
    if VERIFY_HASH and entry.get("sha256"):
        if sha256_file(local_path) != entry["sha256"]:
            return "hash local modificat / local hash changed"
    return None


def local_path_for(local_root, rel):
    # RO: transforma calea relativa posix in cale locala corecta pe OS.
    # EN: turn a posix relative path into the correct OS-specific local path.
    return os.path.join(local_root, *rel.split("/"))


def sync():
    # RO: radacina locala = directorul curent, rezolvat absolut.
    # EN: local root = current directory, resolved to absolute.
    local_root = os.path.abspath(LOCAL_DIR)
    os.makedirs(local_root, exist_ok=True)
    index_path = os.path.join(local_root, INDEX_NAME)
    index = load_index(index_path)
    index["root"] = REMOTE_DIR
    files = index["files"]

    log.info("Radacina locala / local root: %s", local_root)
    log.info("Conectare / connecting to %s:%s ...", HOST, PORT)
    ftp = connect()

    try:
        log.info("Scanez recursiv / scanning recursively %s ...", REMOTE_DIR)
        remote_files, remote_dirs = walk_remote(ftp, REMOTE_DIR)
        log.info("Gasit / found: %d fisiere / files in %d subdirectoare / subdirs.",
                 len(remote_files), len(remote_dirs))

        # RO: recreeaza structura de foldere local.
        # EN: recreate the folder structure locally.
        for d in remote_dirs:
            path = local_path_for(local_root, d)
            if not DRY_RUN:
                os.makedirs(path, exist_ok=True)
        index["dirs"] = sorted(remote_dirs)

        new = updated = skipped = 0

        for rel in sorted(remote_files):
            meta = remote_files[rel]
            local_path = local_path_for(local_root, rel)
            abs_remote = posixpath.join(REMOTE_DIR, rel)
            entry = files.get(rel)
            reason = needs_download(meta, local_path, entry)

            if reason is None:
                skipped += 1
                continue

            existed = os.path.exists(local_path)
            log.info("[%s] %s -> %s",
                     meta["dir"] or "(radacina/root)", meta["name"], reason)
            if DRY_RUN:
                continue

            try:
                download(ftp, abs_remote, local_path)
            except (OSError, *ftplib.all_errors) as e:
                log.error("Esec descarcare / download failed %s: %s", rel, e)
                continue

            # RO: notam in index folderul + toate detaliile fisierului.
            # EN: record in the index the folder + all file details.
            files[rel] = {
                "dir": meta["dir"],                 # RO: folder rel. la root | EN: folder rel. to root
                "name": meta["name"],
                "remote_path": abs_remote,
                "remote_size": meta["size"],
                "remote_mtime": meta.get("mtime"),
                "local_size": os.path.getsize(local_path),
                "downloaded_at": datetime.now(timezone.utc).isoformat(),
                "sha256": sha256_file(local_path) if VERIFY_HASH else None,
                "reason": reason,
            }
            if existed:
                updated += 1
            else:
                new += 1

        # RO: fisiere care nu mai exista pe remote.
        # EN: files that no longer exist on remote.
        orphans = [r for r in files if r not in remote_files]
        for rel in orphans:
            local_path = local_path_for(local_root, rel)
            if DELETE_ORPHANS and not DRY_RUN:
                if os.path.exists(local_path):
                    os.remove(local_path)
                del files[rel]
                log.info("%s -> sters / deleted (nu mai e pe remote / gone from remote)", rel)
            else:
                files[rel]["missing_on_remote"] = True
                log.warning("%s nu mai e pe remote / gone from remote (pastrat / kept)", rel)

        # RO: optional, curata folderele locale ramase goale.
        # EN: optionally, clean up leftover empty local folders.
        if PRUNE_EMPTY and not DRY_RUN:
            _prune_empty_dirs(local_root)

        index["last_sync"] = datetime.now(timezone.utc).isoformat()
        if not DRY_RUN:
            save_index(index_path, index)

        log.info("Gata / done. Noi/new: %d | Actualizate/updated: %d | "
                 "Neschimbate/unchanged: %d | Foldere/folders: %d | Orfane/orphans: %d",
                 new, updated, skipped, len(remote_dirs), len(orphans))
    finally:
        # RO: inchidem curat conexiunea.  EN: close the connection cleanly.
        try:
            ftp.quit()
        except ftplib.all_errors:
            ftp.close()


def _prune_empty_dirs(root):
    # RO: sterge recursiv folderele goale (fara a atinge radacina/indexul).
    # EN: recursively remove empty folders (without touching the root/index).
    root_abs = os.path.abspath(root)
    for dirpath, dirnames, filenames in os.walk(root, topdown=False):
        if os.path.abspath(dirpath) == root_abs:
            continue
        real = [f for f in filenames if f != INDEX_NAME]
        if not real and not dirnames:
            try:
                os.rmdir(dirpath)
                log.info("Folder gol sters / empty folder removed: %s", dirpath)
            except OSError:
                pass


def main():
    global PASSWORD

    logging.basicConfig(
        level=logging.DEBUG if VERBOSE else logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
    )

    # RO: variabila de mediu are prioritate; daca lipseste, cerem parola.
    # EN: env variable takes priority; if missing, prompt for the password.
    PASSWORD = os.environ.get("FTP_PASSWORD", PASSWORD)
    if not PASSWORD:
        import getpass
        PASSWORD = getpass.getpass("Parola FTP / FTP password: ")

    log.info("Sincronizez recursiv / recursive sync %s:%s%s -> %s (dir curent / current dir)",
             HOST, PORT, REMOTE_DIR, os.path.abspath(LOCAL_DIR))

    try:
        sync()
    except ftplib.all_errors as e:
        log.error("Eroare FTP / FTP error: %s", e)
        sys.exit(1)
    except KeyboardInterrupt:
        log.warning("Intrerupt / interrupted.")
        sys.exit(130)


if __name__ == "__main__":
    main()
