"""
wivexx_media_import.py — external storage auto-import  (v1.7)
═══════════════════════════════════════════════════════════════════════
Watches for newly connected drives (SD cards, USB sticks, external disks).
The first time a device appears, a WIVEXX-styled dialog asks whether to
auto-import from it (Always / Never / Later) and lets you set an optional
TAG (e.g. "osmo").  The decision is remembered per device — identified by
its volume serial number — in wivexx_media_devices.json.

On every later connection of an ALLOWED device, all photos and videos on it
are copied to:

    <MEDIA_IMPORT_DEST>\\MM-DD-YYYY\\photos[\\tag]\\...
    <MEDIA_IMPORT_DEST>\\MM-DD-YYYY\\video[\\tag]\\...

Files that already exist at the destination with the same size are skipped,
so re-plugging a card the same day never duplicates anything.

Public API (used by wivexx_api.py / wivexx_gui.py):
    start(cfg, speak_fn, prompt_fn, logger) -> bool
    get_devices() -> dict          # serial → {label, policy, tag, ...}
    set_device(serial, policy, tag)
    forget_device(serial)
    PHOTO_EXTS / VIDEO_EXTS
"""

import ctypes
import datetime as _dt
import json
import os
import shutil
import threading
import time
from typing import Callable, Optional

PHOTO_EXTS = {
    ".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".bmp",
    ".tif", ".tiff", ".gif",
    # camera RAW
    ".raw", ".arw", ".sr2", ".cr2", ".cr3", ".nef", ".nrw", ".orf",
    ".rw2", ".dng", ".raf", ".srw", ".pef", ".gpr", ".x3f",
}
VIDEO_EXTS = {
    ".mp4", ".mov", ".avi", ".mkv", ".mts", ".m2ts", ".mpg", ".mpeg",
    ".wmv", ".flv", ".webm", ".3gp", ".m4v", ".ts", ".mxf",
    # action cam / cinema formats
    ".insv", ".360", ".braw", ".r3d", ".lrf",
}

# Folders never worth scanning (system/app data on fixed external disks)
_SKIP_DIRS = {
    "system volume information", "$recycle.bin", "$recycle bin", "recycler",
    "windows", "program files", "program files (x86)", "programdata",
    "appdata", "node_modules", "__pycache__", ".git",
}

_DEVICES_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "wivexx_media_devices.json"
)

_lock = threading.Lock()
_started = False
_logger = None
_speak: Optional[Callable[[str], None]] = None
_prompt: Optional[Callable] = None
_cfg = None


def _default_dest() -> str:
    """Portable per-user import folder; never raises (works before _cfg is set)."""
    try:
        return _cfg.default_media_dest()
    except Exception:
        return os.path.join(os.path.expanduser("~"), "Pictures", "Wivexx")

# ── v1.21: live transfer status + pause/cancel ────────────────────────────────
# A single status dict drives the transfer overlay + control-room panel. It is
# kept current by the import engine; the web GUI polls get_import_status().
_cancel_evt = threading.Event()
_pause_evt = threading.Event()              # set ⇒ the copy loop is paused
_status_lock = threading.Lock()
_import_status: dict = {
    "active": False,
    "kind": "import",          # "import" | "deliver" (deliver reuses the overlay)
    "phase": "idle",           # idle|scanning|copying|paused|done|cancelled|failed
    "label": "",               # display name (nickname › volume label › "drive X")
    "drive": "",               # raw volume label
    "serial": "",
    "dest": "",
    "copied": 0,               # photos+videos copied so far
    "total": 0,                # files to copy (0 while still scanning)
    "skipped": 0,
    "failed": 0,
    "bytes_done": 0,
    "bytes_total": 0,
    "eta": None,               # seconds remaining (None = unknown)
    "current": "",             # filename being copied right now
    "paused": False,
    "started": 0.0,            # epoch the copy phase began
}


def _set_status(**kw) -> None:
    with _status_lock:
        _import_status.update(kw)


def get_import_status() -> dict:
    """Snapshot of the live transfer, with a derived percent + elapsed so the
    UI doesn't have to recompute them."""
    with _status_lock:
        st = dict(_import_status)
    total = st.get("total") or 0
    bt = st.get("bytes_total") or 0
    if bt:
        st["percent"] = min(100, int(st.get("bytes_done", 0) * 100 / bt))
    elif total:
        st["percent"] = min(100, int(st.get("copied", 0) * 100 / total))
    else:
        st["percent"] = 0
    started = st.get("started") or 0.0
    st["elapsed"] = int(time.time() - started) if started else 0
    return st


def cancel_import() -> None:
    """Stop the running import after the current file finishes copying."""
    if _import_status["active"]:
        _cancel_evt.set()
        _pause_evt.clear()                  # un-pause so the loop can see cancel


def pause_import() -> None:
    if _import_status["active"]:
        _pause_evt.set()
        _set_status(paused=True, phase="paused")


def resume_import() -> None:
    if _import_status["active"]:
        _pause_evt.clear()
        _set_status(paused=False, phase="copying")


def is_import_paused() -> bool:
    return _pause_evt.is_set()


# ── Device registry ────────────────────────────────────────────────────────────
def get_devices() -> dict:
    try:
        with open(_DEVICES_FILE, encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _save_devices(data: dict) -> None:
    try:
        with open(_DEVICES_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    except Exception as e:
        if _logger:
            _logger.warning("media import: could not save device registry: %s", e)


def set_device(serial: str, policy: str, tag: str = "",
               label: str = "", nickname: Optional[str] = None) -> None:
    """policy: 'allow' | 'deny'.  *nickname* is the friendly name shown in the
    UI (None ⇒ keep the existing one); *tag* is the folder-sort label."""
    data = get_devices()
    rec = data.get(serial, {})
    rec.update({
        "label":  label or rec.get("label", ""),
        "policy": policy,
        "tag":    (tag or "").strip(),
    })
    if nickname is not None:
        rec["nickname"] = nickname.strip()
    data[serial] = rec
    _save_devices(data)


def set_nickname(serial: str, nickname: str) -> None:
    """Set/clear just the friendly display name for a known device."""
    data = get_devices()
    if serial in data:
        data[serial]["nickname"] = (nickname or "").strip()
        _save_devices(data)


def display_name(rec: Optional[dict], label: str = "", letter: str = "") -> str:
    """The friendliest available name for a drive: nickname › volume label ›
    "drive X"."""
    rec = rec or {}
    nick = (rec.get("nickname") or "").strip()
    if nick:
        return nick
    lbl = (label or rec.get("label") or "").strip()
    if lbl:
        return lbl
    return f"drive {letter[0]}" if letter else "storage"


def forget_device(serial: str) -> None:
    data = get_devices()
    if serial in data:
        del data[serial]
        _save_devices(data)


# ── Drive helpers (Win32) ──────────────────────────────────────────────────────
def _drive_bitmask() -> int:
    try:
        return ctypes.windll.kernel32.GetLogicalDrives()
    except Exception:
        return 0


def _drive_type(root: str) -> int:
    # 2 = removable, 3 = fixed, 4 = network, 5 = cdrom
    try:
        return ctypes.windll.kernel32.GetDriveTypeW(root)
    except Exception:
        return 0


def _volume_info(root: str) -> tuple:
    """Return (label, serial_hex) for a mounted volume."""
    try:
        kernel32 = ctypes.windll.kernel32
        label_buf = ctypes.create_unicode_buffer(261)
        fs_buf    = ctypes.create_unicode_buffer(261)
        serial    = ctypes.c_uint(0)
        ok = kernel32.GetVolumeInformationW(
            ctypes.c_wchar_p(root), label_buf, 261,
            ctypes.byref(serial), None, None, fs_buf, 261)
        if not ok:
            return ("", "")
        return (label_buf.value or "", f"{serial.value:08X}")
    except Exception:
        return ("", "")


# ── Import engine ──────────────────────────────────────────────────────────────
def _classify(filename: str) -> Optional[str]:
    ext = os.path.splitext(filename)[1].lower()
    if ext in PHOTO_EXTS:
        return "photos"
    if ext in VIDEO_EXTS:
        return "video"
    return None


def _build_existing_index(dest_root: str) -> set:
    """(filename.lower(), size) of every file ANYWHERE under *dest_root* —
    v1.8: a file already imported on a previous day (or moved into a different
    subfolder) is recognised as a duplicate and skipped."""
    index: set = set()
    try:
        for root, dirs, files in os.walk(dest_root):
            dirs[:] = [d for d in dirs if not d.startswith(("$", "."))]
            for fname in files:
                try:
                    index.add((fname.lower(),
                               os.path.getsize(os.path.join(root, fname))))
                except OSError:
                    continue
    except Exception as e:
        if _logger:
            _logger.debug("media import: index build failed: %s", e)
    return index


def _safe_copy(src: str, dest_dir: str, fname: str,
               existing: Optional[set] = None) -> str:
    """Copy with collision handling. Returns 'copied' | 'skipped' | 'failed'."""
    try:
        size = os.path.getsize(src)
        # Global duplicate check: same name + size anywhere under the dest root
        if existing is not None and (fname.lower(), size) in existing:
            return "skipped"
        os.makedirs(dest_dir, exist_ok=True)
        target = os.path.join(dest_dir, fname)
        if os.path.exists(target):
            if os.path.getsize(target) == size:
                return "skipped"            # identical file already imported
            base, ext = os.path.splitext(fname)
            n = 1
            while os.path.exists(target):
                target = os.path.join(dest_dir, f"{base}_{n}{ext}")
                n += 1
        shutil.copy2(src, target)
        if existing is not None:
            existing.add((fname.lower(), size))
        return "copied"
    except Exception as e:
        if _logger:
            _logger.warning("media import: copy failed %s: %s", src, e)
        return "failed"


def _scan_tree(src_root: str) -> tuple:
    """Pre-walk the card to count the photos/videos and total bytes, so the UI
    can show a real progress bar + ETA.  Returns (file_count, total_bytes).
    Honors cancel so a cancel during scanning bails immediately."""
    count, total_bytes = 0, 0
    try:
        for root, dirs, files in os.walk(src_root):
            if _cancel_evt.is_set():
                break
            dirs[:] = [d for d in dirs
                       if d.lower() not in _SKIP_DIRS and not d.startswith(("$", "."))]
            for fname in files:
                if _classify(fname) is None:
                    continue
                count += 1
                try:
                    total_bytes += os.path.getsize(os.path.join(root, fname))
                except OSError:
                    pass
    except Exception as e:
        if _logger:
            _logger.debug("media import: scan failed: %s", e)
    return count, total_bytes


def _wait_if_paused() -> float:
    """Block while paused; return how long we were paused (to keep ETA honest)."""
    if not _pause_evt.is_set():
        return 0.0
    p0 = time.time()
    while _pause_evt.is_set() and not _cancel_evt.is_set():
        time.sleep(0.15)
    return time.time() - p0


def _file_date_folder(path: str) -> str:
    """MM-DD-YYYY of when the file was CREATED/shot.  Uses the earliest of
    creation time and modification time — the modification time of a camera
    file is the moment of capture, and ctime can be the (later) copy time."""
    try:
        st = os.stat(path)
        ctime = getattr(st, "st_ctime", st.st_mtime)
        when = min(st.st_mtime, ctime) if ctime else st.st_mtime
        return _dt.datetime.fromtimestamp(when).strftime("%m-%d-%Y")
    except Exception:
        return _dt.datetime.now().strftime("%m-%d-%Y")


def _import_tree(src_root: str, dest_root: str, tag: str = "",
                 total_bytes: int = 0) -> dict:
    """Scan *src_root* recursively and copy every photo/video into a dated
    folder structure under *dest_root*. Returns counters.

    v1.8.1: files are grouped by their OWN creation date — a card holding
    several days' footage produces one MM-DD-YYYY folder per day, with the
    usual type (photos/video) and optional tag sorting inside each.
    v1.21: updates the live status (bytes/eta/current) after every file and
    honours pause_import() as well as cancel_import()."""
    tag = (tag or "").strip()
    existing = _build_existing_index(dest_root)

    counters = {"photos": 0, "video": 0, "skipped": 0, "failed": 0,
                "cancelled": False}
    bytes_done = 0
    paused_accum = 0.0
    t0 = time.time()
    for root, dirs, files in os.walk(src_root):
        if _cancel_evt.is_set():
            counters["cancelled"] = True
            break
        dirs[:] = [d for d in dirs
                   if d.lower() not in _SKIP_DIRS and not d.startswith(("$", "."))]
        for fname in files:
            paused_accum += _wait_if_paused()
            if _cancel_evt.is_set():
                counters["cancelled"] = True
                break
            kind = _classify(fname)
            if kind is None:
                continue
            src_path = os.path.join(root, fname)
            try:
                fsize = os.path.getsize(src_path)
            except OSError:
                fsize = 0
            _set_status(current=fname)
            base = os.path.join(dest_root, _file_date_folder(src_path))
            dest_dir = (os.path.join(base, kind, tag) if tag
                        else os.path.join(base, kind))
            result = _safe_copy(src_path, dest_dir, fname, existing)
            if result == "copied":
                counters[kind] += 1
            else:
                counters[result] += 1
            bytes_done += fsize
            done_n = counters["photos"] + counters["video"]
            # ETA from throughput, discounting time spent paused
            elapsed = time.time() - t0 - paused_accum
            eta = None
            if total_bytes and bytes_done > 0 and elapsed > 0.5:
                rate = bytes_done / elapsed
                if rate > 0 and bytes_done < total_bytes:
                    eta = (total_bytes - bytes_done) / rate
            _set_status(copied=done_n, skipped=counters["skipped"],
                        failed=counters["failed"], bytes_done=bytes_done,
                        eta=eta)
    return counters


def _run_import(letter: str, label: str, serial: str, tag: str) -> None:
    dest_root = getattr(_cfg, "MEDIA_IMPORT_DEST", "") or _default_dest()
    display = display_name(get_devices().get(serial), label, letter)
    if _import_status["active"]:
        if _logger:
            _logger.info("media import: another import is running — %s skipped",
                         display)
        return
    _cancel_evt.clear()
    _pause_evt.clear()
    _set_status(active=True, kind="import", phase="scanning", label=display,
                drive=label, serial=serial, dest=dest_root, copied=0, total=0,
                skipped=0, failed=0, bytes_done=0, bytes_total=0, eta=None,
                current="", paused=False, started=time.time())
    if _logger:
        _logger.info("media import: starting from %s (%s, serial %s, tag=%r) → %s",
                     letter, label, serial, tag, dest_root)
    if _speak:
        _speak(f"New storage {display} detected, sir. Importing photos and videos now.")
    t0 = time.time()
    # Count first so the UI gets a real bar + ETA (cheap vs. the copy itself).
    n_files, n_bytes = _scan_tree(letter)
    _set_status(total=n_files, bytes_total=n_bytes, phase="copying",
                started=time.time())
    try:
        c = (_import_tree(letter, dest_root, tag, total_bytes=n_bytes)
             if not _cancel_evt.is_set()
             else {"photos": 0, "video": 0, "skipped": 0, "failed": 0,
                   "cancelled": True})
    except Exception as e:
        if _logger:
            _logger.error("media import: failed: %s", e)
        _set_status(active=False, phase="failed", paused=False)
        if _speak:
            _speak(f"The import from {display} failed, sir. Check the log.")
        return
    finally:
        _pause_evt.clear()
    _set_status(active=False, paused=False, current="",
                phase=("cancelled" if c.get("cancelled") else "done"))
    _cancel_evt.clear()

    # Remember when we last imported this device
    data = get_devices()
    if serial in data:
        data[serial]["last_import"] = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
        _save_devices(data)

    secs = int(time.time() - t0)
    if _logger:
        _logger.info("media import: done in %ds — %s", secs, c)
    if not _speak:
        return
    copied_n = c["photos"] + c["video"]
    if c.get("cancelled"):
        _speak(f"Import cancelled, sir — {copied_n} "
               f"file{'s' if copied_n != 1 else ''} were copied before I stopped.")
        return
    if copied_n == 0 and c["skipped"] == 0:
        _speak(f"I found no photos or videos on {display}, sir.")
    else:
        parts = []
        if c["video"]:
            parts.append(f"{c['video']} video{'s' if c['video'] != 1 else ''}")
        if c["photos"]:
            parts.append(f"{c['photos']} photo{'s' if c['photos'] != 1 else ''}")
        copied = " and ".join(parts) if parts else "nothing new"
        extra = (f" {c['skipped']} duplicate{'s' if c['skipped'] != 1 else ''} "
                 f"were already in the library and skipped." if c["skipped"] else "")
        _speak(f"Import complete, sir — {copied} from {display}.{extra}")


# ── New-device handling ────────────────────────────────────────────────────────
def _handle_new_drive(letter: str) -> None:
    time.sleep(1.5)                          # let Windows finish mounting
    dtype = _drive_type(letter)
    if dtype not in (2, 3):                  # removable or fixed external only
        return
    label, serial = _volume_info(letter)
    if not serial:
        return
    if _logger:
        _logger.info("media import: drive %s connected (label=%r serial=%s type=%d)",
                     letter, label, serial, dtype)

    rec = get_devices().get(serial)
    if rec:
        policy = rec.get("policy", "")
        if policy == "deny":
            return
        if policy == "allow":
            threading.Thread(
                target=_run_import, args=(letter, label or rec.get("label", ""),
                                          serial, rec.get("tag", "")),
                daemon=True, name="wivexx-media-import").start()
            return

    # Unknown device → ask the user (GUI dialog). No GUI → ask next time.
    if _prompt is None:
        return

    def _on_decision(result: Optional[dict]) -> None:
        if not result:
            return                            # "Later" — ask again next plug-in
        policy = result.get("policy", "")
        tag    = (result.get("tag") or "").strip()
        if policy in ("allow", "deny"):
            set_device(serial, policy, tag, label=label)
        if policy == "allow":
            threading.Thread(
                target=_run_import, args=(letter, label, serial, tag),
                daemon=True, name="wivexx-media-import").start()

    try:
        _prompt({"letter": letter, "label": label or "Unnamed storage",
                 "serial": serial,
                 "dest": getattr(_cfg, "MEDIA_IMPORT_DEST", "") or _default_dest()},
                _on_decision)
    except Exception as e:
        if _logger:
            _logger.warning("media import: prompt failed: %s", e)


def _watcher() -> None:
    prev = _drive_bitmask()
    while True:
        time.sleep(3)
        try:
            mask = _drive_bitmask()
            new_bits = mask & ~prev
            prev = mask
            if not new_bits:
                continue
            for i in range(26):
                if new_bits & (1 << i):
                    letter = f"{chr(65 + i)}:\\"
                    threading.Thread(target=_handle_new_drive, args=(letter,),
                                     daemon=True,
                                     name="wivexx-media-detect").start()
        except Exception as e:
            if _logger:
                _logger.debug("media import watcher: %s", e)


# ── Public entry point ─────────────────────────────────────────────────────────
def start(cfg, speak_fn: Optional[Callable[[str], None]] = None,
          prompt_fn: Optional[Callable] = None, logger=None) -> bool:
    global _started, _logger, _speak, _prompt, _cfg
    _logger = logger
    _speak = speak_fn
    _prompt = prompt_fn
    _cfg = cfg
    if not getattr(cfg, "MEDIA_IMPORT_ENABLED", True):
        return False
    if os.name != "nt":
        return False
    with _lock:
        if _started:
            return True
        _started = True
    threading.Thread(target=_watcher, daemon=True,
                     name="wivexx-media-watcher").start()
    if logger:
        logger.info("media import: watching for new storage devices (dest=%s)",
                    getattr(cfg, "MEDIA_IMPORT_DEST", "") or _default_dest())
    return True
