#!/usr/bin/env python3
# FILE-SIZE-OK: Exness seeder daemon (Issue #270) — Vision download + TickStorage write
"""Exness forex tick cache seeder.

Downloads daily ZIPs from ticks.ex2archive.com for all registry-enabled
Exness symbols, parses CSV into 3-column Parquet files (timestamp_us, bid, ask),
and writes them to the local Parquet tick cache. Mirrors the Binance seeder
(scripts/tick_cache_seeder.py) architecture.

Key differences from Binance seeder:
  - No batch rotation (10 symbols is small enough to process all each run)
  - No REST fallback (Exness has no equivalent REST API)
  - 4 concurrent downloads (not 12 -- smaller volume, zero rate limiting)
  - has_header=True for CSV parsing (Exness CSVs have headers)
  - 3-column schema (timestamp, bid, ask) instead of 7-column aggTrades
  - Uses _atomic_write_parquet() directly (no agg_trade_id validation)

Symbols are read from the unified symbol registry (symbols.toml) filtered
by exchange="exness". No hardcoded symbol lists.

Writes daily Parquet files to:
  ~/.cache/opendeviationbar/ticks/{tick_cache_key}/YYYY-MM-DD.parquet
  (tick_cache_key read from symbols.toml registry — SSoT)

State file written on completion for monit freshness check:
  /var/tmp/opendeviationbar-exness-seeder-state.txt

Usage:
  # One-off run (all Exness symbols, seeds missing days, exits):
  python scripts/exness_tick_cache_seeder.py

  # Daemon mode via systemd timer (every 1 hour):
  systemctl --user start opendeviationbar-exness-seeder.timer
"""

from __future__ import annotations

import concurrent.futures
import contextlib
import ctypes
import gc
import io
import logging
import os
import sys
import time
import zipfile
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING

import polars as pl

if TYPE_CHECKING:
    pass

from dataclasses import dataclass


@dataclass
class SymbolSeedResult:
    """Per-symbol seeding metrics for Telegram reporting."""

    symbol: str
    days_seeded: int = 0
    days_seeded_daily: int = 0   # from daily ZIPs (recent)
    days_seeded_monthly: int = 0 # from monthly ZIPs (historical)
    days_skipped: int = 0        # already cached
    days_missing_data: int = 0   # 404 weekends/holidays
    days_failed: int = 0         # HTTP errors, parse errors
    total_days: int = 0          # total in date range
    months_downloaded: int = 0   # monthly ZIPs fetched
    elapsed_s: float = 0.0
    error: str | None = None

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Exness constants
# ---------------------------------------------------------------------------

_EXNESS_BASE_URL = "https://ticks.ex2archive.com/ticks"


_purge_lib = None  # Cached ctypes handle (avoid CDLL() on every call)


def _force_allocator_purge() -> None:
    """Force mimalloc/glibc to return freed pages to the OS immediately.

    Caches the ctypes library handle on first call — subsequent calls
    are a single FFI function invocation with zero allocation overhead.
    Pattern from repair_direct_parquet.py (battle-tested through 5 OOM cycles).
    """
    global _purge_lib  # noqa: PLW0603
    if _purge_lib is None:
        try:
            _purge_lib = ctypes.CDLL("libmimalloc.so.2")
        except OSError:
            try:
                _purge_lib = ctypes.CDLL("libc.so.6")
            except OSError:
                _purge_lib = False  # macOS or no libc — skip
    if _purge_lib:
        _purge_lib.malloc_trim(0)


def _force_gc() -> None:
    """Force Python GC + allocator purge. Called every 50 completions + between symbols."""
    gc.collect()
    _force_allocator_purge()


# ---------------------------------------------------------------------------
# Environment loading
# ---------------------------------------------------------------------------

def _load_env_file(env_path: Path) -> None:
    """Load KEY=VALUE lines from env file into os.environ (setdefault)."""
    if not env_path.exists():
        return
    with env_path.open() as f:
        for raw in f:
            stripped = raw.strip()
            if stripped and not stripped.startswith("#") and "=" in stripped:
                k, _, v = stripped.partition("=")
                os.environ.setdefault(k.strip(), v.strip())


# ---------------------------------------------------------------------------
# Exness download helpers
# ---------------------------------------------------------------------------

def _exness_daily_url(symbol: str, d: date) -> str:
    """Construct Exness daily ZIP URL.

    URL pattern (recent months only — ~last 6 months):
      {BASE}/{SYMBOL}_Raw_Spread/{YYYY}/{MM}/{DD}/
      Exness_{SYMBOL}_Raw_Spread_{YYYY}_{MM}_{DD}.zip
    """
    raw_spread = f"{symbol}_Raw_Spread"
    return (
        f"{_EXNESS_BASE_URL}/{raw_spread}"
        f"/{d.year:04d}/{d.month:02d}/{d.day:02d}"
        f"/Exness_{raw_spread}_{d.year:04d}_{d.month:02d}_{d.day:02d}.zip"
    )


def _exness_monthly_url(symbol: str, year: int, month: int) -> str:
    """Construct Exness monthly ZIP URL.

    URL pattern (full history back to 2020):
      {BASE}/{SYMBOL}_Raw_Spread/{YYYY}/{MM}/
      Exness_{SYMBOL}_Raw_Spread_{YYYY}_{MM}.zip

    Monthly ZIPs contain all ticks for the entire month in one CSV.
    Oracle-verified: splitting by UTC calendar date produces bit-exact
    identical results to daily ZIPs (verified 2024-10-01, 2024-10-02).
    """
    raw_spread = f"{symbol}_Raw_Spread"
    return (
        f"{_EXNESS_BASE_URL}/{raw_spread}"
        f"/{year:04d}/{month:02d}"
        f"/Exness_{raw_spread}_{year:04d}_{month:02d}.zip"
    )


def _download_exness_day(
    symbol: str,
    d: date,
    timeout_s: int,
) -> pl.DataFrame | None:
    """Download and parse one Exness daily ZIP.

    Returns a 3-column DataFrame (timestamp, bid, ask) sorted by timestamp,
    or None if the day is unavailable (404 = weekend/holiday).

    Parameters
    ----------
    symbol : str
        Exness instrument symbol (e.g., "EURUSD").
    d : date
        The day to download.
    timeout_s : int
        HTTP timeout in seconds.
    """
    import requests

    url = _exness_daily_url(symbol, d)
    try:
        resp = requests.get(url, timeout=timeout_s, allow_redirects=True)
    except requests.Timeout:
        logger.warning("%s %s: Exness download timed out", symbol, d.isoformat())
        return None
    except Exception as exc:
        logger.warning("%s %s: Exness download error: %s", symbol, d.isoformat(), exc)
        return None

    if resp.status_code == 404:
        # Weekend/holiday -- expected, log at DEBUG only (not error)
        logger.debug("%s %s: Exness 404 -- no data (weekend/holiday)", symbol, d.isoformat())
        resp.close()
        return None
    if resp.status_code != 200:
        logger.warning("%s %s: Exness HTTP %d", symbol, d.isoformat(), resp.status_code)
        resp.close()
        return None

    # Extract ZIP content immediately, then release the response body
    zip_bytes = resp.content
    resp.close()
    del resp  # Release HTTP response + connection pool reference

    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            csv_name = zf.namelist()[0]
            with zf.open(csv_name) as csv_f:
                # Exness CSV has header: "Exness","Symbol","Timestamp","Bid","Ask"
                df = pl.read_csv(
                    csv_f,
                    has_header=True,
                    schema_overrides={
                        "Timestamp": pl.Utf8,
                        "Bid": pl.Float64,
                        "Ask": pl.Float64,
                    },
                )
    except (zipfile.BadZipFile, KeyError) as exc:
        logger.warning("%s %s: ZIP parse error: %s", symbol, d.isoformat(), exc)
        return None
    except Exception as exc:
        logger.warning("%s %s: CSV parse error: %s", symbol, d.isoformat(), exc)
        return None
    finally:
        del zip_bytes  # Release ZIP bytes after parsing

    if df.is_empty():
        logger.warning("%s %s: Exness CSV was empty", symbol, d.isoformat())
        return None

    # Strip trailing "Z" from timestamps before parsing
    # Exness timestamps: "2024-01-15 00:00:00.032Z" -- ISO 8601 UTC
    df = df.with_columns(
        pl.col("Timestamp")
        .str.replace("Z", "")
        .str.to_datetime("%Y-%m-%d %H:%M:%S%.3f")
        .dt.epoch(time_unit="us")
        .alias("timestamp")
    )

    # Select canonical 3 columns — timestamp in MICROSECONDS
    return df.select([
        pl.col("timestamp").cast(pl.Int64),
        pl.col("Bid").alias("bid"),
        pl.col("Ask").alias("ask"),
    ]).sort("timestamp")


# ---------------------------------------------------------------------------
# Monthly ZIP download + split into daily DataFrames
# ---------------------------------------------------------------------------

def _download_exness_month(
    symbol: str,
    year: int,
    month: int,
    timeout_s: int,
) -> dict[date, pl.DataFrame]:
    """Download a monthly ZIP and split into per-day DataFrames.

    Returns a dict mapping calendar date -> 3-column DataFrame.
    Empty dict if the month is unavailable (404) or fails.

    Oracle-verified: splitting by UTC calendar date produces bit-exact
    identical output to downloading individual daily ZIPs.
    """
    import requests

    url = _exness_monthly_url(symbol, year, month)
    try:
        resp = requests.get(url, timeout=timeout_s, allow_redirects=True)
    except Exception as exc:
        logger.warning("%s %04d-%02d: monthly download error: %s", symbol, year, month, exc)
        return {}

    if resp.status_code == 404:
        logger.debug("%s %04d-%02d: monthly ZIP not available", symbol, year, month)
        resp.close()
        return {}
    if resp.status_code != 200:
        logger.warning("%s %04d-%02d: monthly HTTP %d", symbol, year, month, resp.status_code)
        resp.close()
        return {}

    zip_bytes = resp.content
    resp.close()
    del resp

    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            csv_name = zf.namelist()[0]
            with zf.open(csv_name) as csv_f:
                df = pl.read_csv(
                    csv_f,
                    has_header=True,
                    schema_overrides={
                        "Timestamp": pl.Utf8,
                        "Bid": pl.Float64,
                        "Ask": pl.Float64,
                    },
                )
    except Exception as exc:
        logger.warning("%s %04d-%02d: monthly ZIP/CSV parse error: %s", symbol, year, month, exc)
        return {}
    finally:
        del zip_bytes

    if df.is_empty():
        return {}

    # Parse timestamps and extract UTC calendar date for splitting
    df = df.with_columns(
        pl.col("Timestamp")
        .str.replace("Z", "")
        .str.to_datetime("%Y-%m-%d %H:%M:%S%.3f")
        .alias("_ts_dt")
    )
    df = df.with_columns(
        pl.col("_ts_dt").dt.epoch(time_unit="us").alias("timestamp"),
        pl.col("_ts_dt").dt.date().alias("_cal_date"),
    )

    # Split by UTC calendar date into individual DataFrames
    result: dict[date, pl.DataFrame] = {}
    for cal_date, group in df.group_by("_cal_date"):
        d = cal_date[0]  # Polars group_by returns tuple
        day_df = group.select([
            pl.col("timestamp").cast(pl.Int64),
            pl.col("Bid").alias("bid"),
            pl.col("Ask").alias("ask"),
        ]).sort("timestamp")
        result[d] = day_df

    total_rows = sum(len(v) for v in result.values())
    logger.info(
        "%s %04d-%02d: monthly ZIP split into %d days (%s rows)",
        symbol, year, month, len(result), f"{total_rows:,}",
    )

    del df
    return result


# ---------------------------------------------------------------------------
# Download worker (returns date alongside result for future tracking)
# ---------------------------------------------------------------------------

def _download_day(
    symbol: str,
    d: date,
    timeout_s: int,
) -> tuple[date, pl.DataFrame | None]:
    """Download one Exness day. Returns (date, df_or_None)."""
    return d, _download_exness_day(symbol, d, timeout_s)


# ---------------------------------------------------------------------------
# Per-symbol seeder
# ---------------------------------------------------------------------------

def _seed_symbol(
    symbol: str,
    cache_key: str,
    start_date: date,
    ticks_dir: Path,
    max_workers: int,
    timeout_s: int,
    state_path: Path,
    run_start_iso: str,
) -> SymbolSeedResult:
    """Seed all missing days for one Exness symbol.

    Parameters
    ----------
    cache_key : str
        tick_cache_key from symbols.toml registry (SSoT for Parquet directory name).

    Returns SymbolSeedResult with detailed metrics.
    """
    from opendeviationbar.storage.parquet_io import _atomic_write_parquet
    from opendeviationbar.storage.parquet_query import (
        _get_parquet_path,
        _get_symbol_dir,
    )

    result = SymbolSeedResult(symbol=symbol)
    sym_t0 = time.monotonic()

    today = datetime.now(UTC).date()
    yesterday = today - timedelta(days=1)  # T-1 cutoff

    if start_date > yesterday:
        logger.debug("%s: start_date %s is in the future -- skip", symbol, start_date)
        result.elapsed_s = time.monotonic() - sym_t0
        return result

    # Generate date range newest-first (idempotent check)
    missing: list[date] = []
    total_in_range = 0
    current = yesterday
    while current >= start_date:
        total_in_range += 1
        parquet_path = _get_parquet_path(ticks_dir, cache_key, current.isoformat())
        if not parquet_path.exists():
            missing.append(current)
        current -= timedelta(days=1)

    result.total_days = total_in_range
    result.days_skipped = total_in_range - len(missing)

    if not missing:
        logger.debug("%s: fully cached up to %s", symbol, yesterday)
        result.elapsed_s = time.monotonic() - sym_t0
        return result

    logger.info("%s: %d days to seed (newest: %s)", symbol, len(missing), missing[0].isoformat())

    # Ensure symbol directory exists
    symbol_dir = _get_symbol_dir(ticks_dir, cache_key)
    symbol_dir.mkdir(parents=True, exist_ok=True)

    # ── Pass 1: Try daily ZIPs (works for recent ~6 months) ──────────
    # Track which days got 404 — they may need monthly fallback
    daily_404_dates: set[date] = set()
    completed_days = 0

    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
        pending: dict[concurrent.futures.Future[tuple[date, pl.DataFrame | None]], date] = {}
        days_iter = iter(missing)

        for _ in range(max_workers):
            d = next(days_iter, None)
            if d is None:
                break
            pending[ex.submit(_download_day, symbol, d, timeout_s)] = d

        while pending:
            done, _ = concurrent.futures.wait(
                pending,
                return_when=concurrent.futures.FIRST_COMPLETED,
            )
            for fut in done:
                pending.pop(fut)
                try:
                    day_date, df = fut.result()

                    if df is not None:
                        parquet_path = _get_parquet_path(
                            ticks_dir, cache_key, day_date.isoformat()
                        )
                        _atomic_write_parquet(df, parquet_path)
                        result.days_seeded += 1
                        result.days_seeded_daily += 1
                        del df
                    else:
                        # 404 — could be weekend OR old date needing monthly
                        daily_404_dates.add(day_date)

                    completed_days += 1
                    if completed_days % 50 == 0:
                        logger.info(
                            "%s: daily pass %d/%d days",
                            symbol, completed_days, len(missing),
                        )
                        # Touch state file so monit sees liveness during long runs
                        with contextlib.suppress(OSError):
                            state_path.write_text(
                                f"running\ntimestamp={datetime.now(UTC).isoformat()}\n"
                                f"current_symbol={symbol}\ndays_done={result.days_seeded}\n"
                            )
                        _force_gc()
                except Exception as exc:
                    logger.warning("%s: download/write error: %s", symbol, exc)
                    result.days_failed += 1

                d = next(days_iter, None)
                if d is not None:
                    pending[ex.submit(_download_day, symbol, d, timeout_s)] = d

    del missing
    _force_gc()

    # ── Pass 2: Monthly fallback for dates that got 404 on daily ─────
    # Group 404 dates by (year, month) — if a month has many 404s,
    # try the monthly ZIP which has full history back to 2020.
    if daily_404_dates:
        from collections import defaultdict

        months_to_try: dict[tuple[int, int], list[date]] = defaultdict(list)
        for d in daily_404_dates:
            months_to_try[(d.year, d.month)].append(d)

        # Only try monthly for months where >5 weekdays got 404
        # (a normal weekend month has ~8 Sat/Sun, so >5 weekday 404s
        #  strongly suggests the daily endpoint doesn't exist)
        monthly_candidates = {
            ym: days for ym, days in months_to_try.items()
            if sum(1 for d in days if d.weekday() < 5) > 5
        }

        if monthly_candidates:
            logger.info(
                "%s: %d months need monthly fallback (daily 404s)",
                symbol, len(monthly_candidates),
            )

        for (year, month), candidate_days in sorted(monthly_candidates.items(), reverse=True):
            day_dfs = _download_exness_month(symbol, year, month, timeout_s)
            if not day_dfs:
                # Monthly ZIP also unavailable — genuine no-data
                result.days_missing_data += len(candidate_days)
                continue

            result.months_downloaded += 1
            for d in candidate_days:
                if d in day_dfs:
                    parquet_path = _get_parquet_path(ticks_dir, cache_key, d.isoformat())
                    if not parquet_path.exists():
                        _atomic_write_parquet(day_dfs[d], parquet_path)
                        result.days_seeded += 1
                        result.days_seeded_monthly += 1
                else:
                    # Day not in monthly CSV = genuine weekend/holiday
                    result.days_missing_data += 1

            del day_dfs
            # Touch state file after each monthly ZIP so monit sees liveness
            with contextlib.suppress(OSError):
                state_path.write_text(
                    f"running\ntimestamp={datetime.now(UTC).isoformat()}\n"
                    f"current_symbol={symbol}\ndays_done={result.days_seeded}\n"
                    f"monthly={year:04d}-{month:02d}\n"
                )
            _force_gc()

        # Count remaining 404 dates (not in monthly_candidates) as no-data
        monthly_covered = set()
        for days in monthly_candidates.values():
            monthly_covered.update(days)
        uncovered_404s = daily_404_dates - monthly_covered
        result.days_missing_data += len(uncovered_404s)

    result.elapsed_s = time.monotonic() - sym_t0

    if result.days_seeded > 0:
        logger.info("Seeded %s: %d day(s) in %.1fs", symbol, result.days_seeded, result.elapsed_s)

    # Write progress state if applicable
    if state_path is not None and result.days_seeded > 0:
        try:
            state_path.write_text(
                f"running\n"
                f"timestamp={run_start_iso}\n"
                f"current_symbol={symbol}\n"
                f"days_done={result.days_seeded}\n"
            )
        except OSError:
            logger.warning("Failed to write progress state file %s", state_path, exc_info=True)

    return result


# ---------------------------------------------------------------------------
# Telegram verbose report
# ---------------------------------------------------------------------------


def _send_verbose_telegram_report(
    *,
    symbol_results: list[SymbolSeedResult],
    symbols_failed: list[str],
    total_seeded: int,
    symbols_processed: int,
    elapsed: float,
    send_telegram,
) -> None:
    """Build and send a richly decorated per-symbol Telegram report."""
    has_failures = bool(symbols_failed)
    now_str = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
    total_monthly = sum(sr.days_seeded_monthly for sr in symbol_results)
    total_daily = sum(sr.days_seeded_daily for sr in symbol_results)
    total_months_dl = sum(sr.months_downloaded for sr in symbol_results)
    total_cached = sum(sr.days_skipped + sr.days_seeded for sr in symbol_results if not sr.error)
    total_available = sum(sr.days_skipped + sr.days_seeded + sr.days_failed for sr in symbol_results if not sr.error)
    total_weekends = sum(sr.days_missing_data for sr in symbol_results if not sr.error)

    # ═══════════════════════════════════════════════════════════
    # Header banner
    # ═══════════════════════════════════════════════════════════
    div = "\u2550" * 30

    if has_failures:
        lines = [
            "",
            "\U0001f6a8\U0001f534\U0001f6a8 <b>EXNESS SEEDER ALERT</b> \U0001f6a8\U0001f534\U0001f6a8",
            div,
            f"\U0001f525 Status: <b>FAILED</b> \u2014 {len(symbols_failed)} error(s)",
            f"\U0001f4c5 {now_str}",
        ]
    else:
        lines = [
            "",
            "\U0001f331\U0001f30d\U0001f4b9 <b>EXNESS FOREX TICK SEEDER</b> \U0001f4b9\U0001f30d\U0001f331",
            div,
            "\u2705 Status: <b>Healthy</b>",
            f"\U0001f4c5 {now_str}",
        ]

    # ═══════════════════════════════════════════════════════════
    # Run summary with download method breakdown
    # ═══════════════════════════════════════════════════════════
    lines.append("")
    lines.append("\U0001f4ca <b>Run Summary</b>")

    # Elapsed time with human-friendly format
    if elapsed >= 60:
        mins = int(elapsed // 60)
        secs = int(elapsed % 60)
        elapsed_str = f"{mins}m {secs}s"
    else:
        elapsed_str = f"{elapsed:.0f}s"

    lines.append(f"  \U0001f4e6 Symbols processed: <b>{symbols_processed}/{len(symbol_results)}</b>")
    lines.append(f"  \U0001f195 New days seeded: <b>{total_seeded:,}</b>")

    if total_seeded > 0:
        lines.append("  \U0001f4e5 Download breakdown:")
        if total_daily > 0:
            lines.append(f"     \U0001f4c6 {total_daily:,} days from recent daily ZIPs")
        if total_monthly > 0:
            lines.append(f"     \U0001f4c5 {total_monthly:,} days from {total_months_dl} monthly archive ZIPs")

    lines.append(f"  \u23f1\ufe0f Elapsed: <b>{elapsed_str}</b>")

    # ═══════════════════════════════════════════════════════════
    # Per-symbol: one clean line per pair, separated by thin rules
    # ═══════════════════════════════════════════════════════════
    lines.append("")
    lines.append("\U0001f4cb <b>Per-Symbol Status</b>")

    for sr in symbol_results:
        lines.append("\u2500" * 30)

        if sr.error:
            lines.append(f"\U0001f4a5 <code>{sr.symbol:8s}</code> <b>ERROR</b>: {sr.error}")
            continue

        # Status icon
        if sr.days_seeded > 0:
            icon = "\U0001f7e2"  # green = new data this run
        elif sr.days_skipped == sr.total_days:
            icon = "\u2705"     # check = fully cached
        elif sr.days_failed > 0:
            icon = "\U0001f7e1"  # yellow = partial issues
        else:
            icon = "\u26aa"     # white = idle

        # Cache completeness — denominator excludes weekends/holidays (no data exists)
        sym_cached = sr.days_skipped + sr.days_seeded
        sym_available = sym_cached + sr.days_failed  # trading days only
        sym_pct = (sym_cached / sym_available * 100) if sym_available > 0 else 100
        filled = int(sym_pct / 10)
        bar = "\u2588" * filled + "\u2591" * (10 - filled)

        # Line 1: symbol name + status + bar
        lines.append(f"{icon} <code>{sr.symbol:8s}</code> [{bar}] {sym_pct:.0f}%")

        # Line 2: what happened (compact single line)
        parts = []
        if sr.days_seeded > 0:
            parts.append(f"\U0001f195 <b>+{sr.days_seeded:,} new</b>")
        if sr.days_skipped > 0:
            parts.append(f"\U0001f4be{sr.days_skipped:,}")
        if sr.days_missing_data > 0:
            parts.append(f"\U0001f4a4{sr.days_missing_data}")
        if sr.days_failed > 0:
            parts.append(f"\u26a0\ufe0f{sr.days_failed}")

        time_part = f" \u23f1{sr.elapsed_s:.0f}s" if sr.elapsed_s >= 1.0 else ""
        detail = " \u2502 ".join(parts) if parts else "up to date"
        lines.append(f"   {detail}{time_part}")
        if sr.days_missing_data > 0:
            lines.append(f"   \U0001f4a4 {sr.days_missing_data:,} weekends/holidays")

    # ═══════════════════════════════════════════════════════════
    # Cache health dashboard
    # ═══════════════════════════════════════════════════════════
    lines.append("")
    lines.append("\u2500" * 30)
    lines.append("\U0001f3e0\U0001f4be <b>Cache Health Dashboard</b>")

    if total_available > 0:
        pct = total_cached / total_available * 100
        filled = int(pct / 5)  # 20 chars wide
        bar = "\u2588" * filled + "\u2591" * (20 - filled)
        lines.append(f"  \U0001f4ca Overall: [{bar}] <b>{pct:.1f}%</b>")
        lines.append(f"  \U0001f4c1 {total_cached:,} / {total_available:,} trading days cached")
        if total_weekends > 0:
            lines.append(f"  \U0001f4a4 {total_weekends:,} weekends/holidays (no data)")

    # Memory + system
    try:
        import psutil
        proc = psutil.Process()
        rss_mb = proc.memory_info().rss / 1048576
        lines.append(f"  \U0001f9e0 Memory: {rss_mb:.0f} MB RSS")
    except (ImportError, Exception):
        pass

    lines.append("  \U0001f310 Source: exness.com/tick-history")
    lines.append("  \u23f0 Timer: every 1 min (systemd)")

    # ═══════════════════════════════════════════════════════════
    # Footer
    # ═══════════════════════════════════════════════════════════
    if has_failures:
        lines.append("")
        lines.append("\u26a1\U0001f6a8 <b>ACTION REQUIRED</b>")
        lines.append("  Check: <code>journalctl --user -u opendeviationbar-exness-seeder -n 50</code>")

    lines.append("")
    lines.append(div)
    lines.append("\U0001f331 <i>opendeviationbar \u2022 Exness Raw Spread \u2022 UTC daily</i>")

    message = "\n".join(lines)

    send_telegram(
        message,
        disable_notification=not has_failures,
    )


# ---------------------------------------------------------------------------
# Telegram lifecycle messaging (fire-and-forget, never blocks seeder)
# ---------------------------------------------------------------------------

def _tg(send_fn, msg: str, *, loud: bool = False) -> None:
    """Fire-and-forget Telegram send. Never raises — seeder must not die on notification failure."""
    try:
        send_fn(msg, disable_notification=not loud)
    except Exception:
        logger.debug("Telegram send failed (non-fatal)", exc_info=True)


# ---------------------------------------------------------------------------
# Process-level single-instance guard (lock file + stale PID cleanup)
# ---------------------------------------------------------------------------

_LOCK_FILE = Path("/tmp/opendeviationbar-exness-seeder.lock")
_STALE_LOCK_THRESHOLD_S = 3600  # 1 hour — if lock is older, previous run is dead


def _is_pid_alive(pid: int) -> bool:
    """Check if a PID is still running (Linux/macOS)."""
    try:
        os.kill(pid, 0)  # signal 0 = check existence, don't kill
        return True
    except OSError:
        return False


def _kill_stale_seeder(pid: int) -> bool:
    """Force-kill a stale seeder process with escalation: SIGTERM → SIGKILL.

    Returns True if the process was successfully terminated.
    """
    import signal

    logger.warning("Killing stale seeder PID %d (SIGTERM)...", pid)
    try:
        os.kill(pid, signal.SIGTERM)
    except OSError:
        return True  # Already dead

    # Wait up to 5s for graceful shutdown
    for _ in range(10):
        time.sleep(0.5)
        if not _is_pid_alive(pid):
            logger.info("Stale seeder PID %d terminated (SIGTERM)", pid)
            return True

    # Escalate to SIGKILL
    logger.warning("Stale seeder PID %d did not respond to SIGTERM — SIGKILL", pid)
    try:
        os.kill(pid, signal.SIGKILL)
    except OSError:
        return True

    # Verify death
    for _ in range(6):
        time.sleep(0.5)
        if not _is_pid_alive(pid):
            logger.info("Stale seeder PID %d killed (SIGKILL)", pid)
            return True

    logger.error("FAILED to kill stale seeder PID %d — giving up", pid)
    return False


def _acquire_lock(send_telegram=None) -> bool:
    """Acquire single-instance lock. Returns True if lock acquired.

    If another seeder is running:
      - If it's alive → skip this run (return False)
      - If it's stale (PID dead or lock too old) → kill it, take lock

    Lock file contains: PID\\ntimestamp
    """
    if _LOCK_FILE.exists():
        try:
            content = _LOCK_FILE.read_text().strip().split("\n")
            old_pid = int(content[0])
            _old_ts = content[1] if len(content) > 1 else ""
        except (ValueError, IndexError):
            # Corrupt lock file — remove and continue
            logger.warning("Corrupt lock file — removing")
            _LOCK_FILE.unlink(missing_ok=True)
        else:
            lock_age_s = time.time() - _LOCK_FILE.stat().st_mtime

            if _is_pid_alive(old_pid):
                if lock_age_s < _STALE_LOCK_THRESHOLD_S:
                    # Genuinely running — skip
                    logger.info(
                        "Another seeder is running (PID %d, age %.0fs) — skipping",
                        old_pid, lock_age_s,
                    )
                    # Silent skip — 1-min timer means frequent no-ops, don't spam Telegram
                    return False
                # Alive but stale (>1h) — something is stuck
                logger.warning(
                    "Stale seeder detected (PID %d, age %.0fs > %ds) — killing",
                    old_pid, lock_age_s, _STALE_LOCK_THRESHOLD_S,
                )
                if send_telegram:
                    _tg(
                        send_telegram,
                        f"\U0001f6a8 <b>Stale Seeder Detected</b>\n"
                        f"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
                        f"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
                        f"\U0001f480 PID {old_pid} stuck for {lock_age_s/60:.0f} min\n"
                        f"\U0001f52a Killing and taking over...",
                        loud=True,
                    )
                killed = _kill_stale_seeder(old_pid)
                if not killed:
                    logger.error("Cannot kill stale seeder — aborting")
                    return False
            else:
                # PID is dead — orphaned lock from crash/OOM
                logger.info("Removing orphaned lock (PID %d is dead)", old_pid)

            _LOCK_FILE.unlink(missing_ok=True)

    # Write our lock
    _LOCK_FILE.write_text(f"{os.getpid()}\n{datetime.now(UTC).isoformat()}\n")
    return True


def _release_lock() -> None:
    """Release the lock file. Called in finally block — must never raise."""
    try:
        if _LOCK_FILE.exists():
            # Only remove if it's OUR lock (PID matches)
            content = _LOCK_FILE.read_text().strip().split("\n")
            if int(content[0]) == os.getpid():
                _LOCK_FILE.unlink(missing_ok=True)
                logger.debug("Lock released")
            else:
                logger.warning("Lock file PID mismatch — not removing (race?)")
    except Exception:
        # Absolute last resort — unlink regardless
        _LOCK_FILE.unlink(missing_ok=True)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    """Run seeder over all enabled Exness symbols. Returns exit code."""
    import setproctitle

    setproctitle.setproctitle(
        "odb-exness-tick-cache-seeder: forex raw-spread parquet-writer"
    )

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
        stream=sys.stdout,
    )

    # Load deploy env (non-secrets: OPENDEVIATIONBAR_EXNESS_SEEDER_* vars)
    deploy_env = Path.home() / "opendeviationbar-py/deploy/opendeviationbar.env"
    _load_env_file(deploy_env)

    from opendeviationbar.config.exness_seeder import ExnessSeederConfig
    from opendeviationbar.notify.telegram import send_telegram
    from opendeviationbar.storage.parquet_io import get_cache_dir
    from opendeviationbar.symbol_registry import (
        get_effective_start_date,
        get_symbol_entries,
    )

    cfg = ExnessSeederConfig()
    ticks_dir = get_cache_dir() / "ticks"
    state_path = Path(cfg.state_file)

    # Touch state file on EVERY invocation so monit sees freshness.
    # Even lock-skipped runs prove the timer is firing.
    with contextlib.suppress(OSError):
        state_path.write_text(
            f"alive\ntimestamp={datetime.now(UTC).isoformat()}\npid={os.getpid()}\n"
        )

    # ── Single-instance guard ──────────────────────────────────
    if not _acquire_lock(send_telegram=send_telegram):
        return 0  # Another seeder is running — skip silently

    try:
        return _run_seeder(cfg, ticks_dir, send_telegram, get_symbol_entries, get_effective_start_date)
    finally:
        _release_lock()


def _run_seeder(cfg, ticks_dir, send_telegram, get_symbol_entries, get_effective_start_date) -> int:
    """Core seeder logic — called inside lock guard."""
    # Get Exness symbols from registry (SSoT: symbols.toml)
    entries = get_symbol_entries()
    exness_symbols = {
        name: entry
        for name, entry in entries.items()
        if entry.exchange == "exness" and entry.enabled
    }

    if not exness_symbols:
        logger.warning("No enabled Exness symbols found in registry")
        return 0

    t0 = time.monotonic()
    total_seeded = 0
    symbols_processed = 0
    symbols_failed: list[str] = []
    symbol_results: list[SymbolSeedResult] = []

    logger.info(
        "Exness seeder start: %d symbols, historical_start=%s, workers=%d",
        len(exness_symbols),
        cfg.historical_start,
        cfg.max_concurrent_downloads,
    )

    # Write initial state file immediately so monit doesn't see "Does not exist"
    run_start_iso = datetime.now(UTC).isoformat()
    state_path = Path(cfg.state_file)
    try:
        state_path.write_text(
            f"running\ntimestamp={run_start_iso}\nsymbols={len(exness_symbols)}\n"
        )
    except OSError:
        logger.warning("Failed to write initial state file %s", state_path, exc_info=True)

    for symbol in sorted(exness_symbols):
        entry = exness_symbols[symbol]
        # tick_cache_key from registry is SSoT for Parquet directory name
        cache_key = entry.tick_cache_key
        if not cache_key:
            logger.error("%s: no tick_cache_key in registry — skipping", symbol)
            symbols_failed.append(symbol)
            continue

        # Use symbol's effective_start when later than config default
        registry_start = get_effective_start_date(symbol)
        config_start = date.fromisoformat(cfg.historical_start)
        if registry_start:
            symbol_start = max(config_start, date.fromisoformat(registry_start))
        else:
            symbol_start = config_start

        sym_idx = sorted(exness_symbols).index(symbol) + 1

        try:
            sr = _seed_symbol(
                symbol,
                cache_key,
                symbol_start,
                ticks_dir,
                cfg.max_concurrent_downloads,
                cfg.download_timeout_s,
                state_path=state_path,
                run_start_iso=run_start_iso,
            )
            symbol_results.append(sr)
            total_seeded += sr.days_seeded
            symbols_processed += 1

            # ── Telegram: per-symbol completion (only if work was done) ──
            if sr.days_seeded > 0:
                method_lines = []
                if sr.days_seeded_daily > 0:
                    method_lines.append(f"     \U0001f4c6 {sr.days_seeded_daily:,} days from recent daily ZIPs")
                if sr.days_seeded_monthly > 0:
                    method_lines.append(
                        f"     \U0001f4c5 {sr.days_seeded_monthly:,} days from {sr.months_downloaded} monthly archive ZIPs"
                    )
                sym_cached = sr.days_skipped + sr.days_seeded
                sym_available = sym_cached + sr.days_failed
                sym_pct = (sym_cached / sym_available * 100) if sym_available > 0 else 100
                filled = int(sym_pct / 10)
                bar = "\u2588" * filled + "\u2591" * (10 - filled)
                method_block = "\n".join(method_lines)
                _tg(
                    send_telegram,
                    f"\U0001f7e2 <code>{symbol}</code> [{sym_idx}/{len(exness_symbols)}] \u2714\ufe0f\n"
                    f"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
                    f"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
                    f"  \U0001f195 <b>+{sr.days_seeded:,} new trading days seeded</b>\n"
                    f"  \U0001f4e5 Download breakdown:\n"
                    f"{method_block}\n"
                    f"  \U0001f4be Cache: [{bar}] {sym_pct:.0f}% complete\n"
                    f"     {sym_cached:,} of {sym_available:,} trading days cached\n"
                    f"  \U0001f4a4 {sr.days_missing_data:,} weekends/holidays (no market data)\n"
                    f"  \u23f1 Completed in {sr.elapsed_s:.0f}s",
                )

        except Exception as exc:
            logger.exception("Exness seeder failed for %s", symbol)
            symbols_failed.append(symbol)
            symbol_results.append(SymbolSeedResult(symbol=symbol, error=str(exc)))

            # ── Telegram: LOUD error for this symbol ──
            _tg(
                send_telegram,
                f"\U0001f6a8\U0001f534 <b>SEEDER ERROR</b> <code>{symbol}</code> "
                f"[{sym_idx}/{len(exness_symbols)}]\n"
                f"\U0001f4a5 {type(exc).__name__}: {exc}",
                loud=True,
            )

        # Release memory between symbols — prevents RSS accumulation
        # across 10 symbols x 1400+ days backfill
        _force_gc()

    elapsed = time.monotonic() - t0
    logger.info(
        "Exness seeder complete: %d days seeded across %d symbols in %.1fs",
        total_seeded,
        symbols_processed,
        elapsed,
    )

    # Write final state file
    try:
        state_path.write_text(
            f"ok\n"
            f"timestamp={datetime.now(UTC).isoformat()}\n"
            f"symbols={symbols_processed}\n"
            f"seeded={total_seeded}\n"
            f"elapsed_s={elapsed:.1f}\n"
            f"failed={len(symbols_failed)}\n"
        )
    except OSError:
        logger.warning("Failed to write final state file %s", state_path, exc_info=True)

    # Telegram reporting — only when there's actual news to report:
    # - New days were seeded (congratulatory summary)
    # - Failures occurred (LOUD alert)
    # Silent no-op runs (everything cached) just write the state file and exit.
    if total_seeded > 0 or symbols_failed:
        try:
            _send_verbose_telegram_report(
                symbol_results=symbol_results,
                symbols_failed=symbols_failed,
                total_seeded=total_seeded,
                symbols_processed=symbols_processed,
                elapsed=elapsed,
                send_telegram=send_telegram,
            )
        except Exception:
            logger.warning("Failed to send Telegram notification", exc_info=True)

    if symbols_failed:
        logger.error(
            "Exness seeder failed for %d symbol(s): %s",
            len(symbols_failed),
            symbols_failed,
        )
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
