# version: v3
"""
================================================================================
Quantum Terminal Consumer — Cache Directory Watcher
================================================================================
Async task that watches %APPDATA%\\QuantumTerminal\\cache\\ for JSON file changes and:
  1. Loads the changed file into the sync client's in-memory store.
  2. Broadcasts a 'data_sync_complete' WS event so the frontend re-fetches.

Purpose
-------
During the Data Center transition (and for local debugging) the user may drop
files directly into the cache dir. Without this watcher the running server
never re-reads disk after startup, so those drops are invisible until a full
restart. This watcher closes the gap.

It also gives live updates when sync_all() writes new cache files — the usual
periodic_sync broadcast happens too, but this provides a second, finer-grained
trigger (per-file, immediate) and covers the case where sync_all is not the
writer (manual drop, future DC auto-uploader, etc.).

Mechanism
---------
Polls os.stat() mtime on *.json in the cache dir (no new dependency). A file
must have a *stable* mtime for two consecutive ticks before we act on it —
this debounces partial-write races.

Poll interval defaults to 1 second (configurable). Zero compute, Rule C1 safe.

Config (consumer_config.ini)
----------------------------
    [sync]
    cache_watcher_enabled = true        ; set false to disable entirely
    cache_watcher_interval_seconds = 1  ; floor enforced at 0.25s

Integration
-----------
Wired from data_server.py lifespan, same pattern as periodic_sync. Takes the
WS broadcast_event callable. Cancellation honored via asyncio.CancelledError.

v2 — Deletions detected and propagated. When a file vanishes from the cache
     directory, the corresponding entry in the sync client's in-memory store is
     purged and the broadcast payload reports it via a "removed_files" list.
     Frontend treats this like any other data_sync_complete event → refetches
     and gets a 404/null → clears the overlay.

v3 — Filename parsing now delegates to data_sync_client.resolve_cache_filename
     so bare globals ("money_flow.json"), prefixed globals with multi-word
     categories ("GLOBAL_probability_state.json"), and the legacy
     GLOBAL_<lowercase_ticker>_<category>.json misnaming all route to the
     correct in-memory slot. Same resolver used by _load_all_from_cache at
     startup, ensuring watcher and loader stay consistent.

Rule C1 compliance: this module performs ZERO calculations — it only reads
files already on disk and pushes WS events.
================================================================================
"""

import asyncio
import configparser
import json
import logging
import os

# v2: AppData dir name (env-var driven; default "QuantumTerminal" preserves v1 path).
APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal")
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Awaitable, Callable, Optional

log = logging.getLogger("mk.cache_watcher")

DEFAULT_INTERVAL_SECONDS = 1.0
MIN_INTERVAL_SECONDS = 0.25

# Same filename pattern as data_sync_client._load_all_from_cache().
# Greedy first group + underscore-delimited category tail.
_FILE_PATTERN = re.compile(r'^(.+)_([a-z_]+)\.json$', re.IGNORECASE)


def _read_config() -> tuple[bool, float]:
    """Read cache_watcher_enabled + interval from consumer_config.ini.
    Returns (enabled, interval_seconds). Defaults: enabled=True, interval=1.0."""
    project_root = Path(__file__).resolve().parent

    candidates = [
        project_root / "consumer_config.ini",
        project_root.parent / "consumer_config.ini",
    ]

    import sys as _sys
    if getattr(_sys, "frozen", False):
        candidates.append(Path(_sys.executable).parent / "consumer_config.ini")

    appdata = os.environ.get("APPDATA", "")
    if appdata:
        candidates.append(Path(appdata) / APP_DIR / "consumer_config.ini")

    enabled = True
    interval = DEFAULT_INTERVAL_SECONDS

    for p in candidates:
        if not p.exists():
            continue
        try:
            cp = configparser.ConfigParser()
            cp.read(str(p), encoding="utf-8-sig")
            if cp.has_option("sync", "cache_watcher_enabled"):
                enabled = cp.getboolean("sync", "cache_watcher_enabled")
            if cp.has_option("sync", "cache_watcher_interval_seconds"):
                try:
                    interval = max(MIN_INTERVAL_SECONDS,
                                   cp.getfloat("sync", "cache_watcher_interval_seconds"))
                except (ValueError, TypeError):
                    pass
            break
        except Exception as e:
            log.warning(f"Could not parse cache_watcher config from {p.name}: {e}")
            break

    return enabled, interval


def _get_cache_dir() -> Optional[Path]:
    """Resolve %APPDATA%\\QuantumTerminal\\cache via the same helper data_sync_client uses."""
    try:
        from data_sync_client import _get_cache_dir as _dsc_cache_dir
        return _dsc_cache_dir()
    except Exception:
        appdata = os.environ.get("APPDATA", "")
        if not appdata:
            return None
        return Path(appdata) / APP_DIR / "cache"


def create_cache_watcher_task(
    broadcast_event: Callable[[dict], Awaitable[None]],
) -> Optional[Callable]:
    """
    Returns an async coroutine factory that watches the cache dir.
    Returns None if disabled via config, so the caller can skip task creation.

    Usage in data_server.py lifespan:
        from cache_watcher import create_cache_watcher_task
        watcher = create_cache_watcher_task(manager.broadcast_event)
        if watcher:
            tasks.append(asyncio.create_task(watcher()))
    """
    enabled, interval = _read_config()
    if not enabled:
        log.info("Cache watcher disabled via config")
        return None

    cache_dir = _get_cache_dir()
    if cache_dir is None:
        log.warning("Cache watcher: cannot resolve cache dir — disabled")
        return None

    log.info(f"Cache watcher configured: dir={cache_dir}, interval={interval}s")

    async def _task():
        # Track stable mtimes (the "known good" state we've already processed).
        stable_mtimes: dict[str, int] = {}
        # Track the most-recent-seen mtime so we can require two consecutive
        # equal readings before acting (debounce against partial writes).
        last_seen: dict[str, int] = {}

        # Seed stable_mtimes with everything currently on disk so we don't
        # fire a flood of "data_sync_complete" events on startup.
        try:
            if cache_dir.exists():
                for f in cache_dir.glob("*.json"):
                    if f.name.startswith("_"):
                        continue
                    try:
                        stable_mtimes[f.name] = f.stat().st_mtime_ns
                    except OSError:
                        continue
        except Exception as e:
            log.warning(f"Cache watcher seed failed: {e}")

        while True:
            try:
                if not cache_dir.exists():
                    await asyncio.sleep(interval)
                    continue

                changed: list[Path] = []
                current_names: set[str] = set()

                for f in cache_dir.glob("*.json"):
                    if f.name.startswith("_"):
                        continue
                    current_names.add(f.name)
                    try:
                        mt = f.stat().st_mtime_ns
                    except OSError:
                        continue

                    prev_seen = last_seen.get(f.name)
                    prev_stable = stable_mtimes.get(f.name)

                    if mt == prev_stable:
                        # Already processed at this mtime — nothing to do.
                        last_seen[f.name] = mt
                        continue

                    if prev_seen == mt:
                        # Stable for 2 ticks at a new mtime → accept.
                        changed.append(f)
                        stable_mtimes[f.name] = mt
                    last_seen[f.name] = mt

                # Detect deletions: files that were known-stable last tick but
                # are no longer in the directory. Drop bookkeeping AND purge
                # the sync client's in-memory store so /api endpoints stop
                # serving the stale data.
                deleted: list[str] = []
                for gone in list(stable_mtimes.keys()):
                    if gone not in current_names:
                        deleted.append(gone)
                        stable_mtimes.pop(gone, None)
                        last_seen.pop(gone, None)

                if changed or deleted:
                    await _handle_changes(changed, deleted, broadcast_event)

            except asyncio.CancelledError:
                log.info("Cache watcher task cancelled")
                break
            except Exception as e:
                log.error(f"Cache watcher tick error: {e}", exc_info=True)

            try:
                await asyncio.sleep(interval)
            except asyncio.CancelledError:
                break

    return _task


async def _handle_changes(
    changed: list[Path],
    deleted: list[str],
    broadcast_event: Callable[[dict], Awaitable[None]],
) -> None:
    """Reconcile the sync client's in-memory store with disk, then broadcast."""
    try:
        from data_sync_client import get_sync_client
        client = get_sync_client()
    except Exception as e:
        log.warning(f"Cache watcher: sync client unavailable: {e}")
        return

    # v3: use data_sync_client's shared resolver for consistent naming rules.
    try:
        from data_sync_client import resolve_cache_filename
    except Exception:
        resolve_cache_filename = None

    def _resolve(fname: str):
        if not fname.lower().endswith(".json"):
            return None
        stem = fname[:-5]
        if resolve_cache_filename:
            return resolve_cache_filename(stem)
        # Fallback — legacy regex if sync client unavailable for any reason.
        m = _FILE_PATTERN.match(fname)
        return (m.group(1).upper(), m.group(2).lower()) if m else None

    loaded: list[dict] = []
    for path in changed:
        resolved = _resolve(path.name)
        if not resolved:
            continue
        ticker, category = resolved

        try:
            with path.open("r", encoding="utf-8") as fh:
                data = json.load(fh)
        except Exception as e:
            log.warning(f"Cache watcher: failed to read {path.name}: {e}")
            continue

        try:
            client._get_store(category)[ticker] = data
            client._known_categories.add(category)
            loaded.append({"file": path.name, "ticker": ticker, "category": category})
            log.info(f"Cache watcher: reloaded {path.name} → ({ticker}, {category})")
        except Exception as e:
            log.warning(f"Cache watcher: store update failed for {path.name}: {e}")

    removed: list[dict] = []
    for fname in deleted:
        resolved = _resolve(fname)
        if not resolved:
            continue
        ticker, category = resolved
        try:
            store = client._get_store(category)
            if ticker in store:
                store.pop(ticker, None)
                removed.append({"file": fname, "ticker": ticker, "category": category})
                log.info(f"Cache watcher: purged {fname} (deleted from disk)")
        except Exception as e:
            log.warning(f"Cache watcher: store purge failed for {fname}: {e}")

    if not loaded and not removed:
        return

    try:
        await broadcast_event({
            "type": "data_sync_complete",
            "synced": len(loaded),
            "deleted": len(removed),
            "source": "cache_watcher",
            "files": [x["file"] for x in loaded],
            "removed_files": [x["file"] for x in removed],
            "timestamp": datetime.now(timezone.utc).isoformat(),
        })
    except Exception as e:
        log.warning(f"Cache watcher: broadcast failed: {e}")
