#!/usr/bin/env python3
"""Shadow mode validation for Rust ClickHouse writer (Issue #318, Phase 37).

Three subcommands for the 24h staging checkpoint:

  pre   - Capture baseline before enabling shadow mode
  check - Run during the 24h shadow period (repeatable)
  post  - Validate results after 24+ hours

Usage:
    uv run --python 3.13 python scripts/validate_shadow_mode.py pre
    uv run --python 3.13 python scripts/validate_shadow_mode.py check
    uv run --python 3.13 python scripts/validate_shadow_mode.py post

Exit codes:
    0 - PASS (or baseline captured successfully)
    1 - FAIL (mismatches, new gaps, or regressions detected)
    2 - ERROR (connection or runtime failure)
"""

from __future__ import annotations

import argparse
import contextlib
import json
import subprocess
import sys
import time
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import clickhouse_connect
    from detect_gaps import SSHTunnel

# Baseline file paths
PRE_STATHERA = Path("/tmp/pre-shadow-stathera.json")
PRE_COUNTS = Path("/tmp/pre-shadow-counts.json")
POST_STATHERA = Path("/tmp/post-shadow-stathera.json")


def _get_ch_client() -> tuple[clickhouse_connect.driver.Client, SSHTunnel | None]:
    """Get ClickHouse client via detect_gaps connection logic.

    Returns (client, tunnel). Caller must close both.
    """
    sys.path.insert(0, str(Path(__file__).parent))
    from detect_gaps import connect

    return connect()


def _close_client(
    client: clickhouse_connect.driver.Client,
    tunnel: SSHTunnel | None,
) -> None:
    """Close client and tunnel."""
    with contextlib.suppress(OSError):
        client.close()
    if tunnel is not None:
        tunnel.stop()


def _query_bar_counts(client: clickhouse_connect.driver.Client) -> dict[str, int]:
    """Query bar counts per (symbol, threshold) from ClickHouse."""
    result = client.query("""
        SELECT symbol, threshold_decimal_bps, count()
        FROM opendeviationbar_cache.open_deviation_bars
        GROUP BY symbol, threshold_decimal_bps
        ORDER BY symbol, threshold_decimal_bps
    """)
    counts: dict[str, int] = {}
    for row in result.result_rows:
        key = f"{row[0]}@{row[1]}"
        counts[key] = row[2]
    return counts


def _run_detect_gaps_json() -> dict:
    """Run detect_gaps.py --json and return parsed output."""
    script = Path(__file__).parent / "detect_gaps.py"
    result = subprocess.run(
        [sys.executable, str(script), "--json"],
        capture_output=True,
        text=True,
        timeout=300,
        check=False,  # exit code 1 = issues found (not an error)
    )
    if result.returncode == 2:
        print(f"ERROR: detect_gaps.py failed: {result.stderr}", file=sys.stderr)
        sys.exit(2)
    # Parse JSON from stdout (stderr has progress messages)
    try:
        return json.loads(result.stdout)
    except json.JSONDecodeError:
        print("ERROR: Failed to parse detect_gaps.py output", file=sys.stderr)
        print(f"stdout: {result.stdout[:500]}", file=sys.stderr)
        print(f"stderr: {result.stderr[:500]}", file=sys.stderr)
        sys.exit(2)


def _query_sidecar_health(port: int = 8081) -> dict:
    """Query sidecar /health endpoint for Rust CH writer metrics."""
    url = f"http://localhost:{port}/health"
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.loads(resp.read().decode())
    except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
        print(f"WARNING: Could not reach sidecar health endpoint: {e}", file=sys.stderr)
        return {}


def _count_shadow_mismatches() -> int:
    """Count SHADOW MISMATCH warnings in sidecar journal (last 24h).

    Returns -1 if journalctl is unavailable.
    """
    try:
        result = subprocess.run(
            [
                "journalctl",
                "--user-unit=opendeviationbar-sidecar.service",
                "--since", "24 hours ago",
                "--no-pager",
                "-o", "cat",
            ],
            capture_output=True,
            text=True,
            timeout=30,
            check=False,  # non-zero = journalctl unavailable, handled below
        )
        if result.returncode != 0:
            print(
                "WARNING: journalctl not available, skipping mismatch scan",
                file=sys.stderr,
            )
            return -1
        count = 0
        for line in result.stdout.splitlines():
            if "SHADOW MISMATCH" in line.upper():
                count += 1
        return count
    except (subprocess.TimeoutExpired, FileNotFoundError):
        print(
            "WARNING: journalctl not available, skipping mismatch scan",
            file=sys.stderr,
        )
        return -1


# ============================================================================
# Subcommands
# ============================================================================


def cmd_pre(_args: argparse.Namespace) -> int:
    """Capture baseline before enabling shadow mode."""
    print("=" * 70)
    print("  SHADOW MODE PRE-FLIGHT: Capturing baseline")
    print("=" * 70)
    print()

    # 1. Run Stathera audit
    print("[1/2] Running Stathera audit (detect_gaps.py --json)...")
    stathera = _run_detect_gaps_json()
    PRE_STATHERA.write_text(json.dumps(stathera, indent=2))
    gap_count = stathera.get("stathera_gap_count", 0)
    overlap_count = stathera.get("stathera_overlap_count", 0)
    print(f"      Saved to {PRE_STATHERA}")
    print(f"      Baseline: {gap_count} gaps, {overlap_count} overlaps")
    print()

    # 2. Query bar counts
    print("[2/2] Querying bar counts from ClickHouse...")
    client, tunnel = _get_ch_client()
    try:
        counts = _query_bar_counts(client)
        PRE_COUNTS.write_text(json.dumps(counts, indent=2))
        total = sum(counts.values())
        print(f"      Saved to {PRE_COUNTS}")
        print(f"      {len(counts)} (symbol, threshold) pairs, {total:,} total bars")
    finally:
        _close_client(client, tunnel)

    # Timestamp
    baseline_meta = {
        "captured_at": datetime.now(UTC).isoformat(),
        "epoch": int(time.time()),
    }
    Path("/tmp/pre-shadow-meta.json").write_text(json.dumps(baseline_meta, indent=2))

    print()
    print("=" * 70)
    print("  BASELINE CAPTURED")
    print()
    print("  Next steps:")
    print("  1. Set OPENDEVIATIONBAR_USE_RUST_CH_WRITER=shadow")
    print("     in deploy/sidecar.env")
    print("  2. Deploy: mise run deploy:bigblack")
    print("  3. Wait 24+ hours, monitoring with:")
    print("     uv run --python 3.13 python scripts/validate_shadow_mode.py check")
    print("=" * 70)
    return 0


def cmd_check(_args: argparse.Namespace) -> int:
    """Check shadow mode health during the 24h staging period."""
    print("=" * 70)
    print("  SHADOW MODE CHECK")
    print(f"  {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}")
    print("=" * 70)
    print()

    # Elapsed time since baseline
    meta_path = Path("/tmp/pre-shadow-meta.json")
    if meta_path.exists():
        meta = json.loads(meta_path.read_text())
        elapsed_s = int(time.time()) - meta.get("epoch", 0)
        elapsed_h = elapsed_s / 3600
        print(f"  Elapsed since baseline: {elapsed_h:.1f} hours ({elapsed_s:,} seconds)")
        print()

    issues: list[str] = []

    # 1. Shadow mismatches in journal
    print("[1/3] Scanning journal for SHADOW MISMATCH warnings...")
    mismatch_count = _count_shadow_mismatches()
    if mismatch_count < 0:
        print("      (journalctl unavailable -- check manually on bigblack)")
    elif mismatch_count == 0:
        print("      0 mismatches (GOOD)")
    else:
        print(f"      {mismatch_count} MISMATCHES FOUND (BAD)")
        issues.append(f"{mismatch_count} shadow mismatches in journal")
    print()

    # 2. Sidecar health: Rust CH writer metrics
    print("[2/3] Querying sidecar /health for Rust CH writer metrics...")
    health = _query_sidecar_health()
    if health:
        bars_flushed = health.get("ch_writer_bars_flushed", "N/A")
        dead_letters = health.get("ch_writer_dead_letter_count", "N/A")
        print(f"      ch_writer_bars_flushed:      {bars_flushed}")
        print(f"      ch_writer_dead_letter_count: {dead_letters}")
        if isinstance(dead_letters, int) and dead_letters > 0:
            issues.append(f"ch_writer_dead_letter_count = {dead_letters}")
        if isinstance(bars_flushed, int) and bars_flushed == 0:
            issues.append("ch_writer_bars_flushed = 0 (Rust writer may not be active)")
    else:
        print("      (sidecar health endpoint unreachable)")
    print()

    # 3. Quick Stathera check
    print("[3/3] Running quick Stathera audit...")
    stathera = _run_detect_gaps_json()
    gap_count = stathera.get("stathera_gap_count", 0)
    overlap_count = stathera.get("stathera_overlap_count", 0)
    print(f"      Current: {gap_count} gaps, {overlap_count} overlaps")

    # Compare with baseline if available
    if PRE_STATHERA.exists():
        baseline = json.loads(PRE_STATHERA.read_text())
        base_gaps = baseline.get("stathera_gap_count", 0)
        base_overlaps = baseline.get("stathera_overlap_count", 0)
        new_gaps = gap_count - base_gaps
        new_overlaps = overlap_count - base_overlaps
        print(f"      vs baseline: {new_gaps:+d} gaps, {new_overlaps:+d} overlaps")
        if new_gaps > 0:
            issues.append(f"{new_gaps} new gaps since baseline")
        if new_overlaps > 0:
            issues.append(f"{new_overlaps} new overlaps since baseline")
    print()

    # Summary
    print("=" * 70)
    if issues:
        print("  STATUS: ISSUES DETECTED")
        for issue in issues:
            print(f"    - {issue}")
    else:
        print("  STATUS: HEALTHY")
        print("  Shadow mode running with no issues detected.")
    print("=" * 70)
    return 1 if issues else 0


def cmd_post(_args: argparse.Namespace) -> int:
    """Validate results after 24+ hours of shadow mode."""
    print("=" * 70)
    print("  SHADOW MODE POST-VALIDATION")
    print(f"  {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}")
    print("=" * 70)
    print()

    # Check baseline exists
    if not PRE_STATHERA.exists() or not PRE_COUNTS.exists():
        print("ERROR: Baseline files not found. Run 'pre' first.", file=sys.stderr)
        print(f"  Expected: {PRE_STATHERA}", file=sys.stderr)
        print(f"  Expected: {PRE_COUNTS}", file=sys.stderr)
        return 2

    # Elapsed time
    meta_path = Path("/tmp/pre-shadow-meta.json")
    if meta_path.exists():
        meta = json.loads(meta_path.read_text())
        elapsed_s = int(time.time()) - meta.get("epoch", 0)
        elapsed_h = elapsed_s / 3600
        print(f"  Elapsed since baseline: {elapsed_h:.1f} hours")
        if elapsed_h < 24:
            print(f"  WARNING: Only {elapsed_h:.1f}h elapsed (recommended: 24+)")
        print()

    verdicts: list[tuple[str, bool, str]] = []  # (name, passed, detail)

    # 1. Shadow mismatches
    print("[1/4] Scanning journal for SHADOW MISMATCH warnings...")
    mismatch_count = _count_shadow_mismatches()
    if mismatch_count < 0:
        verdicts.append(("Shadow mismatches", True, "journalctl unavailable (check manually)"))
    elif mismatch_count == 0:
        verdicts.append(("Shadow mismatches", True, "0 mismatches"))
    else:
        verdicts.append(("Shadow mismatches", False, f"{mismatch_count} mismatches found"))
    print()

    # 2. Stathera audit comparison
    print("[2/4] Running Stathera audit and comparing with baseline...")
    stathera = _run_detect_gaps_json()
    POST_STATHERA.write_text(json.dumps(stathera, indent=2))

    baseline_stathera = json.loads(PRE_STATHERA.read_text())
    cur_gaps = stathera.get("stathera_gap_count", 0)
    cur_overlaps = stathera.get("stathera_overlap_count", 0)
    base_gaps = baseline_stathera.get("stathera_gap_count", 0)
    base_overlaps = baseline_stathera.get("stathera_overlap_count", 0)
    new_gaps = cur_gaps - base_gaps
    new_overlaps = cur_overlaps - base_overlaps

    print(f"      Baseline: {base_gaps} gaps, {base_overlaps} overlaps")
    print(f"      Current:  {cur_gaps} gaps, {cur_overlaps} overlaps")
    print(f"      Delta:    {new_gaps:+d} gaps, {new_overlaps:+d} overlaps")

    if new_gaps <= 0 and new_overlaps <= 0:
        verdicts.append(("Stathera integrity", True, f"{new_gaps:+d} gaps, {new_overlaps:+d} overlaps"))
    else:
        detail_parts = []
        if new_gaps > 0:
            detail_parts.append(f"{new_gaps} new gaps")
        if new_overlaps > 0:
            detail_parts.append(f"{new_overlaps} new overlaps")
        verdicts.append(("Stathera integrity", False, ", ".join(detail_parts)))
    print()

    # 3. Bar count comparison
    print("[3/4] Comparing bar counts with baseline...")
    client, tunnel = _get_ch_client()
    try:
        current_counts = _query_bar_counts(client)
    finally:
        _close_client(client, tunnel)

    baseline_counts = json.loads(PRE_COUNTS.read_text())
    decreased_pairs: list[str] = []
    total_increase = 0
    for key, cur in current_counts.items():
        base = baseline_counts.get(key, 0)
        delta = cur - base
        total_increase += max(delta, 0)
        if delta < 0:
            decreased_pairs.append(f"{key}: {base} -> {cur} ({delta})")

    print(f"      Total bar increase: {total_increase:,}")
    if decreased_pairs:
        print(f"      WARNING: {len(decreased_pairs)} pairs decreased:")
        for d in decreased_pairs[:5]:
            print(f"        {d}")
        verdicts.append(("Bar counts", False, f"{len(decreased_pairs)} pairs decreased"))
    else:
        verdicts.append(("Bar counts", True, f"+{total_increase:,} bars across all pairs"))
    print()

    # 4. Rust writer health
    print("[4/4] Checking Rust CH writer metrics...")
    health = _query_sidecar_health()
    if health:
        bars_flushed = health.get("ch_writer_bars_flushed", 0)
        dead_letters = health.get("ch_writer_dead_letter_count", 0)
        print(f"      ch_writer_bars_flushed:      {bars_flushed}")
        print(f"      ch_writer_dead_letter_count: {dead_letters}")

        if isinstance(bars_flushed, int) and bars_flushed > 0:
            verdicts.append(("Rust writer active", True, f"{bars_flushed:,} bars flushed"))
        else:
            verdicts.append(("Rust writer active", False, "bars_flushed = 0"))

        if isinstance(dead_letters, int) and dead_letters == 0:
            verdicts.append(("Rust dead letters", True, "0 dead letters"))
        else:
            verdicts.append(("Rust dead letters", False, f"{dead_letters} dead letters"))
    else:
        verdicts.append(("Rust writer active", True, "sidecar unreachable (check manually)"))
        verdicts.append(("Rust dead letters", True, "sidecar unreachable (check manually)"))
    print()

    # Final verdict
    print("=" * 70)
    all_passed = all(v[1] for v in verdicts)
    print(f"  VERDICT: {'PASS' if all_passed else 'FAIL'}")
    print()
    for name, passed, detail in verdicts:
        status = "PASS" if passed else "FAIL"
        print(f"  [{status}] {name}: {detail}")
    print()

    if all_passed:
        print("  All checks passed. Shadow mode validated successfully.")
        print()
        print("  Next steps to enable live mode:")
        print("  1. Set OPENDEVIATIONBAR_USE_RUST_CH_WRITER=live")
        print("     in deploy/sidecar.env")
        print("  2. Deploy: mise run deploy:bigblack")
        print("  3. Monitor heartbeat for 1+ hour")
    else:
        print("  FAILED checks detected. Investigate before enabling live mode.")
        print("  To revert: set OPENDEVIATIONBAR_USE_RUST_CH_WRITER=off")

    print("=" * 70)
    return 0 if all_passed else 1


# ============================================================================
# CLI
# ============================================================================


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Shadow mode validation for Rust ClickHouse writer (Phase 37).",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Subcommands:
  pre    Capture baseline (Stathera audit + bar counts) before shadow mode
  check  Check health during 24h shadow period (run multiple times)
  post   Validate results after 24+ hours and print PASS/FAIL verdict

Workflow:
  1. python scripts/validate_shadow_mode.py pre
  2. Set OPENDEVIATIONBAR_USE_RUST_CH_WRITER=shadow in deploy/sidecar.env
  3. mise run deploy:bigblack
  4. Wait 24h, periodically running: python scripts/validate_shadow_mode.py check
  5. python scripts/validate_shadow_mode.py post
        """,
    )
    subparsers = parser.add_subparsers(dest="command", required=True)
    subparsers.add_parser("pre", help="Capture baseline before shadow mode")
    subparsers.add_parser("check", help="Check health during shadow period")
    subparsers.add_parser("post", help="Validate results after 24+ hours")

    args = parser.parse_args()

    try:
        if args.command == "pre":
            return cmd_pre(args)
        if args.command == "check":
            return cmd_check(args)
        if args.command == "post":
            return cmd_post(args)
        parser.print_help()
        return 2
    except KeyboardInterrupt:
        print("\nInterrupted.", file=sys.stderr)
        return 2
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        import traceback
        traceback.print_exc(file=sys.stderr)
        return 2


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