"""
Cloud Backup Router

Manages cloud backup configuration (rclone-based sync to B2/S3/etc.):
- Config CRUD with sensitive field masking
- rclone.conf generation (preserves existing sections)
- Sync control (trigger, pause, resume)
- Status and log retrieval
- Connection testing
"""

import json
import os
import re
import subprocess
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel, Field
from slowapi import Limiter
from slowapi.util import get_remote_address

from ..core.dependencies import get_current_user, require_admin, get_app_state
from modules.universal_logger import get_logger

logger = get_logger('CloudBackup')

router = APIRouter(prefix="/api/cloud-backup", tags=["Cloud Backup"])
limiter = Limiter(key_func=get_remote_address)

# ============================================================================
# CONSTANTS
# ============================================================================

RCLONE_CONF_PATH = Path("/root/.config/rclone/rclone.conf")
RCLONE_REMOTE_NAME = "cloud-backup-remote"
RCLONE_CRYPT_NAME = "cloud-backup-crypt"
STATUS_FILE = Path("/tmp/cloud_backup_status.json")
TRIGGER_FILE = Path("/tmp/cloud_backup_trigger.json")
LOG_FILE = Path("/opt/media-downloader/logs/cloud_backup.log")
IMMICH_BASE = Path("/opt/immich")
SETTINGS_KEY = "cloud_backup"
SERVICE_NAME = "cloud-backup-sync.service"

SENSITIVE_FIELDS = {"key_id", "application_key", "encryption_password", "encryption_salt"}
MASK_VALUE = "****"

DEFAULT_INCLUDE_DIRS = ["paid", "private", "md", "el", "elv", "ela", "upload", "review", "recycle", "db_dumps", "app_backup"]
DEFAULT_EXCLUDE_DIRS = ["lost+found", "db.old"]

# ============================================================================
# JOB TRACKING FOR BACKGROUND SYNC
# ============================================================================

_sync_jobs: Dict[str, Dict] = {}
_jobs_lock = Lock()


def _get_sync_job(job_id: str) -> Optional[Dict]:
    with _jobs_lock:
        return _sync_jobs.get(job_id)


def _update_sync_job(job_id: str, updates: Dict):
    with _jobs_lock:
        if job_id in _sync_jobs:
            _sync_jobs[job_id].update(updates)


def _create_sync_job(job_id: str) -> Dict:
    with _jobs_lock:
        _sync_jobs[job_id] = {
            'id': job_id,
            'status': 'running',
            'started_at': datetime.now().isoformat(),
            'completed_at': None,
            'dirs_synced': 0,
            'dirs_total': 0,
            'current_dir': None,
            'current_file': None,
            'transfer_stats': None,
            'phase': 'preparing',
            'error': None,
        }
        return _sync_jobs[job_id]


def _cleanup_old_sync_jobs():
    with _jobs_lock:
        now = datetime.now()
        to_remove = []
        for job_id, job in _sync_jobs.items():
            if job.get('completed_at'):
                try:
                    completed = datetime.fromisoformat(job['completed_at'])
                    if (now - completed).total_seconds() > 3600:
                        to_remove.append(job_id)
                except (ValueError, TypeError):
                    pass
        for job_id in to_remove:
            del _sync_jobs[job_id]


# ============================================================================
# PYDANTIC MODELS
# ============================================================================

class CloudBackupConfigModel(BaseModel):
    enabled: bool = False
    provider: str = "b2"
    endpoint: str = ""
    bucket: str = ""
    key_id: str = ""
    application_key: str = ""
    encryption_enabled: bool = True
    encryption_password: str = ""
    encryption_salt: str = ""
    include_dirs: List[str] = Field(default_factory=lambda: list(DEFAULT_INCLUDE_DIRS))
    exclude_dirs: List[str] = Field(default_factory=lambda: list(DEFAULT_EXCLUDE_DIRS))
    cooldown_seconds: int = 300
    bandwidth_limit: Optional[str] = None


class CloudBackupConfigUpdate(BaseModel):
    enabled: Optional[bool] = None
    provider: Optional[str] = None
    endpoint: Optional[str] = None
    bucket: Optional[str] = None
    key_id: Optional[str] = None
    application_key: Optional[str] = None
    encryption_enabled: Optional[bool] = None
    encryption_password: Optional[str] = None
    encryption_salt: Optional[str] = None
    include_dirs: Optional[List[str]] = None
    exclude_dirs: Optional[List[str]] = None
    cooldown_seconds: Optional[int] = None
    bandwidth_limit: Optional[str] = None


# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

def _get_settings_manager():
    """Get SettingsManager from AppState."""
    app_state = get_app_state()
    return app_state.settings


def _load_config() -> dict:
    """Load cloud backup config from settings DB."""
    sm = _get_settings_manager()
    stored = sm.get(SETTINGS_KEY)
    if stored and isinstance(stored, dict):
        return stored
    return CloudBackupConfigModel().model_dump()


def _save_config(config: dict):
    """Save cloud backup config to settings DB."""
    sm = _get_settings_manager()
    sm.set(SETTINGS_KEY, config, category='cloud_backup', description='Cloud backup configuration')


def _mask_config(config: dict) -> dict:
    """Return config with sensitive fields masked."""
    masked = dict(config)
    for field in SENSITIVE_FIELDS:
        val = masked.get(field)
        if val and val != MASK_VALUE:
            masked[field] = MASK_VALUE
        elif not val:
            masked[field] = None
    return masked


def _merge_config_update(existing: dict, update: dict) -> dict:
    """Merge update into existing config, treating MASK_VALUE as 'keep existing'."""
    merged = dict(existing)
    for key, val in update.items():
        if val is None:
            continue
        if key in SENSITIVE_FIELDS and val == MASK_VALUE:
            continue  # keep existing
        merged[key] = val
    return merged


def _rclone_obscure(password: str) -> str:
    """Obscure a password using rclone obscure."""
    if not password:
        return ""
    result = subprocess.run(
        ["rclone", "obscure", password],
        capture_output=True, text=True, timeout=10
    )
    if result.returncode != 0:
        raise RuntimeError(f"rclone obscure failed: {result.stderr.strip()}")
    return result.stdout.strip()


def _regenerate_rclone_config(config: dict):
    """
    Regenerate rclone.conf preserving all existing sections
    except cloud-backup-remote and cloud-backup-crypt.
    """
    RCLONE_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)

    # Read existing config
    existing_content = ""
    if RCLONE_CONF_PATH.exists():
        existing_content = RCLONE_CONF_PATH.read_text()

    # Parse existing sections (preserve all except our managed ones)
    sections = {}
    current_section = None
    current_lines = []

    for line in existing_content.splitlines():
        section_match = re.match(r'^\[(.+)\]$', line.strip())
        if section_match:
            if current_section is not None:
                sections[current_section] = '\n'.join(current_lines)
            current_section = section_match.group(1)
            current_lines = [line]
        elif current_section is not None:
            current_lines.append(line)
        # Lines before any section are ignored (shouldn't exist in rclone.conf)

    if current_section is not None:
        sections[current_section] = '\n'.join(current_lines)

    # Remove our managed sections
    sections.pop(RCLONE_REMOTE_NAME, None)
    sections.pop(RCLONE_CRYPT_NAME, None)

    # Build new sections
    provider = config.get('provider', 'b2')
    new_sections = []

    if provider == 'b2':
        new_sections.append(f"""[{RCLONE_REMOTE_NAME}]
type = b2
account = {config.get('key_id', '')}
key = {config.get('application_key', '')}
endpoint = {config.get('endpoint', '')}""")
    else:
        # S3-compatible fallback
        new_sections.append(f"""[{RCLONE_REMOTE_NAME}]
type = s3
provider = Other
access_key_id = {config.get('key_id', '')}
secret_access_key = {config.get('application_key', '')}
endpoint = {config.get('endpoint', '')}""")

    if config.get('encryption_enabled', True):
        enc_pass = _rclone_obscure(config.get('encryption_password', ''))
        enc_salt = _rclone_obscure(config.get('encryption_salt', ''))
        bucket = config.get('bucket', '')
        new_sections.append(f"""[{RCLONE_CRYPT_NAME}]
type = crypt
remote = {RCLONE_REMOTE_NAME}:{bucket}
password = {enc_pass}
password2 = {enc_salt}
filename_encryption = standard
directory_name_encryption = true""")

    # Write config atomically
    output_parts = []
    for section_name, section_content in sections.items():
        output_parts.append(section_content)
    for section in new_sections:
        output_parts.append(section)

    final_content = '\n\n'.join(output_parts) + '\n'

    fd, tmp_path = tempfile.mkstemp(dir=str(RCLONE_CONF_PATH.parent), suffix='.tmp')
    try:
        with os.fdopen(fd, 'w') as f:
            f.write(final_content)
        os.chmod(tmp_path, 0o600)
        os.rename(tmp_path, str(RCLONE_CONF_PATH))
        logger.info("rclone.conf regenerated successfully")
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _read_status_file() -> dict:
    """Read the daemon's status JSON file."""
    try:
        if STATUS_FILE.exists():
            data = json.loads(STATUS_FILE.read_text())
            return data
    except (json.JSONDecodeError, OSError) as e:
        logger.warning(f"Failed to read status file: {e}")
    return {}


def _get_service_status() -> str:
    """Check systemd service status."""
    try:
        result = subprocess.run(
            ["systemctl", "is-active", SERVICE_NAME],
            capture_output=True, text=True, timeout=5
        )
        return result.stdout.strip()  # active, inactive, failed, etc.
    except Exception:
        return "unknown"


def _find_ssd_mount() -> Optional[Path]:
    """Auto-detect the SSD underlying a mergerfs mount at IMMICH_BASE.

    Reads /proc/mounts to find the mergerfs entry, resolves drive names
    to mount paths, then checks each drive's rotational flag (ROTA=0 = SSD).
    """
    try:
        # Build a map of all mount points: mountpoint -> (device, ...)
        mounts = {}
        with open('/proc/mounts') as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2 and parts[0].startswith('/dev/'):
                    mounts[parts[1]] = parts[0]

        with open('/proc/mounts') as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 3 and parts[1] == str(IMMICH_BASE) and 'mergerfs' in parts[2]:
                    # Source can be "samsung2tb:onetouch4tb" or "/mnt/a:/mnt/b"
                    drive_names = parts[0].split(':')
                    for name in drive_names:
                        name = name.strip()
                        if not name:
                            continue
                        # Try as-is first (absolute path), then /mnt/<name>
                        candidates = [name, f'/mnt/{name}']
                        for drive_path in candidates:
                            if drive_path in mounts:
                                dev = mounts[drive_path]
                                dev_base = dev.rstrip('0123456789')
                                dev_name = dev_base.split('/')[-1]
                                rotational = Path(f'/sys/block/{dev_name}/queue/rotational')
                                if rotational.exists() and rotational.read_text().strip() == '0':
                                    return Path(drive_path)
    except Exception:
        pass
    return None


def _get_fast_storage_base() -> Path:
    """Get the fastest storage path for dumps.

    Returns the SSD mount under mergerfs if detected, otherwise falls back
    to the mergerfs path itself. Dirs created here are visible via mergerfs
    without any cross-filesystem copies.
    """
    ssd = _find_ssd_mount()
    if ssd:
        return ssd
    return IMMICH_BASE


_FAST_BASE = _get_fast_storage_base()
DB_DUMPS_DIR = _FAST_BASE / "db_dumps"
APP_BACKUP_DIR = _FAST_BASE / "app_backup"

# Systemd service files to back up
SYSTEMD_FILES = [
    "media-downloader.service",
    "media-downloader-api.service",
    "media-downloader-frontend.service",
    "media-downloader-db-cleanup.service",
    "media-downloader-db-cleanup.timer",
    "xvfb-media-downloader.service",
    "cloud-backup-sync.service",
]


def _run_pre_sync_dumps(job_id: str = None, include_immich_db: bool = True, include_app_archive: bool = True):
    """Dump databases and archive app + system configs before syncing.

    Args:
        include_immich_db: If False, skip Immich DB dump (only needed daily).
        include_app_archive: If False, skip app tar.gz + system configs (only needed daily).
    """
    DB_DUMPS_DIR.mkdir(parents=True, exist_ok=True)
    APP_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    errors = []

    import shutil
    import time as _time

    def _status(msg):
        if job_id:
            _update_sync_job(job_id, {'current_dir': msg})

    def _get_dir_size(path):
        """Get total size of a directory in bytes."""
        try:
            return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
        except OSError:
            return 0

    def _get_docker_dir_size(container, path):
        """Get size of a directory inside a docker container."""
        try:
            r = subprocess.run(
                ["docker", "exec", container, "du", "-sb", path],
                capture_output=True, text=True, timeout=5
            )
            if r.returncode == 0:
                return int(r.stdout.split()[0])
        except Exception:
            pass
        return 0

    def _run_with_progress(cmd, label, total_bytes, size_fn, env=None):
        """Run a subprocess while polling output size for progress updates."""
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
        while proc.poll() is None:
            _time.sleep(3)
            current = size_fn()
            if total_bytes > 0:
                pct = min(99, int(current / total_bytes * 100))
                _status(f"{label} ({pct}%)")
        stdout, stderr = proc.communicate()
        return proc.returncode, stderr.decode() if stderr else ""

    # ── 1. Database dumps ──────────────────────────────────────────────

    # Immich PostgreSQL (Docker container) — only during daily/full backup
    if include_immich_db:
        try:
            _status("Dumping Immich database (0%)...")
            logger.info("Dumping Immich database...")
            # Get DB size for progress tracking
            sz_result = subprocess.run(
                ["docker", "exec", "immich_postgres",
                 "psql", "-U", "postgres", "-d", "immich", "-tAc",
                 "SELECT pg_database_size('immich')"],
                capture_output=True, text=True, timeout=10
            )
            immich_db_size = int(sz_result.stdout.strip()) if sz_result.returncode == 0 else 0

            # Clean previous dump dir
            subprocess.run(
                ["docker", "exec", "immich_postgres", "rm", "-rf", "/tmp/immich_dump"],
                capture_output=True, text=True, timeout=10
            )

            rc, stderr = _run_with_progress(
                ["docker", "exec", "immich_postgres",
                 "pg_dump", "-U", "postgres", "-d", "immich",
                 "--no-owner", "--no-acl", "-Fd", "-j", "4",
                 "--exclude-table-data=face_search",
                 "--exclude-table-data=smart_search",
                 "-f", "/tmp/immich_dump"],
                "Dumping Immich DB",
                immich_db_size,
                lambda: _get_docker_dir_size("immich_postgres", "/tmp/immich_dump"),
            )
            if rc == 0:
                _status("Copying Immich dump...")
                subprocess.run(
                    ["docker", "exec", "immich_postgres",
                     "tar", "cf", "/tmp/immich_dump.tar", "-C", "/tmp", "immich_dump"],
                    capture_output=True, text=True, timeout=120
                )
                # docker cp directly to SSD-backed DB_DUMPS_DIR (no cross-fs move needed)
                final_dest = str(DB_DUMPS_DIR / "immich_dump.tar")
                subprocess.run(
                    ["docker", "cp", "immich_postgres:/tmp/immich_dump.tar", final_dest],
                    capture_output=True, text=True, timeout=120
                )
                subprocess.run(
                    ["docker", "exec", "immich_postgres",
                     "sh", "-c", "rm -rf /tmp/immich_dump /tmp/immich_dump.tar"],
                    capture_output=True, text=True, timeout=10
                )
                size_mb = Path(final_dest).stat().st_size / 1e6
                logger.info(f"Immich DB dump: {size_mb:.1f} MB")
            else:
                errors.append(f"Immich pg_dump failed: {stderr[:200]}")
                logger.error(f"Immich pg_dump failed: {stderr[:200]}")
        except Exception as e:
            errors.append(f"Immich pg_dump error: {e}")
            logger.error(f"Immich pg_dump error: {e}")

    # Media Downloader PostgreSQL (local) — parallel directory-format dump
    # Uses --lock-wait-timeout to avoid hanging when scheduler holds locks,
    # with a serial (-j 1) retry if parallel dump fails on lock contention.
    try:
        _status("Dumping Media Downloader DB (0%)...")
        logger.info("Dumping Media Downloader database...")
        # Get DB size
        env = os.environ.copy()
        env["PGPASSWORD"] = "PNsihOXvvuPwWiIvGlsc9Fh2YmMmB"
        sz_result = subprocess.run(
            ["psql", "-h", "localhost", "-U", "media_downloader",
             "-d", "media_downloader", "-tAc",
             "SELECT pg_database_size('media_downloader')"],
            capture_output=True, text=True, timeout=10, env=env
        )
        md_db_size = int(sz_result.stdout.strip()) if sz_result.returncode == 0 else 0

        # Dump directly to destination (cross-fs move is slower than direct write)
        final_dump_dir = str(DB_DUMPS_DIR / "media_downloader_dump")
        if Path(final_dump_dir).exists():
            shutil.rmtree(final_dump_dir)

        base_cmd = [
            "pg_dump", "-h", "localhost", "-U", "media_downloader",
            "-d", "media_downloader", "--no-owner", "--no-acl",
            "--exclude-table-data=thumbnails",
            "--lock-wait-timeout=30000",
        ]
        rc, stderr = _run_with_progress(
            base_cmd + ["-Fd", "-j", "4", "-f", final_dump_dir],
            "Dumping Media Downloader DB",
            md_db_size,
            lambda: _get_dir_size(final_dump_dir),
            env=env,
        )
        if rc != 0 and "could not obtain lock" in stderr:
            # Lock contention — retry serial after a short wait
            logger.warning("Parallel pg_dump hit lock contention, retrying serial in 15s...")
            _status("DB lock contention, retrying...")
            if Path(final_dump_dir).exists():
                shutil.rmtree(final_dump_dir)
            import time as _time2
            _time2.sleep(15)
            rc, stderr = _run_with_progress(
                base_cmd + ["-Fd", "-j", "1", "-f", final_dump_dir],
                "Dumping Media Downloader DB (retry)",
                md_db_size,
                lambda: _get_dir_size(final_dump_dir),
                env=env,
            )
        if rc == 0:
            total = _get_dir_size(final_dump_dir)
            logger.info(f"Media Downloader DB dump: {total / 1e6:.1f} MB")
        else:
            errors.append(f"Media Downloader pg_dump failed: {stderr[:200]}")
            logger.error(f"Media Downloader pg_dump failed: {stderr[:200]}")
    except Exception as e:
        errors.append(f"Media Downloader pg_dump error: {e}")
        logger.error(f"Media Downloader pg_dump error: {e}")

    # ── 2-6: App archive + system configs (daily only) ─────────────────

    if include_app_archive:
        try:
            _status("Archiving media-downloader app...")
            logger.info("Archiving media-downloader app...")
            # Write directly to SSD-backed APP_BACKUP_DIR (no cross-fs move)
            final_archive = APP_BACKUP_DIR / "media-downloader-app.tar.gz"
            result = subprocess.run(
                ["tar", "czf", str(final_archive),
                 "--exclude=./venv",
                 "--exclude=./web/frontend/node_modules",
                 "--exclude=./logs",
                 "--exclude=./cache/thumbnails",
                 "--exclude=./__pycache__",
                 "--exclude=./.git",
                 "--exclude=./temp",
                 "-C", "/opt", "media-downloader"],
                capture_output=True, text=True, timeout=600
            )
            if result.returncode == 0:
                size_mb = final_archive.stat().st_size / 1e6
                logger.info(f"App archive: {size_mb:.1f} MB")
            else:
                errors.append(f"App archive failed: {result.stderr[:200]}")
                logger.error(f"App archive failed: {result.stderr[:200]}")
        except Exception as e:
            errors.append(f"App archive error: {e}")
            logger.error(f"App archive error: {e}")

        try:
            services_dir = APP_BACKUP_DIR / "systemd"
            services_dir.mkdir(parents=True, exist_ok=True)
            copied = 0
            for svc in SYSTEMD_FILES:
                src = Path(f"/etc/systemd/system/{svc}")
                if src.exists():
                    (services_dir / svc).write_text(src.read_text())
                    copied += 1
            logger.info(f"Copied {copied} systemd service files")
        except Exception as e:
            errors.append(f"Systemd backup error: {e}")
            logger.error(f"Systemd backup error: {e}")

        try:
            if RCLONE_CONF_PATH.exists():
                (APP_BACKUP_DIR / "rclone.conf").write_text(RCLONE_CONF_PATH.read_text())
                logger.info("Copied rclone.conf")
        except Exception as e:
            errors.append(f"rclone config backup error: {e}")
            logger.error(f"rclone config backup error: {e}")

        try:
            compose_src = IMMICH_BASE / "docker-compose.yml"
            if compose_src.exists():
                (APP_BACKUP_DIR / "immich-docker-compose.yml").write_text(compose_src.read_text())
            env_src = IMMICH_BASE / ".env"
            if env_src.exists():
                (APP_BACKUP_DIR / "immich-env").write_text(env_src.read_text())
            logger.info("Copied Immich docker-compose + .env")
        except Exception as e:
            errors.append(f"Immich compose backup error: {e}")
            logger.error(f"Immich compose backup error: {e}")

        try:
            restore_src = Path("/opt/media-downloader/scripts/cloud_backup_restore.sh")
            if restore_src.exists():
                (APP_BACKUP_DIR / "RESTORE.sh").write_text(restore_src.read_text())
                logger.info("Copied restore script as RESTORE.sh")
        except Exception as e:
            errors.append(f"Restore script backup error: {e}")
            logger.error(f"Restore script backup error: {e}")

    return errors


def _run_full_backup(config: dict, job_id: str):
    """Run Media Downloader DB dump + rclone sync.
    Immich DB + app archive are daily-only (handled by daemon at 3 AM).
    """
    dump_errors = _run_pre_sync_dumps(job_id=job_id, include_immich_db=False, include_app_archive=False)
    _run_file_sync(config, job_id, extra_errors=dump_errors)


def _parse_rclone_stats(line: str) -> Optional[Dict]:
    """Parse an rclone --stats-one-line output line into structured data.

    Actual rclone format:
      INFO  :     1.424 GiB / 1.424 GiB, 100%, 0 B/s, ETA -
      INFO  :     1.424 GiB / 1.424 GiB, 100%, 10.5 MiB/s, ETA 7m30s, Checks: 100, Transferred: 15 / 45
    """
    stats = {}
    try:
        # Extract: bytes_done / bytes_total, pct%, speed
        m = re.search(r'([\d.]+\s*\S*?B)\s*/\s*([\d.]+\s*\S*?B),\s*(\d+)%,\s*([\d.]+\s*\S*?/s)', line)
        if m:
            stats['bytes_done'] = m.group(1).strip()
            stats['bytes_total'] = m.group(2).strip()
            stats['pct'] = int(m.group(3))
            stats['speed'] = m.group(4).strip()
        # Extract ETA
        m_eta = re.search(r'ETA\s+([\w.]+)', line)
        if m_eta and m_eta.group(1) != '-':
            stats['eta'] = m_eta.group(1)
        # Extract file count: "Transferred: 15 / 45" at end of one-line stats
        m_files = re.search(r'Transferred:\s*(\d+)\s*/\s*(\d+)', line)
        if m_files:
            stats['files_done'] = int(m_files.group(1))
            stats['files_total'] = int(m_files.group(2))
        # Extract checks count — "Checks: 100" or "(chk#573/582)"
        m_checks = re.search(r'Checks:\s*(\d+)', line)
        if m_checks:
            stats['checks_done'] = int(m_checks.group(1))
        m_chk = re.search(r'chk#(\d+)/(\d+)', line)
        if m_chk:
            stats['checks_done'] = int(m_chk.group(1))
            stats['checks_total'] = int(m_chk.group(2))
    except (ValueError, AttributeError):
        pass
    return stats if stats else None


def _run_file_sync(config: dict, job_id: str, extra_errors: list = None):
    """Run rclone sync for all included directories with live transfer stats."""
    include_dirs = config.get('include_dirs', DEFAULT_INCLUDE_DIRS)
    encryption_enabled = config.get('encryption_enabled', True)
    bandwidth_limit = config.get('bandwidth_limit')
    bucket = config.get('bucket', '')

    dirs_to_sync = [d for d in include_dirs if (IMMICH_BASE / d).is_dir()]
    _update_sync_job(job_id, {'dirs_total': len(dirs_to_sync), 'transfer_stats': None})

    errors = list(extra_errors) if extra_errors else []
    for i, dir_name in enumerate(dirs_to_sync):
        _update_sync_job(job_id, {
            'current_dir': dir_name, 'dirs_synced': i,
            'transfer_stats': None, 'current_file': None,
            'phase': 'checking',
        })
        src = str(IMMICH_BASE / dir_name)
        if encryption_enabled:
            dest = f"{RCLONE_CRYPT_NAME}:{dir_name}"
        else:
            dest = f"{RCLONE_REMOTE_NAME}:{bucket}/{dir_name}"

        cmd = [
            "rclone", "sync", src, dest,
            "--config", str(RCLONE_CONF_PATH),
            "--stats", "3s",
            "--stats-one-line",
            "-v",
            "--transfers", "4",
            "--checkers", "16",
            "--fast-list",
        ]
        if bandwidth_limit:
            cmd.extend(["--bwlimit", bandwidth_limit])

        try:
            proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
            log_fh = open(str(LOG_FILE), 'a')
            ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            log_fh.write(f"\n[{ts}] Syncing {dir_name}/ ({i+1}/{len(dirs_to_sync)})\n")
            log_fh.flush()
            try:
                for line in proc.stderr:
                    stripped = line.strip()
                    if not stripped:
                        continue

                    # Clean rclone output: strip syslog prefix "<6>INFO  :" → clean message
                    clean = re.sub(r'^<\d+>', '', stripped)  # strip syslog priority
                    clean = re.sub(r'^(INFO|DEBUG|ERROR|NOTICE)\s*:\s*', '', clean).strip()

                    # Detect progress/stats lines (with % or chk# markers)
                    is_progress = ('%,' in stripped and '/s' in stripped) or 'chk#' in stripped

                    # Write cleaned output to log file
                    if is_progress:
                        # Progress line — only log the latest (overwrite style)
                        log_fh.write(f"  [progress] {clean}\n")
                    elif ': Copied' in stripped or ': Moved' in stripped:
                        m = re.search(r'(.+?):\s*(Copied|Moved)\s*(.*)', clean)
                        if m:
                            fname = m.group(1).strip()
                            action = m.group(2)
                            detail = m.group(3).strip()
                            short = fname.split('/')[-1] if '/' in fname else fname
                            log_fh.write(f"  {action}: {short} {detail}\n")
                        else:
                            log_fh.write(f"  {clean}\n")
                    elif 'Checks:' in clean and 'Transferred:' in clean:
                        # Summary line at end of dir sync
                        log_fh.write(f"  {clean}\n")
                    elif clean:
                        log_fh.write(f"  {clean}\n")
                    log_fh.flush()

                    # Update live stats for frontend
                    if is_progress:
                        stats = _parse_rclone_stats(stripped)
                        if stats:
                            # Determine phase from stats
                            phase = 'checking'
                            if stats.get('files_done', 0) > 0 or stats.get('files_total', 0) > 0:
                                phase = 'transferring'
                            elif stats.get('pct', 0) > 0:
                                phase = 'transferring'
                            _update_sync_job(job_id, {'transfer_stats': stats, 'phase': phase})

                    if ': Copied' in stripped or ': Moved' in stripped:
                        m = re.search(r'INFO\s*:\s*(.+?):\s*(?:Copied|Moved)', stripped)
                        if m:
                            fname = m.group(1).strip()
                            short = fname.split('/')[-1] if '/' in fname else fname
                            _update_sync_job(job_id, {'current_file': short, 'phase': 'transferring'})
            finally:
                log_fh.close()

            proc.wait()
            ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            if proc.returncode != 0:
                err_msg = f"Sync failed for {dir_name}: exit code {proc.returncode}"
                errors.append(err_msg)
                logger.error(err_msg)
                with open(str(LOG_FILE), 'a') as f:
                    f.write(f"[{ts}] ERROR: {dir_name}/ failed (exit {proc.returncode})\n")
            else:
                with open(str(LOG_FILE), 'a') as f:
                    f.write(f"[{ts}] Completed {dir_name}/\n")
        except Exception as e:
            errors.append(f"Sync error for {dir_name}: {e}")
            logger.error(f"Sync error for {dir_name}: {e}")

    ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    status_msg = 'completed' if not errors else f'completed with {len(errors)} error(s)'
    with open(str(LOG_FILE), 'a') as f:
        f.write(f"\n[{ts}] Sync {status_msg} — {len(dirs_to_sync)} directories\n{'='*60}\n")

    completed_status = 'completed' if not errors else 'completed_with_errors'
    _update_sync_job(job_id, {
        'status': completed_status,
        'completed_at': datetime.now().isoformat(),
        'dirs_synced': len(dirs_to_sync),
        'current_dir': None,
        'error': '; '.join(errors) if errors else None,
    })

    # Write status file for daemon/dashboard
    _write_manual_sync_status(completed_status, errors)
    _cleanup_old_sync_jobs()

    # Auto-refresh cloud storage stats after sync
    _trigger_cloud_status_refresh()


def _write_manual_sync_status(status: str, errors: list):
    """Write sync completion info to status file for dashboard consumption."""
    try:
        existing = _read_status_file()
        existing['last_manual_sync'] = datetime.now().isoformat()
        existing['last_manual_sync_status'] = status
        if errors:
            existing['last_manual_sync_errors'] = errors
        STATUS_FILE.write_text(json.dumps(existing))
    except OSError as e:
        logger.warning(f"Failed to write status file: {e}")


# ============================================================================
# API ENDPOINTS
# ============================================================================

@router.get("/config")
async def get_config(user=Depends(get_current_user)):
    """Get cloud backup configuration (sensitive fields masked)."""
    config = _load_config()
    return _mask_config(config)


@router.put("/config")
async def update_config(update: CloudBackupConfigUpdate, user=Depends(require_admin)):
    """Save cloud backup configuration and regenerate rclone.conf."""
    existing = _load_config()
    update_dict = update.model_dump(exclude_unset=True)
    merged = _merge_config_update(existing, update_dict)

    # Validate required fields if enabling
    if merged.get('enabled'):
        missing = []
        if not merged.get('endpoint'):
            missing.append('endpoint')
        if not merged.get('bucket'):
            missing.append('bucket')
        if not merged.get('key_id'):
            missing.append('key_id')
        if not merged.get('application_key'):
            missing.append('application_key')
        if merged.get('encryption_enabled'):
            if not merged.get('encryption_password'):
                missing.append('encryption_password')
            if not merged.get('encryption_salt'):
                missing.append('encryption_salt')
        if missing:
            from fastapi import HTTPException
            raise HTTPException(status_code=400, detail=f"Missing required fields: {', '.join(missing)}")

    _save_config(merged)

    # Regenerate rclone config if we have credentials
    if merged.get('key_id') and merged.get('application_key'):
        try:
            _regenerate_rclone_config(merged)
        except Exception as e:
            logger.error(f"Failed to regenerate rclone config: {e}")
            return {"status": "saved", "rclone_config": "failed", "error": str(e), "config": _mask_config(merged)}

    return {"status": "saved", "rclone_config": "updated", "config": _mask_config(merged)}


@router.get("/status")
async def get_status(user=Depends(get_current_user)):
    """Get sync status including service state, last sync time, storage info.

    Reads job progress from the daemon's status file. When the daemon is
    running a manual sync (has job_id), its progress fields take priority
    over in-memory job tracking.
    """
    config = _load_config()
    service_status = _get_service_status()
    daemon_status = _read_status_file()

    active_job = None
    daemon_job_id = daemon_status.get('job_id')

    # Priority 1: Daemon is actively syncing a job — build active_job from daemon progress
    if daemon_job_id and daemon_status.get('state') == 'syncing':
        active_job = {
            'id': daemon_job_id,
            'status': 'running',
            'started_at': daemon_status.get('started_at'),
            'completed_at': None,
            'dirs_synced': daemon_status.get('dirs_synced', 0),
            'dirs_total': daemon_status.get('dirs_total', 0),
            'current_dir': daemon_status.get('current_dir'),
            'current_file': daemon_status.get('current_file'),
            'transfer_stats': daemon_status.get('transfer_stats'),
            'phase': daemon_status.get('phase', 'syncing'),
            'error': None,
        }
        # Also update in-memory job to stay in sync
        if _get_sync_job(daemon_job_id):
            _update_sync_job(daemon_job_id, {
                'dirs_synced': daemon_status.get('dirs_synced', 0),
                'dirs_total': daemon_status.get('dirs_total', 0),
                'current_dir': daemon_status.get('current_dir'),
                'current_file': daemon_status.get('current_file'),
                'transfer_stats': daemon_status.get('transfer_stats'),
                'phase': daemon_status.get('phase', 'syncing'),
            })

    # Priority 2: Daemon has a completed job — update in-memory job
    elif daemon_job_id and daemon_status.get('completed_at'):
        in_mem = _get_sync_job(daemon_job_id)
        if in_mem and in_mem.get('status') == 'running':
            completed_status = 'completed' if not daemon_status.get('sync_error') else 'completed_with_errors'
            _update_sync_job(daemon_job_id, {
                'status': completed_status,
                'completed_at': daemon_status.get('completed_at'),
                'dirs_synced': daemon_status.get('dirs_synced', 0),
                'dirs_total': daemon_status.get('dirs_total', 0),
                'error': daemon_status.get('sync_error'),
                'phase': 'completed',
                'current_dir': None,
                'current_file': None,
                'transfer_stats': None,
            })
            # Sync just finished in daemon — refresh cloud storage stats
            _trigger_cloud_status_refresh()

    # Priority 3: Fall back to in-memory jobs (legacy / pending trigger)
    if active_job is None:
        with _jobs_lock:
            for job in _sync_jobs.values():
                if job['status'] == 'running':
                    active_job = dict(job)
                    break

    # Determine effective state
    is_syncing = active_job is not None or daemon_status.get('state') == 'syncing'
    if active_job is not None:
        effective_state = 'syncing'
    elif daemon_status.get('state'):
        effective_state = daemon_status['state']
    elif service_status == 'active':
        effective_state = 'idle'
    else:
        effective_state = 'idle'

    return {
        "configured": bool(config.get('key_id') and config.get('bucket')),
        "enabled": config.get('enabled', False),
        "service_status": service_status,
        "syncing": is_syncing,
        "last_sync": daemon_status.get('last_sync', daemon_status.get('last_manual_sync')),
        "last_sync_status": daemon_status.get('last_sync_status', daemon_status.get('last_manual_sync_status')),
        "files_watched": daemon_status.get('files_watched', 0),
        "storage_used": daemon_status.get('storage_used'),
        "error_count": daemon_status.get('error_count', 0),
        "last_error": daemon_status.get('last_error'),
        "cooldown_remaining": daemon_status.get('cooldown_remaining', 0),
        "active_job": active_job,
        "state": effective_state,
    }


@router.post("/sync")
async def trigger_sync(user=Depends(get_current_user)):
    """Trigger a manual sync by delegating to the daemon via trigger file.

    The sync runs inside the daemon process (cloud-backup-sync.service),
    so it survives API/scheduler restarts. Progress is tracked via the
    daemon's status file.
    """
    from fastapi import HTTPException

    config = _load_config()
    if not config.get('key_id') or not config.get('bucket'):
        raise HTTPException(status_code=400, detail="Cloud backup not configured")

    # Check daemon is running
    service_status = _get_service_status()
    if service_status != "active":
        raise HTTPException(status_code=503, detail=f"Cloud backup daemon is not running (status: {service_status})")

    # Check daemon not already syncing (via status file)
    daemon_status = _read_status_file()
    if daemon_status.get('state') == 'syncing':
        raise HTTPException(status_code=409, detail="A sync is already in progress")

    # Check no trigger file already pending
    if TRIGGER_FILE.exists():
        raise HTTPException(status_code=409, detail="A sync trigger is already pending")

    # Check for in-memory running jobs (race condition guard)
    with _jobs_lock:
        for job in _sync_jobs.values():
            if job['status'] == 'running':
                raise HTTPException(status_code=409, detail="A sync is already in progress")

    job_id = str(uuid.uuid4())

    # Write trigger file for daemon to pick up
    trigger_data = {
        "type": "sync",
        "job_id": job_id,
        "requested_at": datetime.now().isoformat(),
        "requested_by": "api",
    }
    try:
        TRIGGER_FILE.write_text(json.dumps(trigger_data))
    except OSError as e:
        raise HTTPException(status_code=500, detail=f"Failed to write trigger file: {e}")

    # Create in-memory job tracker for immediate API response
    _create_sync_job(job_id)

    return {"status": "started", "job_id": job_id}


@router.post("/pause")
async def pause_sync(user=Depends(get_current_user)):
    """Stop the cloud backup sync service."""
    try:
        result = subprocess.run(
            ["systemctl", "stop", SERVICE_NAME],
            capture_output=True, text=True, timeout=30
        )
        if result.returncode != 0:
            return {"status": "error", "message": result.stderr.strip()}
        return {"status": "stopped"}
    except Exception as e:
        logger.error(f"Failed to stop sync service: {e}")
        return {"status": "error", "message": str(e)}


@router.post("/resume")
async def resume_sync(user=Depends(get_current_user)):
    """Start the cloud backup sync service."""
    try:
        result = subprocess.run(
            ["systemctl", "start", SERVICE_NAME],
            capture_output=True, text=True, timeout=30
        )
        if result.returncode != 0:
            return {"status": "error", "message": result.stderr.strip()}
        return {"status": "started"}
    except Exception as e:
        logger.error(f"Failed to start sync service: {e}")
        return {"status": "error", "message": str(e)}


def _format_progress_summary(progress_line: str) -> str:
    """Format a [progress] line into a clean summary like '    Checked 108,024 / 118,056 files — 100% — 46.985 KiB/s'."""
    parts = []
    chk_m = re.search(r'chk#(\d+)/(\d+)', progress_line)
    if chk_m:
        parts.append(f"Checked {int(chk_m.group(1)):,} / {int(chk_m.group(2)):,} files")
    xfr_m = re.search(r'xfr#(\d+)/(\d+)', progress_line)
    if xfr_m:
        parts.append(f"Transferred {int(xfr_m.group(1)):,} / {int(xfr_m.group(2)):,} files")
    pct_m = re.search(r'(\d+)%', progress_line)
    if pct_m:
        parts.append(f"{pct_m.group(1)}%")
    speed_m = re.search(r'([\d.]+\s*\S*i?B/s)', progress_line)
    if speed_m:
        parts.append(speed_m.group(1))
    return f"    {' — '.join(parts)}" if parts else ""


@router.get("/logs")
async def get_logs(lines: int = 200, user=Depends(get_current_user)):
    """Get last N lines from the cloud backup sync log, cleaned and summarized."""
    if not LOG_FILE.exists():
        return {"logs": "", "lines": 0}

    try:
        result = subprocess.run(
            ["tail", "-n", str(min(lines * 10, 5000)), str(LOG_FILE)],
            capture_output=True, text=True, timeout=10
        )
        # Whitelist-based parser: only emit lines matching known useful patterns.
        # Everything else (daemon internals, tracebacks, config noise) is dropped.
        output = []
        last_progress = None

        # Regex to strip daemon/universal-logger timestamp prefixes from any line
        _prefix_re = re.compile(
            r'^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[.,]\d+\s+'
            r'(?:\[MediaDownloader\.\w+\]\s+)?'
            r'(?:\[(?:INFO|DEBUG|WARNING|ERROR)\]\s+)?'
            r'(?:\[Core\]\s+\[(?:INFO|DEBUG|WARNING|ERROR)\]\s+)?'
        )

        for line in result.stdout.splitlines():
            raw = line.strip()
            if not raw:
                continue

            # Skip universal-logger formatted lines entirely (they duplicate
            # the file_handler lines with a different format)
            if '[MediaDownloader.' in raw:
                continue

            # Strip daemon log prefix to get the clean message
            stripped = _prefix_re.sub('', raw).strip()
            if not stripped:
                continue

            # ── Capture rclone progress lines (emitted by daemon Popen loop) ──
            if re.match(r'^\s*\[progress\]', stripped):
                last_progress = stripped
                continue

            # ── Section headers: "[2026-03-22 01:13:58] Syncing paid/ (1/11)" ──
            if re.match(r'^\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\]\s+', stripped):
                # Flush pending progress summary before new section
                if last_progress:
                    output.append(_format_progress_summary(last_progress))
                    last_progress = None
                output.append(stripped)
                continue

            # ── Separator lines: "===..." ──
            if re.match(r'^={10,}$', stripped):
                output.append(stripped)
                continue

            # ── File transfer lines: "fname: Copied (new, replaced existing)" ──
            m_xfer = re.match(r'^(.+?):\s*(Copied|Moved|Deleted)\s*(.*)', stripped)
            if m_xfer:
                fname = m_xfer.group(1).strip()
                action = m_xfer.group(2)
                detail = m_xfer.group(3).strip()
                short = fname.split('/')[-1] if '/' in fname else fname
                detail_short = f" ({detail})" if detail and detail != '(new)' else ''
                output.append(f"    {action}: {short}{detail_short}")
                continue

            # ── Our cleaned format: "    Copied: filename.jpg" ──
            m_clean = re.match(r'^\s*(Copied|Moved|Deleted):\s*(.+)', stripped)
            if m_clean:
                output.append(f"    {m_clean.group(1)}: {m_clean.group(2).strip()}")
                continue

            # ── Completion lines from manual log writes: "Completed dir/" ──
            if re.match(r'^Completed\s+\S+/', stripped):
                output.append(stripped)
                continue

            # ── rclone sync errors ──
            if re.match(r'^rclone sync failed', stripped):
                if not any(stripped in prev for prev in output[-3:]):
                    output.append(f"    ERROR: {stripped}")
                continue
            if re.match(r'^ERROR:', stripped):
                if not any(stripped in prev for prev in output[-3:]):
                    output.append(stripped)
                continue

            # ── Sync summary lines: "Sync completed — 11 directories" ──
            if re.match(r'^Sync (completed|completed with \d+ error)', stripped):
                output.append(stripped)
                continue

            # ── pg_dump error (deduplicated) ──
            if 'pg_dump failed' in stripped:
                if not any('pg_dump failed' in prev for prev in output[-5:]):
                    output.append(f"    ERROR: {stripped}")
                continue

            # Everything else is dropped (daemon internals, tracebacks, config noise, etc.)

        # Flush trailing progress for active directory
        if last_progress:
            output.append(_format_progress_summary(last_progress))

        # Return last N lines
        final = output[-lines:] if len(output) > lines else output
        log_content = '\n'.join(final)
        return {"logs": log_content, "lines": len(final)}
    except Exception as e:
        return {"logs": f"Error reading logs: {e}", "lines": 0}


@router.post("/test")
async def test_connection(user=Depends(get_current_user)):
    """Test the rclone connection by listing the remote."""
    config = _load_config()
    if not config.get('key_id') or not config.get('application_key'):
        from fastapi import HTTPException
        raise HTTPException(status_code=400, detail="Cloud backup credentials not configured")

    # Ensure rclone config is up to date
    try:
        _regenerate_rclone_config(config)
    except Exception as e:
        return {"status": "error", "message": f"Failed to write rclone config: {e}"}

    # Test with rclone lsd
    bucket = config.get('bucket', '')
    encryption_enabled = config.get('encryption_enabled', True)

    if encryption_enabled:
        test_remote = f"{RCLONE_CRYPT_NAME}:"
    else:
        test_remote = f"{RCLONE_REMOTE_NAME}:{bucket}"

    try:
        result = subprocess.run(
            ["rclone", "lsd", test_remote, "--config", str(RCLONE_CONF_PATH), "--max-depth", "1"],
            capture_output=True, text=True, timeout=30
        )
        if result.returncode == 0:
            dirs = [line.strip().split()[-1] for line in result.stdout.strip().splitlines() if line.strip()]
            return {"status": "success", "message": "Connection successful", "remote_dirs": dirs}
        else:
            return {"status": "error", "message": result.stderr.strip() or "Connection failed"}
    except subprocess.TimeoutExpired:
        return {"status": "error", "message": "Connection timed out (30s)"}
    except Exception as e:
        return {"status": "error", "message": str(e)}


@router.get("/job/{job_id}")
async def get_job(job_id: str, user=Depends(get_current_user)):
    """Get the status of a specific sync job.

    Checks both in-memory tracking and the daemon's status file.
    Daemon status takes priority when the job is actively running there.
    """
    # Check daemon status file for this job
    daemon_status = _read_status_file()
    daemon_job_id = daemon_status.get('job_id')

    if daemon_job_id == job_id:
        if daemon_status.get('state') == 'syncing':
            # Daemon is actively running this job
            return {
                'id': job_id,
                'status': 'running',
                'started_at': daemon_status.get('started_at'),
                'completed_at': None,
                'dirs_synced': daemon_status.get('dirs_synced', 0),
                'dirs_total': daemon_status.get('dirs_total', 0),
                'current_dir': daemon_status.get('current_dir'),
                'current_file': daemon_status.get('current_file'),
                'transfer_stats': daemon_status.get('transfer_stats'),
                'phase': daemon_status.get('phase', 'syncing'),
                'error': None,
            }
        elif daemon_status.get('completed_at'):
            # Daemon completed this job
            has_error = bool(daemon_status.get('sync_error'))
            return {
                'id': job_id,
                'status': 'completed_with_errors' if has_error else 'completed',
                'started_at': daemon_status.get('started_at'),
                'completed_at': daemon_status.get('completed_at'),
                'dirs_synced': daemon_status.get('dirs_synced', 0),
                'dirs_total': daemon_status.get('dirs_total', 0),
                'current_dir': None,
                'current_file': None,
                'transfer_stats': None,
                'phase': 'completed',
                'error': daemon_status.get('sync_error'),
            }

    # Fall back to in-memory job tracking
    job = _get_sync_job(job_id)
    if not job:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail="Job not found")
    return job


@router.get("/available-dirs")
async def get_available_dirs(user=Depends(get_current_user)):
    """List available directories under /opt/immich for backup."""
    dirs = []
    if IMMICH_BASE.is_dir():
        for entry in sorted(IMMICH_BASE.iterdir()):
            if entry.is_dir() and not entry.name.startswith('.'):
                dirs.append(entry.name)
    return {"dirs": dirs}


CLOUD_STATUS_CACHE_FILE = Path("/tmp/cloud_storage_status.json")
CLOUD_STATUS_REFRESH_INTERVAL = 6 * 3600  # 6 hours

_cloud_status_query: Dict[str, Any] = {"running": False}
_cloud_status_lock = Lock()


def _trigger_cloud_status_refresh():
    """Start a cloud status refresh in a background thread if not already running."""
    import threading
    with _cloud_status_lock:
        if _cloud_status_query["running"]:
            return
        _cloud_status_query["running"] = True
    t = threading.Thread(target=_run_cloud_status_query, daemon=True)
    t.start()


def _cloud_status_periodic_loop():
    """Periodic loop that refreshes cloud status every 6 hours.

    On startup, checks if the cached data is stale (older than the refresh
    interval) and triggers an immediate refresh if so. This prevents restarts
    from resetting the 6-hour countdown indefinitely.
    """
    import time as _time

    # Check staleness on startup — refresh immediately if cache is old
    try:
        _time.sleep(30)  # brief delay to let API fully start
        stale = True
        if CLOUD_STATUS_CACHE_FILE.exists():
            cached = json.loads(CLOUD_STATUS_CACHE_FILE.read_text())
            queried_at = cached.get('queried_at')
            if queried_at:
                from datetime import datetime as _dt
                age = (_dt.now() - _dt.fromisoformat(queried_at)).total_seconds()
                stale = age > CLOUD_STATUS_REFRESH_INTERVAL
        if stale:
            config = _load_config()
            if config.get('key_id') and config.get('bucket'):
                logger.info("Cloud status cache is stale on startup, refreshing...")
                _trigger_cloud_status_refresh()
    except Exception as e:
        logger.warning(f"Startup cloud status staleness check error: {e}")

    while True:
        _time.sleep(CLOUD_STATUS_REFRESH_INTERVAL)
        try:
            config = _load_config()
            if config.get('key_id') and config.get('bucket'):
                logger.info("Periodic cloud status refresh starting...")
                _trigger_cloud_status_refresh()
        except Exception as e:
            logger.warning(f"Periodic cloud status refresh error: {e}")


# Start the periodic refresh thread
import threading as _threading
_cloud_status_timer = _threading.Thread(target=_cloud_status_periodic_loop, daemon=True)
_cloud_status_timer.start()


def _run_cloud_status_query():
    """Background worker: query rclone for cloud storage stats and cache to disk."""
    from concurrent.futures import ThreadPoolExecutor, as_completed

    config = _load_config()
    bucket = config.get('bucket', '')
    encryption_enabled = config.get('encryption_enabled', True)
    provider = config.get('provider', 'b2')
    endpoint = config.get('endpoint', '')

    if encryption_enabled:
        remote = f"{RCLONE_CRYPT_NAME}:"
    else:
        remote = f"{RCLONE_REMOTE_NAME}:{bucket}"

    result = {
        "provider": provider,
        "bucket": bucket,
        "endpoint": endpoint,
        "encryption": encryption_enabled,
        "total_files": None,
        "total_size": None,
        "total_size_human": None,
        "directories": [],
        "error": None,
        "queried_at": datetime.now().isoformat(),
        "query_status": "running",
    }

    def _rclone_size(target: str, timeout: int = 120) -> dict:
        r = subprocess.run(
            ["rclone", "size", target, "--config", str(RCLONE_CONF_PATH), "--json"],
            capture_output=True, text=True, timeout=timeout
        )
        if r.returncode == 0:
            return json.loads(r.stdout)
        return {"error": r.stderr.strip()[:100]}

    def _get_dir_size(d: str) -> dict:
        dir_remote = f"{remote}{d}" if remote.endswith(":") else f"{remote}/{d}"
        try:
            dd = _rclone_size(dir_remote, timeout=90)
            if "error" in dd:
                return {"name": d, "files": 0, "size": 0, "size_human": "error", "error": dd["error"]}
            return {
                "name": d,
                "files": dd.get("count", 0),
                "size": dd.get("bytes", 0),
                "size_human": _format_bytes(dd.get("bytes", 0)),
            }
        except subprocess.TimeoutExpired:
            return {"name": d, "files": 0, "size": 0, "size_human": "timeout"}
        except Exception as e:
            return {"name": d, "files": 0, "size": 0, "size_human": "error", "error": str(e)[:100]}

    try:
        # Save initial "running" state so frontend can show progress
        CLOUD_STATUS_CACHE_FILE.write_text(json.dumps(result))

        lsd_result = subprocess.run(
            ["rclone", "lsd", remote, "--config", str(RCLONE_CONF_PATH), "--max-depth", "1"],
            capture_output=True, text=True, timeout=30
        )
        if lsd_result.returncode != 0:
            result["error"] = lsd_result.stderr.strip()[:200]
            result["query_status"] = "error"
            CLOUD_STATUS_CACHE_FILE.write_text(json.dumps(result))
            return

        dir_names = []
        for line in lsd_result.stdout.strip().splitlines():
            parts = line.strip().split()
            if parts:
                dir_names.append(parts[-1])

        with ThreadPoolExecutor(max_workers=6) as executor:
            dir_futures = {executor.submit(_get_dir_size, d): d for d in sorted(dir_names)}

            dir_infos = []
            for future in as_completed(dir_futures, timeout=300):
                dir_infos.append(future.result())

        # Compute totals from per-directory results (avoids slow rclone size on entire remote)
        total_files = sum(d.get("files", 0) for d in dir_infos)
        total_bytes = sum(d.get("size", 0) for d in dir_infos)
        result["total_files"] = total_files
        result["total_size"] = total_bytes
        result["total_size_human"] = _format_bytes(total_bytes)
        result["directories"] = dir_infos
        result["query_status"] = "complete"

    except Exception as e:
        result["error"] = str(e)[:200]
        result["query_status"] = "error"
    finally:
        result["queried_at"] = datetime.now().isoformat()
        CLOUD_STATUS_CACHE_FILE.write_text(json.dumps(result))

        with _cloud_status_lock:
            _cloud_status_query["running"] = False


@router.get("/cloud-status")
async def get_cloud_status(user=Depends(get_current_user)):
    """Return cached cloud storage stats. Returns instantly."""
    config = _load_config()
    if not config.get('key_id') or not config.get('bucket'):
        from fastapi import HTTPException
        raise HTTPException(status_code=400, detail="Cloud backup not configured")

    # Return cached results if available
    cached = {}
    try:
        if CLOUD_STATUS_CACHE_FILE.exists():
            cached = json.loads(CLOUD_STATUS_CACHE_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        pass

    with _cloud_status_lock:
        is_running = _cloud_status_query["running"]

    if cached:
        cached["query_running"] = is_running
        return cached

    return {
        "provider": config.get('provider', 'b2'),
        "bucket": config.get('bucket', ''),
        "endpoint": config.get('endpoint', ''),
        "encryption": config.get('encryption_enabled', True),
        "total_files": None,
        "total_size": None,
        "total_size_human": None,
        "directories": [],
        "error": None,
        "queried_at": None,
        "query_status": "never",
        "query_running": is_running,
    }


@router.post("/cloud-status/refresh")
async def refresh_cloud_status(background_tasks: BackgroundTasks, user=Depends(get_current_user)):
    """Trigger a background refresh of cloud storage stats."""
    config = _load_config()
    if not config.get('key_id') or not config.get('bucket'):
        from fastapi import HTTPException
        raise HTTPException(status_code=400, detail="Cloud backup not configured")

    with _cloud_status_lock:
        if _cloud_status_query["running"]:
            return {"status": "already_running"}
        _cloud_status_query["running"] = True

    background_tasks.add_task(_run_cloud_status_query)
    return {"status": "started"}


def _format_bytes(b: int) -> str:
    """Format bytes into human-readable string."""
    if b is None or b == 0:
        return "0 B"
    for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']:
        if abs(b) < 1024.0:
            return f"{b:.1f} {unit}" if unit != 'B' else f"{b} B"
        b /= 1024.0
    return f"{b:.1f} EB"
