"""Config dataclass with load-time validation. Fail fast."""
from __future__ import annotations

import os
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

_VALID_WEEKDAYS: tuple[str, ...] = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")

_SECRET_ENV_VARS: tuple[str, ...] = ("ATM_DISCORD_URL", "ATM_TG_TOKEN", "ATM_TG_CHAT")

_env_log_emitted: bool = False


def _find_env_file(start: Path | None = None) -> Path | None:
    """Walk up from `start` (default cwd) to find `.env` or a project-root sentinel.

    Stops at `.env` if found, otherwise at the first dir containing `pyproject.toml`
    (and returns the `.env` under it iff it exists). Returns None when nothing hits.
    """
    cur = (start or Path.cwd()).resolve()
    for parent in (cur, *cur.parents):
        env = parent / ".env"
        if env.is_file():
            return env
        if (parent / "pyproject.toml").is_file():
            return None
    return None


def _load_env_file(path: Path | None) -> tuple[int, int]:
    """Parse a simple KEY=value .env file into os.environ. Shell env wins.

    Returns (loaded, overridden_by_shell). No-op if path is None/missing.
    Raises ValueError on malformed lines (missing `=`, not a blank/comment).
    """
    if path is None or not path.is_file():
        return (0, 0)
    loaded = 0
    overridden = 0
    text = path.read_text(encoding="utf-8")
    for lineno, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            raise ValueError(
                f"{path}:{lineno}: malformed (expected KEY=value)"
            )
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
            value = value[1:-1]
        if key in os.environ:
            overridden += 1
            continue
        os.environ[key] = value
        loaded += 1
    return (loaded, overridden)


def _ensure_env_loaded() -> None:
    """Load .env into os.environ if found; shell values always win.

    Called on every Config.load/load_current because monkeypatched tests may
    erase env vars between runs. The shell-wins rule keeps this idempotent:
    a second call is a no-op for any key already set. The startup log line
    is emitted only the first time.
    """
    global _env_log_emitted
    path = _find_env_file()
    if path is None:
        return
    loaded, overridden = _load_env_file(path)
    if not _env_log_emitted and (loaded or overridden):
        _env_log_emitted = True
        print(
            f"[atm.config] .env: loaded {loaded} vars ({overridden} overridden by shell)",
            file=sys.stderr,
        )


def _require_env(name: str) -> str:
    val = os.environ.get(name)
    if not val:
        raise ValueError(
            f"{name} not set — copy .env.example to .env at project root "
            f"and fill in the value (or export {name} in your shell)"
        )
    return val

DotColor = Literal[
    "turquoise", "yellow",
    "dark_green", "dark_red",
    "light_green", "light_red",
    "gray", "background",
]

VALID_COLORS: tuple[str, ...] = (
    "turquoise", "yellow", "dark_green", "dark_red",
    "light_green", "light_red", "gray", "background",
)


@dataclass(frozen=True)
class ROI:
    x: int
    y: int
    w: int
    h: int

    def __post_init__(self) -> None:
        if self.w <= 0 or self.h <= 0:
            raise ValueError(f"ROI w/h must be positive: {self}")
        if self.x < 0 or self.y < 0:
            raise ValueError(f"ROI x/y must be non-negative: {self}")


@dataclass(frozen=True)
class ColorSpec:
    rgb: tuple[int, int, int]
    tolerance: float

    def __post_init__(self) -> None:
        if len(self.rgb) != 3 or any(not (0 <= c <= 255) for c in self.rgb):
            raise ValueError(f"rgb must be 3 ints 0-255: {self.rgb}")
        if self.tolerance <= 0:
            raise ValueError(f"tolerance must be positive: {self.tolerance}")


@dataclass(frozen=True)
class YAxisCalib:
    """Two reference points (pixel_y, price) for linear interp."""
    p1_y: int
    p1_price: float
    p2_y: int
    p2_price: float

    def __post_init__(self) -> None:
        if self.p1_y == self.p2_y:
            raise ValueError("y-axis calibration points must differ in y")


@dataclass(frozen=True)
class CanaryRegion:
    roi: ROI
    baseline_phash: str
    drift_threshold: int = 8


@dataclass(frozen=True)
class DiscordCfg:
    webhook_url: str

    def __post_init__(self) -> None:
        if not self.webhook_url.startswith("http"):
            raise ValueError("discord webhook_url required")
        if "REPLACE_ME" in self.webhook_url:
            raise ValueError(
                "discord webhook_url is still the placeholder — edit .env"
            )


@dataclass(frozen=True)
class TelegramCfg:
    bot_token: str
    chat_id: str
    allowed_chat_ids: tuple[str, ...] = ()
    poll_timeout_s: int = 30
    auto_poll_interval_s: int = 180

    def __post_init__(self) -> None:
        if not self.bot_token or not self.chat_id:
            raise ValueError("telegram bot_token + chat_id required")
        if self.bot_token == "REPLACE_ME" or "REPLACE_ME" in self.bot_token:
            raise ValueError(
                "telegram bot_token is still the placeholder — edit .env"
            )
        cid = self.chat_id.lstrip("-")
        if not cid.isdigit():
            raise ValueError(
                f"telegram chat_id must be numeric (optionally with leading '-'), got {self.chat_id!r}"
            )


@dataclass(frozen=True)
class AlertsCfg:
    """Per-kind screenshot-attach toggles. Default all True — zero behavior change."""
    late_start: bool = True
    catchup: bool = True
    arm: bool = True
    prime: bool = True
    trigger: bool = True


@dataclass
class OperatingHoursCfg:
    """Session window: only run detection on allowed weekdays within HH:MM range.

    Timezone is the source of truth for the exchange (default America/New_York
    for NYSE). Start/stop are compared against the clock in that timezone.
    Weekday check uses datetime.weekday() + a fixed MON..SUN list to stay
    locale-independent (strftime('%a') returns localized names).

    The ZoneInfo is cached at config load time so the detection loop doesn't
    pay per-tick lookup cost.

    NOTE: this dataclass is mutable (non-frozen) so Config._from_dict can stash
    the resolved ZoneInfo onto `_tz_cache` after validation. Treat fields as
    read-only at runtime.
    """
    enabled: bool = False
    timezone: str = "America/New_York"
    weekdays: tuple[str, ...] = ("MON", "TUE", "WED", "THU", "FRI")
    start_hhmm: str = "09:30"
    stop_hhmm: str = "16:00"
    # Populated by Config._from_dict; None for disabled or failed-load cases.
    _tz_cache: ZoneInfo | None = None


@dataclass(frozen=True)
class AlertBehaviorCfg:
    """Alert behavior knobs (not screenshot toggles).

    `fire_on_phase_skip`: backstop alert when FSM observes ARMED→light_{green,red}
    directly (skipping the dark prime phase — often means dark color was
    mis-classified as gray). Default True: missing a fire is worse than a noisy
    phase-skip alert. Disable via `[options.alerts] fire_on_phase_skip = false`.
    """
    fire_on_phase_skip: bool = True


@dataclass(frozen=True)
class Config:
    window_title: str
    dot_roi: ROI
    chart_roi: ROI
    colors: dict[str, ColorSpec]
    y_axis: YAxisCalib
    canary: CanaryRegion
    discord: DiscordCfg
    telegram: TelegramCfg
    chart_window_region: ROI | None = None  # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop
    debounce_depth: int = 1
    loop_interval_s: float = 5.0
    heartbeat_min: int = 30
    lockout_s: int = 240
    low_conf_threshold: float = 0.2
    low_conf_run: int = 3
    phaseb_timeout_s: int = 600
    dead_letter_path: str = "logs/dead_letter.jsonl"
    attach_screenshots: AlertsCfg = field(default_factory=AlertsCfg)
    alerts: AlertBehaviorCfg = field(default_factory=AlertBehaviorCfg)
    operating_hours: OperatingHoursCfg = field(default_factory=OperatingHoursCfg)
    config_version: str = "unknown"

    def __post_init__(self) -> None:
        required = {"turquoise", "yellow", "dark_green", "dark_red",
                    "light_green", "light_red", "gray"}
        missing = required - set(self.colors.keys())
        if missing:
            raise ValueError(f"config.colors missing: {missing}")
        if self.debounce_depth < 1:
            raise ValueError("debounce_depth >= 1")
        if self.loop_interval_s <= 0:
            raise ValueError("loop_interval_s > 0")

    @classmethod
    def load(cls, path: str | Path) -> "Config":
        _ensure_env_loaded()
        p = Path(path)
        data = tomllib.loads(p.read_text(encoding="utf-8"))
        return cls._from_dict(data, version=p.stem)

    @classmethod
    def load_current(cls, configs_dir: str | Path) -> "Config":
        """Resolve configs/current.txt → active config file."""
        _ensure_env_loaded()
        d = Path(configs_dir)
        marker = d / "current.txt"
        if not marker.exists():
            raise FileNotFoundError(f"marker not found: {marker}")
        name = marker.read_text(encoding="utf-8").strip()
        return cls.load(d / name)

    @classmethod
    def _from_dict(cls, data: dict, version: str = "unknown") -> "Config":
        roi = ROI(**data["dot_roi"])
        chart = ROI(**data["chart_roi"])
        colors = {k: ColorSpec(rgb=tuple(v["rgb"]), tolerance=float(v["tolerance"]))
                  for k, v in data["colors"].items()}
        y = YAxisCalib(**data["y_axis"])
        canary = CanaryRegion(
            roi=ROI(**data["canary"]["roi"]),
            baseline_phash=data["canary"]["baseline_phash"],
            drift_threshold=int(data["canary"].get("drift_threshold", 8)),
        )
        discord = DiscordCfg(webhook_url=_require_env("ATM_DISCORD_URL"))
        tg = data.get("telegram", {}) or {}
        tg_chat = _require_env("ATM_TG_CHAT")
        _allowed = [str(c) for c in tg.get("allowed_chat_ids", [])] or [tg_chat]
        telegram = TelegramCfg(
            bot_token=_require_env("ATM_TG_TOKEN"),
            chat_id=tg_chat,
            allowed_chat_ids=tuple(_allowed),
            poll_timeout_s=int(tg.get("poll_timeout_s", 30)),
            auto_poll_interval_s=int(tg.get("auto_poll_interval_s", 180)),
        )
        opts = data.get("options", {})
        region = None
        if "chart_window_region" in data:
            region = ROI(**data["chart_window_region"])
        scr = opts.get("attach_screenshots")
        if isinstance(scr, bool):
            attach = AlertsCfg(
                late_start=scr, catchup=scr, arm=scr, prime=scr, trigger=scr,
            )
        elif isinstance(scr, dict):
            fields_set = set(AlertsCfg.__dataclass_fields__)
            attach = AlertsCfg(
                **{k: bool(v) for k, v in scr.items() if k in fields_set}
            )
        else:
            attach = AlertsCfg()

        alerts_dict = opts.get("alerts", {}) or {}
        alert_behavior = AlertBehaviorCfg(
            fire_on_phase_skip=bool(alerts_dict.get("fire_on_phase_skip", True)),
        )

        oh_dict = opts.get("operating_hours", {}) or {}
        oh_weekdays = tuple(
            str(w).upper() for w in oh_dict.get("weekdays", ("MON", "TUE", "WED", "THU", "FRI"))
        )
        for wd in oh_weekdays:
            if wd not in _VALID_WEEKDAYS:
                raise ValueError(
                    f"operating_hours.weekdays contains invalid day {wd!r}; "
                    f"expected any of {_VALID_WEEKDAYS}"
                )
        oh = OperatingHoursCfg(
            enabled=bool(oh_dict.get("enabled", False)),
            timezone=str(oh_dict.get("timezone", "America/New_York")),
            weekdays=oh_weekdays,
            start_hhmm=str(oh_dict.get("start_hhmm", "09:30")),
            stop_hhmm=str(oh_dict.get("stop_hhmm", "16:00")),
        )
        if oh.enabled:
            try:
                oh._tz_cache = ZoneInfo(oh.timezone)
            except ZoneInfoNotFoundError as exc:
                raise ValueError(
                    f"operating_hours.timezone {oh.timezone!r} invalid: {exc}"
                ) from exc
        return cls(
            window_title=data["window_title"],
            dot_roi=roi,
            chart_roi=chart,
            colors=colors,
            y_axis=y,
            canary=canary,
            discord=discord,
            telegram=telegram,
            chart_window_region=region,
            debounce_depth=int(opts.get("debounce_depth", 1)),
            loop_interval_s=float(opts.get("loop_interval_s", 5.0)),
            heartbeat_min=int(opts.get("heartbeat_min", 30)),
            lockout_s=int(opts.get("lockout_s", 240)),
            low_conf_threshold=float(opts.get("low_conf_threshold", 0.2)),
            low_conf_run=int(opts.get("low_conf_run", 3)),
            phaseb_timeout_s=int(opts.get("phaseb_timeout_s", 600)),
            dead_letter_path=opts.get("dead_letter_path", "logs/dead_letter.jsonl"),
            attach_screenshots=attach,
            alerts=alert_behavior,
            operating_hours=oh,
            config_version=version,
        )
