from __future__ import annotations

import hashlib
import hmac
import json
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Mapping

SECRET_KEYS = {"secret", "webhook_secret", "token", "api_key", "apikey", "password"}
VALID_SIDES = {"long", "short"}
VALID_ACTIONS = {"entry", "exit"}


@dataclass(frozen=True)
class TradingViewWebhookConfig:
    runtime_dir: Path
    secret: str
    allowed_strategies: set[str]
    allowed_coins: set[str]
    journal_relative_path: str = "signals/signal_journal.jsonl"
    rejected_journal_relative_path: str = "signals/rejected_signal_journal.jsonl"

    def journal_path(self) -> Path:
        return self.runtime_dir / self.journal_relative_path

    def rejected_journal_path(self) -> Path:
        return self.runtime_dir / self.rejected_journal_relative_path


@dataclass(frozen=True)
class TradingViewSignal:
    source: str
    signal_id: str
    strategy_id: str
    coin: str
    side: str
    action: str
    timeframe: str
    price: str | None
    timestamp: str
    execution_mode: str = "paper_signal"
    live_order_allowed: bool = False
    reason: str | None = None
    received_at: str | None = None

    def to_journal_row(self) -> dict[str, Any]:
        row = asdict(self)
        row["received_at"] = self.received_at or datetime.now(timezone.utc).isoformat()
        return row


class TradingViewSignalError(ValueError):
    def __init__(self, reason: str):
        super().__init__(reason)
        self.reason = reason


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _clean_str(value: Any) -> str:
    return str(value or "").strip()


def normalize_symbol(symbol: str) -> str:
    raw = _clean_str(symbol).upper()
    raw = raw.replace("PERP", "")
    raw = raw.replace(".P", "")
    raw = raw.replace("-PERP", "")
    raw = raw.replace("/", "")
    raw = raw.replace(":", "")
    for suffix in ("USDT", "USDC", "USD"):
        if raw.endswith(suffix):
            raw = raw[: -len(suffix)]
            break
    raw = re.sub(r"[^A-Z0-9]", "", raw)
    return raw


def _validate_timestamp(value: str) -> str:
    ts = _clean_str(value)
    if not ts:
        raise TradingViewSignalError("missing_timestamp")
    if ts.endswith("Z"):
        parse_value = ts[:-1] + "+00:00"
    else:
        parse_value = ts
    try:
        datetime.fromisoformat(parse_value)
    except ValueError as exc:
        raise TradingViewSignalError("invalid_timestamp") from exc
    return ts


def _validate_price(value: Any) -> str | None:
    if value is None or _clean_str(value) == "":
        return None
    try:
        price = Decimal(str(value))
    except InvalidOperation as exc:
        raise TradingViewSignalError("invalid_price") from exc
    if price <= 0:
        raise TradingViewSignalError("invalid_price")
    return str(value)


def _make_signal_id(payload: Mapping[str, Any], coin: str, strategy: str, side: str, action: str, timestamp: str) -> str:
    explicit = _clean_str(payload.get("signal_id") or payload.get("id"))
    if explicit:
        return explicit
    base = f"tradingview|{strategy}|{coin}|{side}|{action}|{timestamp}|{_clean_str(payload.get('timeframe'))}"
    digest = hashlib.sha256(base.encode("utf-8")).hexdigest()[:16]
    return f"tv-{coin.lower()}-{digest}"


def normalize_tradingview_payload(
    payload: Mapping[str, Any],
    *,
    allowed_strategies: set[str],
    allowed_coins: set[str],
) -> TradingViewSignal:
    source = _clean_str(payload.get("source") or "tradingview").lower()
    if source != "tradingview":
        raise TradingViewSignalError("invalid_source")

    strategy = _clean_str(payload.get("strategy") or payload.get("strategy_id"))
    if not strategy:
        raise TradingViewSignalError("missing_strategy")
    if strategy not in allowed_strategies:
        raise TradingViewSignalError("strategy_not_allowed")

    coin = normalize_symbol(_clean_str(payload.get("symbol") or payload.get("coin")))
    if not coin:
        raise TradingViewSignalError("missing_symbol")
    if coin not in allowed_coins:
        raise TradingViewSignalError("coin_not_allowed")

    side = _clean_str(payload.get("side")).lower()
    if side not in VALID_SIDES:
        raise TradingViewSignalError("invalid_side")

    action = _clean_str(payload.get("action") or payload.get("signal")).lower()
    if action not in VALID_ACTIONS:
        raise TradingViewSignalError("invalid_action")

    timeframe = _clean_str(payload.get("timeframe"))
    if not timeframe:
        raise TradingViewSignalError("missing_timeframe")

    timestamp = _validate_timestamp(_clean_str(payload.get("timestamp") or payload.get("time")))
    price = _validate_price(payload.get("price"))
    signal_id = _make_signal_id(payload, coin, strategy, side, action, timestamp)
    reason = _clean_str(payload.get("reason")) or None

    return TradingViewSignal(
        source="tradingview",
        signal_id=signal_id,
        strategy_id=strategy,
        coin=coin,
        side=side,
        action=action,
        timeframe=timeframe,
        price=price,
        timestamp=timestamp,
        execution_mode="paper_signal",
        live_order_allowed=False,
        reason=reason,
        received_at=_now(),
    )


def _sanitize_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
    sanitized: dict[str, Any] = {}
    for key, value in payload.items():
        lowered = str(key).lower()
        if lowered in SECRET_KEYS or "secret" in lowered or "token" in lowered or "password" in lowered:
            continue
        sanitized[str(key)] = value
    return sanitized


def _authorized(payload: Mapping[str, Any], headers: Mapping[str, str], expected_secret: str) -> bool:
    header_secret = headers.get("X-CTB-Webhook-Secret") or headers.get("x-ctb-webhook-secret")
    payload_secret = payload.get("secret") or payload.get("webhook_secret")
    supplied = _clean_str(header_secret or payload_secret)
    return bool(supplied) and hmac.compare_digest(supplied, expected_secret)


def _read_existing_signal_ids(journal_path: Path) -> set[str]:
    if not journal_path.exists():
        return set()
    ids: set[str] = set()
    for line in journal_path.read_text(encoding="utf-8", errors="replace").splitlines():
        if not line.strip():
            continue
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        signal_id = _clean_str(row.get("signal_id"))
        if signal_id:
            ids.add(signal_id)
    return ids


def append_signal_journal(signal: TradingViewSignal, config: TradingViewWebhookConfig, raw_payload: Mapping[str, Any]) -> Path:
    journal = config.journal_path()
    journal.parent.mkdir(parents=True, exist_ok=True)
    row = signal.to_journal_row()
    row["raw_payload_sanitized"] = _sanitize_payload(raw_payload)
    row["mainnet_signed_action"] = False
    row["order_intent_created"] = False
    with journal.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n")
    return journal


def append_rejected_signal_journal(
    *,
    reason: str,
    config: TradingViewWebhookConfig,
    raw_payload: Mapping[str, Any],
) -> Path:
    journal = config.rejected_journal_path()
    journal.parent.mkdir(parents=True, exist_ok=True)
    row = {
        "timestamp": _now(),
        "source": "tradingview",
        "accepted": False,
        "reason": reason,
        "live_order_allowed": False,
        "mainnet_signed_action": False,
        "order_intent_created": False,
        "raw_payload_sanitized": _sanitize_payload(raw_payload),
    }
    with journal.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n")
    return journal


def handle_tradingview_payload(
    payload: Mapping[str, Any],
    *,
    headers: Mapping[str, str],
    config: TradingViewWebhookConfig,
) -> dict[str, Any]:
    base_reject = {"accepted": False, "live_order_allowed": False, "execution_mode": "paper_signal"}
    if not _authorized(payload, headers, config.secret):
        return {**base_reject, "reason": "unauthorized"}

    try:
        signal = normalize_tradingview_payload(
            payload,
            allowed_strategies=config.allowed_strategies,
            allowed_coins=config.allowed_coins,
        )
    except TradingViewSignalError as exc:
        append_rejected_signal_journal(reason=exc.reason, config=config, raw_payload=payload)
        return {**base_reject, "reason": exc.reason}

    if signal.signal_id in _read_existing_signal_ids(config.journal_path()):
        append_rejected_signal_journal(reason="duplicate_signal", config=config, raw_payload=payload)
        return {**base_reject, "reason": "duplicate_signal", "signal_id": signal.signal_id}

    journal = append_signal_journal(signal, config, payload)
    return {
        "accepted": True,
        "reason": "accepted_for_paper_signal_journal",
        "signal_id": signal.signal_id,
        "coin": signal.coin,
        "strategy_id": signal.strategy_id,
        "side": signal.side,
        "action": signal.action,
        "execution_mode": signal.execution_mode,
        "live_order_allowed": False,
        "journal_path": str(journal),
    }
