"""
backup_sync.py — Real-time incremental backup sync for the Flask/SQLite Docker app.

Two independent classes:
  LocalSyncer  — mirrors SQLite DB + all static data folders to a local path.
  FTPSyncer    — pushes/pulls SQLite DB + all static data folders via FTP.

Static folders covered: uploads/, blogs/, avatars/, site-media/, screenshots/, bg-removed/

Each class exposes:
  run_sync()            → dict  (incremental export; also deletes remote files removed on disk)
  restore_from_source() → dict  (import back into the container)
  test_connection()     → dict  (verifies connectivity/write-access)

State file (incremental detection):
  /website/storage/backup_sync_state.json
  Stores mtime+size per file so only changed files are re-transferred.
  Deleted files are detected by comparing state entries against the live filesystem.

No third-party packages are required; only Python stdlib is used.
"""

import os
import json
import shutil
import sqlite3
import ftplib
import io
from datetime import datetime

# ---------------------------------------------------------------------------
# Paths — mirror exactly what the Docker app uses
# ---------------------------------------------------------------------------
_ROOT_DIR        = "/website"
_DB_SRC          = os.path.join(_ROOT_DIR, "storage", "sqlite.db")
_UPLOADS_SRC     = os.path.join(_ROOT_DIR, "static", "uploads")
_BLOGS_SRC       = os.path.join(_ROOT_DIR, "static", "blogs")
_AVATARS_SRC     = os.path.join(_ROOT_DIR, "static", "avatars")
_SITE_MEDIA_SRC  = os.path.join(_ROOT_DIR, "static", "site-media")
_SCREENSHOTS_SRC = os.path.join(_ROOT_DIR, "static", "screenshots")
_BG_REMOVED_SRC  = os.path.join(_ROOT_DIR, "static", "bg-removed")
_STATE_FILE      = os.path.join(_ROOT_DIR, "storage", "backup_sync_state.json")

# All static folders that are synced/restored (label used as dict key + remote sub-path).
_STATIC_FOLDERS = [
    ("uploads",     _UPLOADS_SRC),
    ("blogs",       _BLOGS_SRC),
    ("avatars",     _AVATARS_SRC),
    ("site-media",  _SITE_MEDIA_SRC),
    ("screenshots", _SCREENSHOTS_SRC),
    ("bg-removed",  _BG_REMOVED_SRC),
]

# Quick lookup: folder label → source path (used by deletion pass)
_FOLDER_SRC_MAP = {label: src for label, src in _STATIC_FOLDERS}


# ---------------------------------------------------------------------------
# State helpers
# ---------------------------------------------------------------------------

def _load_state() -> dict:
    try:
        with open(_STATE_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def _save_state(state: dict):
    tmp = _STATE_FILE + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)
    os.replace(tmp, _STATE_FILE)


def _local_state_key(target_path: str) -> str:
    """
    Return a state-dict key unique to target_path.
    Changing the target path automatically triggers a full re-sync because
    the new key starts with no recorded files.
    """
    import hashlib
    h = hashlib.md5(target_path.rstrip("/").encode()).hexdigest()[:10]
    return f"local_{h}"


def clear_local_state(target_path: str = None):
    """
    Clear incremental state for a specific local target (or all local keys).
    Called by Force Full Sync so the next run copies every file regardless
    of whether mtime/size has changed.
    """
    state = _load_state()
    if target_path:
        key = _local_state_key(target_path)
        state.pop(key, None)
    else:
        for k in list(state.keys()):
            if k.startswith("local_") and k not in ("local_last_sync", "local_last_error"):
                state.pop(k, None)
    _save_state(state)


def _file_sig(path: str):
    """Return (mtime_int, size) or None if file missing."""
    try:
        st = os.stat(path)
        return (int(st.st_mtime), st.st_size)
    except OSError:
        return None


def _fmt_ts() -> str:
    return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")


# ---------------------------------------------------------------------------
# LocalSyncer
# ---------------------------------------------------------------------------

class LocalSyncer:
    """
    Incremental sync from /website/storage/sqlite.db,
    /website/static/uploads/, /website/static/avatars/, and
    /website/static/site-media/ to a configurable local target directory.

    Target layout:
      <target_path>/storage/sqlite.db
      <target_path>/static/uploads/<...>
      <target_path>/static/avatars/<...>
      <target_path>/static/site-media/<...>
    """

    def __init__(self, target_path: str):
        self.target_path = target_path.rstrip("/")

    # ------------------------------------------------------------------ sync

    def run_sync(self) -> dict:
        """Copy DB + changed/new files from all static folders to target_path."""
        state = _load_state()
        _state_key = _local_state_key(self.target_path)
        local_state = state.get(_state_key, {})

        copied_files = []
        skipped = 0
        errors = []

        def _open_perms(path: str, is_dir: bool = False):
            """Set world-readable/writable permissions so non-root host users can manage synced files."""
            try:
                os.chmod(path, 0o777 if is_dir else 0o666)
            except Exception:
                pass

        def _makedirs_open(path: str):
            """Create directories with open permissions all the way down."""
            os.makedirs(path, exist_ok=True)
            # Walk up and ensure every new directory segment is open
            p = path
            while True:
                try:
                    os.chmod(p, 0o777)
                except Exception:
                    pass
                parent = os.path.dirname(p)
                if parent == p:
                    break
                p = parent

        # 1. SQLite DB — always copy using sqlite3.backup() for consistency
        try:
            db_dest_dir = os.path.join(self.target_path, "storage")
            _makedirs_open(db_dest_dir)
            db_dest = os.path.join(db_dest_dir, "sqlite.db")
            tmp_dest = db_dest + ".sync_tmp"
            with sqlite3.connect(_DB_SRC) as src_conn:
                with sqlite3.connect(tmp_dest) as dst_conn:
                    src_conn.backup(dst_conn)
            os.replace(tmp_dest, db_dest)
            _open_perms(db_dest)
            copied_files.append("storage/sqlite.db")
        except Exception as exc:
            errors.append(f"DB sync error: {exc}")

        # 2. Static folders — incremental: only copy files whose mtime/size changed
        for folder_label, folder_src in _STATIC_FOLDERS:
            if not os.path.isdir(folder_src):
                continue
            try:
                folder_dest = os.path.join(self.target_path, "static", folder_label)
                for root, _, files in os.walk(folder_src):
                    rel_root = os.path.relpath(root, folder_src)
                    for fname in files:
                        src_file = os.path.join(root, fname)
                        rel_key = f"{folder_label}/" + (
                            os.path.join(rel_root, fname) if rel_root != "." else fname
                        )
                        sig = _file_sig(src_file)
                        if sig is None:
                            continue
                        if local_state.get(rel_key) and tuple(local_state[rel_key]) == sig:
                            skipped += 1
                            continue
                        dest_file = (
                            os.path.join(folder_dest, rel_root, fname)
                            if rel_root != "."
                            else os.path.join(folder_dest, fname)
                        )
                        _makedirs_open(os.path.dirname(dest_file))
                        shutil.copy2(src_file, dest_file)
                        _open_perms(dest_file)
                        local_state[rel_key] = list(sig)
                        copied_files.append(rel_key)
            except Exception as exc:
                errors.append(f"{folder_label} sync error: {exc}")

        # 3. Deletion pass — remove files from target that no longer exist on disk.
        #    Only remove a key from state when deletion is confirmed, so transient
        #    failures are retried on the next sync run.
        deleted_keys = []
        static_base = os.path.join(self.target_path, "static")
        for rel_key in list(local_state.keys()):
            folder_label = rel_key.split("/")[0]
            src_folder = _FOLDER_SRC_MAP.get(folder_label)
            if src_folder is None:
                continue
            rel_path = rel_key[len(folder_label) + 1:]
            src_file = os.path.join(src_folder, rel_path)
            if not os.path.exists(src_file):
                dest_file = os.path.join(static_base, rel_key)
                delete_ok = False
                try:
                    if os.path.isfile(dest_file):
                        os.remove(dest_file)
                    # Whether the dest file existed or not, source is gone → clean up
                    delete_ok = True
                    # Prune now-empty parent directories up to static_base
                    parent = os.path.dirname(dest_file)
                    while os.path.abspath(parent) != os.path.abspath(static_base):
                        if os.path.isdir(parent) and not os.listdir(parent):
                            os.rmdir(parent)
                            parent = os.path.dirname(parent)
                        else:
                            break
                except Exception as exc:
                    errors.append(f"Local delete error ({rel_key}): {exc}")
                if delete_ok:
                    deleted_keys.append(rel_key)
        for k in deleted_keys:
            local_state.pop(k, None)

        state[_state_key] = local_state
        state["local_last_sync"] = _fmt_ts()
        state["local_last_error"] = errors[0] if errors else None
        _save_state(state)

        return {
            "status": "ok" if not errors else "partial",
            "copied": len(copied_files),
            "skipped": skipped,
            "deleted": len(deleted_keys),
            "errors": errors,
            "timestamp": state["local_last_sync"],
        }

    # --------------------------------------------------------------- restore

    def restore_from_source(self) -> dict:
        """Copy DB + all static-folder files from target_path back into the container."""
        errors = []
        restored_files = []

        # 1. SQLite DB
        src_db = os.path.join(self.target_path, "storage", "sqlite.db")
        if os.path.exists(src_db):
            try:
                tmp = _DB_SRC + ".restore_tmp"
                shutil.copy2(src_db, tmp)
                with sqlite3.connect(tmp) as src_conn:
                    with sqlite3.connect(_DB_SRC) as dst_conn:
                        src_conn.backup(dst_conn)
                os.remove(tmp)
                restored_files.append("storage/sqlite.db")
            except Exception as exc:
                errors.append(f"DB restore error: {exc}")
        else:
            errors.append(f"No sqlite.db found at {src_db}")

        # 2. Static folders
        for folder_label, folder_dst in _STATIC_FOLDERS:
            src_folder = os.path.join(self.target_path, "static", folder_label)
            if not os.path.isdir(src_folder):
                continue
            try:
                for root, _, files in os.walk(src_folder):
                    rel_root = os.path.relpath(root, src_folder)
                    for fname in files:
                        src_file = os.path.join(root, fname)
                        dest_file = (
                            os.path.join(folder_dst, rel_root, fname)
                            if rel_root != "."
                            else os.path.join(folder_dst, fname)
                        )
                        os.makedirs(os.path.dirname(dest_file), exist_ok=True)
                        shutil.copy2(src_file, dest_file)
                        restored_files.append(f"{folder_label}/{fname}")
            except Exception as exc:
                errors.append(f"{folder_label} restore error: {exc}")

        ts = _fmt_ts()
        state = _load_state()
        state.setdefault("restore_log", []).insert(0, {
            "source": "local",
            "timestamp": ts,
            "restored": len(restored_files),
            "errors": errors,
        })
        state["restore_log"] = state["restore_log"][:20]
        _save_state(state)

        return {
            "status": "ok" if not errors else "partial",
            "restored": len(restored_files),
            "errors": errors,
            "timestamp": ts,
        }

    # ----------------------------------------------------------- test conn

    def test_connection(self) -> dict:
        """Verify that the target local path is accessible for writing."""
        path = self.target_path
        if not path:
            return {"status": "error", "message": "Local sync path is not configured."}
        try:
            os.makedirs(path, exist_ok=True)
            test_file = os.path.join(path, ".sync_probe")
            with open(test_file, "w") as f:
                f.write("ok")
            os.remove(test_file)
            return {"status": "ok", "message": f"Local path '{path}' is writable."}
        except Exception as exc:
            return {"status": "error", "message": str(exc)}

    # ------------------------------------------------------------------ info

    def last_sync_info(self) -> dict:
        state = _load_state()
        return {
            "last_sync": state.get("local_last_sync"),
            "last_error": state.get("local_last_error"),
        }


# ---------------------------------------------------------------------------
# FTPSyncer
# ---------------------------------------------------------------------------

class FTPSyncer:
    """
    Incremental push/pull of SQLite DB, uploads/, avatars/, and site-media/
    to/from a remote FTP server. Uses stdlib ftplib only.

    Remote layout on FTP server:
      <remote_path>/storage/sqlite.db
      <remote_path>/static/uploads/<...>
      <remote_path>/static/avatars/<...>
      <remote_path>/static/site-media/<...>
    """

    def __init__(self, host: str, port: int, user: str, password: str, remote_path: str):
        self.host = host
        self.port = int(port or 21)
        self.user = user
        self.password = password
        self.remote_path = remote_path.rstrip("/")

    # ------------------------------------------------------------- connect

    def _connect(self) -> ftplib.FTP:
        ftp = ftplib.FTP()
        ftp.connect(self.host, self.port, timeout=30)
        ftp.login(self.user, self.password)
        ftp.set_pasv(True)
        return ftp

    def _mkdirs_ftp(self, ftp: ftplib.FTP, remote_dir: str):
        """Recursively create remote directories."""
        parts = remote_dir.replace("\\", "/").split("/")
        current = ""
        for part in parts:
            if not part:
                continue
            current = f"{current}/{part}" if current else part
            try:
                ftp.mkd(current)
            except ftplib.error_perm:
                pass  # directory already exists

    # ------------------------------------------------------------------ sync

    def run_sync(self) -> dict:
        """Push DB + changed/new files from all static folders to FTP remote."""
        state = _load_state()
        ftp_state = state.get("ftp", {})

        copied_files = []
        deleted_keys = []
        skipped = 0
        errors = []
        ftp = None

        try:
            ftp = self._connect()

            # 1. SQLite DB — dump to buffer using sqlite3.backup() then upload
            try:
                db_remote_dir = f"{self.remote_path}/storage"
                self._mkdirs_ftp(ftp, db_remote_dir)
                db_remote_path = f"{db_remote_dir}/sqlite.db"

                tmp_file = _DB_SRC + ".ftp_tmp"
                with sqlite3.connect(_DB_SRC) as src_conn:
                    with sqlite3.connect(tmp_file) as tmp_conn:
                        src_conn.backup(tmp_conn)
                with open(tmp_file, "rb") as f:
                    buf = io.BytesIO(f.read())
                try:
                    os.remove(tmp_file)
                except OSError:
                    pass
                buf.seek(0)
                ftp.storbinary(f"STOR {db_remote_path}", buf)
                copied_files.append("storage/sqlite.db")
            except Exception as exc:
                errors.append(f"FTP DB upload error: {exc}")

            # 2. Static folders — incremental based on mtime/size
            for folder_label, folder_src in _STATIC_FOLDERS:
                if not os.path.isdir(folder_src):
                    continue
                try:
                    for root, _, files in os.walk(folder_src):
                        rel_root = os.path.relpath(root, folder_src)
                        for fname in files:
                            src_file = os.path.join(root, fname)
                            rel_key = f"{folder_label}/" + (
                                os.path.join(rel_root, fname) if rel_root != "." else fname
                            )
                            sig = _file_sig(src_file)
                            if sig is None:
                                continue
                            if ftp_state.get(rel_key) and tuple(ftp_state[rel_key]) == sig:
                                skipped += 1
                                continue

                            if rel_root != ".":
                                remote_dir = f"{self.remote_path}/static/{folder_label}/{rel_root}"
                            else:
                                remote_dir = f"{self.remote_path}/static/{folder_label}"
                            self._mkdirs_ftp(ftp, remote_dir)
                            remote_file = f"{remote_dir}/{fname}"

                            with open(src_file, "rb") as f:
                                ftp.storbinary(f"STOR {remote_file}", f)
                            ftp_state[rel_key] = list(sig)
                            copied_files.append(rel_key)
                except Exception as exc:
                    errors.append(f"FTP {folder_label} error: {exc}")

            # 3. Deletion pass — delete remote files that no longer exist on disk.
            #    Only remove a key from state when deletion is confirmed, so transient
            #    network/FTP errors are retried automatically on the next sync run.
            for rel_key in list(ftp_state.keys()):
                folder_label = rel_key.split("/")[0]
                src_folder = _FOLDER_SRC_MAP.get(folder_label)
                if src_folder is None:
                    continue
                rel_path = rel_key[len(folder_label) + 1:]
                src_file = os.path.join(src_folder, rel_path)
                if not os.path.exists(src_file):
                    remote_file = f"{self.remote_path}/static/{rel_key}"
                    delete_ok = False
                    try:
                        ftp.delete(remote_file)
                        delete_ok = True
                    except ftplib.error_perm as e:
                        # 550 = file does not exist / not found — remote already clean
                        if str(e).startswith("550"):
                            delete_ok = True
                        else:
                            # Real permission error — keep state so next sync retries
                            errors.append(f"FTP delete permission error ({rel_key}): {e}")
                    except Exception as exc:
                        errors.append(f"FTP delete error ({rel_key}): {exc}")
                    if delete_ok:
                        deleted_keys.append(rel_key)
            for k in deleted_keys:
                ftp_state.pop(k, None)

        except Exception as exc:
            errors.append(f"FTP connection error: {exc}")
        finally:
            if ftp:
                try:
                    ftp.quit()
                except Exception:
                    pass

        state["ftp"] = ftp_state
        state["ftp_last_sync"] = _fmt_ts()
        state["ftp_last_error"] = errors[0] if errors else None
        _save_state(state)

        return {
            "status": "ok" if not errors else "partial",
            "copied": len(copied_files),
            "skipped": skipped,
            "deleted": len(deleted_keys),
            "errors": errors,
            "timestamp": state["ftp_last_sync"],
        }

    # --------------------------------------------------------------- restore

    def restore_from_source(self) -> dict:
        """Pull DB + all static-folder files from FTP server back into the container."""
        errors = []
        restored_files = []
        ftp = None
        tmp_db = _DB_SRC + ".ftp_restore_tmp"

        try:
            ftp = self._connect()

            # 1. SQLite DB
            db_remote = f"{self.remote_path}/storage/sqlite.db"
            try:
                with open(tmp_db, "wb") as f:
                    ftp.retrbinary(f"RETR {db_remote}", f.write)
                with sqlite3.connect(tmp_db) as src_conn:
                    with sqlite3.connect(_DB_SRC) as dst_conn:
                        src_conn.backup(dst_conn)
                restored_files.append("storage/sqlite.db")
            except Exception as exc:
                errors.append(f"FTP DB restore error: {exc}")
            finally:
                if os.path.exists(tmp_db):
                    try:
                        os.remove(tmp_db)
                    except OSError:
                        pass

            # 2. Static folders
            for folder_label, folder_dst in _STATIC_FOLDERS:
                remote_base = f"{self.remote_path}/static/{folder_label}"
                try:
                    self._restore_dir_ftp(ftp, remote_base, folder_dst, restored_files, errors)
                except Exception as exc:
                    errors.append(f"FTP {folder_label} restore error: {exc}")

        except Exception as exc:
            errors.append(f"FTP connection error: {exc}")
        finally:
            if ftp:
                try:
                    ftp.quit()
                except Exception:
                    pass

        ts = _fmt_ts()
        state = _load_state()
        state.setdefault("restore_log", []).insert(0, {
            "source": "ftp",
            "timestamp": ts,
            "restored": len(restored_files),
            "errors": errors,
        })
        state["restore_log"] = state["restore_log"][:20]
        _save_state(state)

        return {
            "status": "ok" if not errors else "partial",
            "restored": len(restored_files),
            "errors": errors,
            "timestamp": ts,
        }

    # --------------------------------------------------- FTP dir walk helper

    def _is_ftp_dir(self, ftp: ftplib.FTP, path: str) -> bool:
        """
        Reliably detect whether a remote FTP path is a directory.
        Uses CWD which works across more server implementations than MLST,
        because many servers return the file path from nlst(file) instead of
        an empty list.
        """
        original = ftp.pwd()
        try:
            ftp.cwd(path)
            ftp.cwd(original)
            return True
        except ftplib.error_perm:
            return False

    def _restore_dir_ftp(self, ftp, remote_dir, local_dir, restored, errors, _depth=0):
        """Recursively download remote_dir into local_dir."""
        if _depth > 20:
            return
        try:
            items = ftp.nlst(remote_dir)
        except ftplib.error_perm:
            return
        for item in items:
            fname = item.split("/")[-1]
            if fname in (".", ".."):
                continue
            local_path = os.path.join(local_dir, fname)
            if self._is_ftp_dir(ftp, item):
                os.makedirs(local_path, exist_ok=True)
                self._restore_dir_ftp(ftp, item, local_path, restored, errors, _depth + 1)
            else:
                try:
                    os.makedirs(os.path.dirname(local_path), exist_ok=True)
                    with open(local_path, "wb") as f:
                        ftp.retrbinary(f"RETR {item}", f.write)
                    restored.append(local_path)
                except Exception as exc:
                    errors.append(f"FTP file restore error ({item}): {exc}")

    # ----------------------------------------------------------- test conn

    def test_connection(self) -> dict:
        """Verify FTP credentials and that remote_path is accessible."""
        ftp = None
        try:
            ftp = self._connect()
            self._mkdirs_ftp(ftp, self.remote_path)
            probe = f"{self.remote_path}/.sync_probe"
            ftp.storbinary(f"STOR {probe}", io.BytesIO(b"ok"))
            try:
                ftp.delete(probe)
            except Exception:
                pass
            return {"status": "ok", "message": f"FTP connection to {self.host} successful."}
        except Exception as exc:
            return {"status": "error", "message": str(exc)}
        finally:
            if ftp:
                try:
                    ftp.quit()
                except Exception:
                    pass

    # ------------------------------------------------------------------ info

    def last_sync_info(self) -> dict:
        state = _load_state()
        return {
            "last_sync": state.get("ftp_last_sync"),
            "last_error": state.get("ftp_last_error"),
        }


# ---------------------------------------------------------------------------
# Convenience: combined sync-now (used by the admin "Sync Now" button)
# ---------------------------------------------------------------------------

def run_combined_sync(settings: dict) -> dict:
    """
    Run local and/or FTP sync based on the provided settings dict.
    Expected keys:
      local_sync_enabled   ('1'/'0')
      local_sync_path      (str)
      ftp_sync_enabled     ('1'/'0')
      ftp_host, ftp_port, ftp_user, ftp_password, ftp_remote_path
    """
    results = {}

    if settings.get("local_sync_enabled") == "1":
        path = (settings.get("local_sync_path") or "").strip()
        if path:
            syncer = LocalSyncer(path)
            results["local"] = syncer.run_sync()
        else:
            results["local"] = {"status": "error", "errors": ["Local sync path not configured"]}

    if settings.get("ftp_sync_enabled") == "1":
        try:
            syncer = FTPSyncer(
                host=settings.get("ftp_host", ""),
                port=int(settings.get("ftp_port") or 21),
                user=settings.get("ftp_user", ""),
                password=settings.get("ftp_password", ""),
                remote_path=(settings.get("ftp_remote_path") or "/").strip(),
            )
            results["ftp"] = syncer.run_sync()
        except Exception as exc:
            results["ftp"] = {"status": "error", "errors": [str(exc)]}

    return results
