#!/usr/bin/env python3
"""Hybrid XAUUSD 2026 candidate scanner scaffold.

This script is intentionally a candidate finder, not a final backtest engine.
It reads historical OHLCV from hybrid/data/ohlcv_M15.json and, if present,
hybrid/data/ohlcv_M5.json, then writes scanner candidates to
hybrid/candidates/candidates.json.

Final validity requires chronological manual TradingView Bar Replay review.
Confirmed trades must be written separately to the canonical manual replay
journal/dashboard files after review.
"""

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

SCANNER_VERSION = "0.1.0-scaffold"
SYMBOL = "XAUUSD"
ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = ROOT.parent
DEFAULT_M15_PATH = ROOT / "data" / "ohlcv_M15.json"
DEFAULT_M5_PATH = ROOT / "data" / "ohlcv_M5.json"
DEFAULT_OUTPUT_PATH = ROOT / "candidates" / "candidates.json"

STRATEGIES = {
    "fib_50_62": "Aryy HTF Narrative Fib 50/62",
    "sweep_mss_fvg": "ICT Sweep + MSS + FVG",
    "order_block_reclaim": "Order Block Reclaim",
    "liquidity_sweep_reversal": "Liquidity Sweep Reversal",
    "breaker_continuation": "Breaker Continuation",
}

SESSION_WINDOWS_UTC = [
    ("Asia", 0, 7),
    ("London", 7, 10),
    ("NY AM", 12, 17),
    ("London Close", 15, 17),
    ("NY PM", 17, 21),
]


@dataclass(frozen=True)
class Bar:
    timestamp: str
    open: float
    high: float
    low: float
    close: float
    volume: float | None = None

    @property
    def dt(self) -> datetime:
        return parse_timestamp(self.timestamp)


class ScannerInputError(RuntimeError):
    """Raised when scanner input is malformed."""


def now_utc_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def parse_timestamp(value: Any) -> datetime:
    """Parse common OHLCV timestamp forms into UTC datetimes."""
    if isinstance(value, (int, float)):
        # Accept seconds or milliseconds.
        seconds = value / 1000 if value > 10_000_000_000 else value
        return datetime.fromtimestamp(seconds, tz=timezone.utc)

    text = str(value).strip()
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    dt = datetime.fromisoformat(text)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


def normalize_timestamp(value: Any) -> str:
    return parse_timestamp(value).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def load_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as handle:
        return json.load(handle)


def extract_bar_rows(payload: Any) -> list[Any]:
    """Accept common OHLCV JSON shapes without locking data collection yet."""
    if isinstance(payload, list):
        return payload
    if isinstance(payload, dict):
        for key in ("bars", "ohlcv", "data", "candles", "values"):
            rows = payload.get(key)
            if isinstance(rows, list):
                return rows
    raise ScannerInputError("OHLCV JSON must be a list or an object with bars/ohlcv/data/candles/values")


def row_to_bar(row: Any) -> Bar:
    if isinstance(row, dict):
        timestamp = row.get("timestamp", row.get("time", row.get("date")))
        if timestamp is None:
            raise ScannerInputError(f"OHLCV row missing timestamp/time/date: {row!r}")
        return Bar(
            timestamp=normalize_timestamp(timestamp),
            open=float(row["open"]),
            high=float(row["high"]),
            low=float(row["low"]),
            close=float(row["close"]),
            volume=float(row["volume"]) if row.get("volume") is not None else None,
        )

    if isinstance(row, (list, tuple)) and len(row) >= 5:
        # Expected order: [timestamp, open, high, low, close, volume?]
        return Bar(
            timestamp=normalize_timestamp(row[0]),
            open=float(row[1]),
            high=float(row[2]),
            low=float(row[3]),
            close=float(row[4]),
            volume=float(row[5]) if len(row) > 5 and row[5] is not None else None,
        )

    raise ScannerInputError(f"Unsupported OHLCV row shape: {row!r}")


def load_bars(path: Path, required: bool) -> list[Bar]:
    if not path.exists():
        if required:
            raise FileNotFoundError(path)
        return []
    rows = extract_bar_rows(load_json(path))
    bars = [row_to_bar(row) for row in rows]
    return sorted(bars, key=lambda bar: bar.dt)


def classify_session(dt: datetime) -> str:
    hour = dt.astimezone(timezone.utc).hour
    for session, start, end in SESSION_WINDOWS_UTC:
        if start <= hour < end:
            return session
    return "Other"


def slugify_timestamp(ts: str) -> str:
    return ts.replace(":", "-").replace("+00:00", "Z")


def base_candidate(
    *,
    timestamp: str,
    strategy: str,
    direction: str,
    timeframe: str,
    entry_timeframe: str,
    liquidity_swept: str,
    score: float,
    flags: Iterable[str],
    notes: str,
) -> dict[str, Any]:
    dt = parse_timestamp(timestamp)
    strategy_slug = strategy.lower().replace(" + ", "_").replace(" ", "_").replace("/", "_")
    candidate_id = f"candidate_{slugify_timestamp(timestamp)}_{direction.lower()}_{strategy_slug}"
    return {
        "id": candidate_id,
        "date": dt.date().isoformat(),
        "timestamp": timestamp,
        "symbol": SYMBOL,
        "strategy": strategy,
        "direction": direction,
        "session": classify_session(dt),
        "timeframe": timeframe,
        "entry_timeframe": entry_timeframe,
        "narrative": "TBD manual review",
        "htf_context": "TBD manual review",
        "ltf_trigger": "Mechanical candidate; manual TradingView replay required",
        "liquidity_swept": liquidity_swept,
        "entry": None,
        "stop": None,
        "target": None,
        "fib": "TBD",
        "pd": "TBD",
        "candidate_score": round(score, 2),
        "confidence": "Unreviewed",
        "status": "unreviewed",
        "requires_manual_replay": True,
        "scanner_flags": list(flags),
        "notes": notes,
    }


def find_candidate_windows(m15_bars: list[Bar], m5_bars: list[Bar]) -> list[dict[str, Any]]:
    """Return mechanical candidate windows.

    Scaffold heuristics are deliberately conservative and incomplete:
    - mark a possible bullish sweep when a bar takes the previous bar low and closes back above it;
    - mark a possible bearish sweep when a bar takes the previous bar high and closes back below it;
    - add a light displacement flag when the candle body is larger than the recent average body.

    TODO: replace these placeholders with explicit PDL/PDH/PWL/PWH/session sweep logic,
    M15/M5 MSS detection, FVG detection, Fib 50/62 retracement checks, and HTF context
    enrichment. Do not promote any candidate to a confirmed trade here.
    """
    candidates: list[dict[str, Any]] = []
    if len(m15_bars) < 12:
        return candidates

    entry_timeframe = "M5" if m5_bars else "M15"

    for index in range(10, len(m15_bars)):
        bar = m15_bars[index]
        previous = m15_bars[index - 1]
        lookback = m15_bars[max(0, index - 10):index]
        avg_body = sum(abs(b.close - b.open) for b in lookback) / max(len(lookback), 1)
        body = abs(bar.close - bar.open)
        displacement = body > avg_body * 1.5 if avg_body else False

        if bar.low < previous.low and bar.close > previous.low:
            flags = ["prior_bar_low_sweep", "manual_review_required"]
            score = 35.0
            if displacement:
                flags.append("displacement_candidate")
                score += 15.0
            candidates.append(base_candidate(
                timestamp=bar.timestamp,
                strategy=STRATEGIES["liquidity_sweep_reversal"],
                direction="Long",
                timeframe="M15",
                entry_timeframe=entry_timeframe,
                liquidity_swept="prior_bar_low_placeholder",
                score=score,
                flags=flags,
                notes="Placeholder sweep heuristic only; confirm PDL/PWL/session liquidity, MSS, PD array, and Fib 50/62 manually.",
            ))

        if bar.high > previous.high and bar.close < previous.high:
            flags = ["prior_bar_high_sweep", "manual_review_required"]
            score = 35.0
            if displacement:
                flags.append("displacement_candidate")
                score += 15.0
            candidates.append(base_candidate(
                timestamp=bar.timestamp,
                strategy=STRATEGIES["liquidity_sweep_reversal"],
                direction="Short",
                timeframe="M15",
                entry_timeframe=entry_timeframe,
                liquidity_swept="prior_bar_high_placeholder",
                score=score,
                flags=flags,
                notes="Placeholder sweep heuristic only; confirm PDH/PWH/session liquidity, MSS, PD array, and Fib 50/62 manually.",
            ))

    return candidates


def build_output(candidates: list[dict[str, Any]], warnings: list[str], input_files: dict[str, str]) -> dict[str, Any]:
    return {
        "metadata": {
            "symbol": SYMBOL,
            "mode": "hybrid_candidate_scanner",
            "period": "2026",
            "timeframes": [key for key, value in input_files.items() if value],
            "source": "historical_ohlcv_mechanical_scan",
            "isFinalBacktest": False,
            "generated_at": now_utc_iso(),
            "scanner_version": SCANNER_VERSION,
            "input_files": input_files,
            "candidate_count": len(candidates),
            "warnings": warnings,
            "notes": "Mechanical candidates only; final validity requires manual TradingView Bar Replay review.",
        },
        "candidates": candidates,
    }


def write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Scan XAUUSD OHLCV for manual-replay candidate windows.")
    parser.add_argument("--m15", type=Path, default=DEFAULT_M15_PATH, help="Path to M15 OHLCV JSON")
    parser.add_argument("--m5", type=Path, default=DEFAULT_M5_PATH, help="Path to optional M5 OHLCV JSON")
    parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH, help="Output candidates JSON path")
    parser.add_argument(
        "--allow-missing-m15",
        action="store_true",
        help="Write an empty candidate file with a warning if M15 input is missing. Useful for scaffold validation only.",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    warnings: list[str] = []

    try:
        m15_bars = load_bars(args.m15, required=not args.allow_missing_m15)
    except FileNotFoundError:
        warnings.append(f"missing required M15 input: {args.m15}")
        m15_bars = []

    m5_bars = load_bars(args.m5, required=False)
    if not args.m5.exists():
        warnings.append(f"optional M5 input not found: {args.m5}")

    if not m15_bars:
        warnings.append("no M15 bars loaded; no candidates generated")
        candidates: list[dict[str, Any]] = []
    else:
        candidates = find_candidate_windows(m15_bars, m5_bars)

    output = build_output(
        candidates,
        warnings,
        {
            "M15": str(args.m15.relative_to(PROJECT_ROOT)) if args.m15.is_relative_to(PROJECT_ROOT) else str(args.m15),
            "M5": str(args.m5.relative_to(PROJECT_ROOT)) if args.m5.is_relative_to(PROJECT_ROOT) else str(args.m5),
        },
    )
    write_json(args.output, output)
    print(f"wrote {len(candidates)} candidates to {args.output}")
    if warnings:
        print("warnings:")
        for warning in warnings:
            print(f"- {warning}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
