#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd
import pyarrow.parquet as pq
from lightgbm import LGBMClassifier, LGBMRegressor
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


SNAPSHOT_ROOT = Path("data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical")
NUMERIC_FEATURES = [
    "reaction_day_return",
    "gap_size",
    "close_location",
    "volume_ratio_20d",
    "document_quality_score",
    "parse_confidence_overall",
    "oneoff_penalty",
    "market_cap_proxy",
    "avg_dollar_volume_20d",
    "pre_event_entropy_60d",
    "pre_event_gravitational_pull",
    "pre_event_hurst_60d",
    "pre_event_market_temperature",
    "pre_event_ou_theta_60d",
    "pre_event_volatility_20d",
    "pre_event_rsi_14",
    "pre_event_bb_position",
    "pre_event_obv_slope_20d",
    "macro_vix",
    "macro_hy_spread",
    "macro_t10y2y",
    "prior_event_fwd5d",
    "lm_positive_pct",
    "lm_negative_pct",
    "lm_net_sentiment",
    "earnings_surprise_pct",
    "sue_lag_1_pct",
    "sue_lag_2_pct",
    "sue_lag_3_pct",
    "sue_lag_4_pct",
    "sue_lag_5_pct",
    "sue_lag_6_pct",
    "sue_lag_7_pct",
    "sue_lag_8_pct",
    "sue_lag_9_pct",
    "sue_lag_10_pct",
    "sue_lag_11_pct",
    "sue_lag_12_pct",
    "sue_hist_mean_4q",
    "sue_hist_mean_8q",
    "sue_hist_mean_12q",
    "sue_hist_pos_rate_4q",
    "sue_hist_pos_rate_12q",
    "sue_hist_latest_pct",
    "sue_hist_streak_pos",
    "peer_sector_event_count_365d",
    "peer_sector_surprise_median_365d",
    "peer_sector_surprise_mean_365d",
    "peer_sector_surprise_pos_rate_365d",
    "peer_relative_surprise_pct_365d",
    "peer_sector_sue_hist_mean_4q_median_365d",
    "peer_sector_sue_hist_mean_4q_mean_365d",
    "peer_relative_sue_hist_mean_4q_365d",
    "peer_sector_sue_hist_pos_rate_4q_mean_365d",
]
CAT_FEATURES = ["event_type", "event_direction", "guidance_status", "sector"]
ALL_COLUMNS = NUMERIC_FEATURES + CAT_FEATURES + [
    "reported_eps",
    "estimated_eps",
    "fwd_return_10d",
    "fwd_return_20d",
    "mfe_10d",
    "mae_10d",
]


def _availability_report(columns: set[str]) -> dict[str, dict[str, object]]:
    requested = {
        "v13e_scoring_core": {
            "required": [
                "reaction_day_return",
                "gap_size",
                "close_location",
                "volume_ratio_20d",
                "document_quality_score",
                "parse_confidence_overall",
                "oneoff_penalty",
            ],
            "nice_to_have": [
                "market_cap_proxy",
                "avg_dollar_volume_20d",
            ],
        },
        "historical_12q_sue_vector": {
            "required": [],
            "nice_to_have": [
                "reported_eps",
                "estimated_eps",
                "earnings_surprise_pct",
                "sue_lag_1_pct",
                "sue_lag_2_pct",
                "sue_lag_3_pct",
                "sue_lag_4_pct",
                "sue_lag_5_pct",
                "sue_lag_6_pct",
                "sue_lag_7_pct",
                "sue_lag_8_pct",
                "sue_lag_9_pct",
                "sue_lag_10_pct",
                "sue_lag_11_pct",
                "sue_lag_12_pct",
                "sue_hist_mean_4q",
                "sue_hist_mean_8q",
                "sue_hist_mean_12q",
                "sue_hist_pos_rate_4q",
                "sue_hist_pos_rate_12q",
                "sue_hist_latest_pct",
                "sue_hist_streak_pos",
            ],
        },
        "transcript_sentiment_surprise": {
            "required": [],
            "nice_to_have": ["lm_positive_pct", "lm_negative_pct", "lm_net_sentiment"],
        },
        "three_day_early_reaction": {
            "required": [],
            "nice_to_have": [],
        },
        "insider_form4_persistence": {
            "required": [],
            "nice_to_have": [],
        },
        "peer_relative_surprise": {
            "required": [],
            "nice_to_have": [
                "sector",
                "peer_sector_event_count_365d",
                "peer_sector_surprise_median_365d",
                "peer_sector_surprise_mean_365d",
                "peer_sector_surprise_pos_rate_365d",
                "peer_relative_surprise_pct_365d",
                "peer_sector_sue_hist_mean_4q_median_365d",
                "peer_sector_sue_hist_mean_4q_mean_365d",
                "peer_relative_sue_hist_mean_4q_365d",
                "peer_sector_sue_hist_pos_rate_4q_mean_365d",
            ],
        },
        "options_skew_iv_term_structure": {
            "required": [],
            "nice_to_have": [],
        },
        "technical_macro_context": {
            "required": [
                "pre_event_entropy_60d",
                "pre_event_hurst_60d",
                "pre_event_gravitational_pull",
                "pre_event_market_temperature",
                "pre_event_ou_theta_60d",
                "pre_event_volatility_20d",
                "pre_event_rsi_14",
                "pre_event_bb_position",
                "pre_event_obv_slope_20d",
                "macro_vix",
                "macro_hy_spread",
                "macro_t10y2y",
                "prior_event_fwd5d",
            ],
            "nice_to_have": [],
        },
    }
    report: dict[str, dict[str, object]] = {}
    for name, spec in requested.items():
        present = [c for c in spec["required"] + spec["nice_to_have"] if c in columns]
        missing_required = [c for c in spec["required"] if c not in columns]
        missing_optional = [c for c in spec["nice_to_have"] if c not in columns]
        if spec["required"] and not missing_required:
            status = "available"
        elif present:
            status = "partial"
        else:
            status = "missing"
        report[name] = {
            "status": status,
            "present": present,
            "missing_required": missing_required,
            "missing_optional": missing_optional,
        }
    return report


def _load_split(snapshot_root: Path, split: str) -> pd.DataFrame:
    parquet_path = snapshot_root / f"{split}.parquet"
    schema = pq.read_schema(parquet_path)
    present = [column for column in ALL_COLUMNS if column in schema.names]
    df = pq.read_table(parquet_path, columns=present).to_pandas()
    for column in ALL_COLUMNS:
        if column not in df.columns:
            df[column] = pd.NA
    df["target_reg_10d"] = df["fwd_return_10d"]
    df["target_cls_strong_drift_10d"] = ((df["mfe_10d"] - df["mae_10d"]) > 0.08).astype(int)
    for col in CAT_FEATURES:
        df[col] = df[col].fillna("missing").astype("category")
    for col in NUMERIC_FEATURES:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    return df


def _bucket_summary(pred: pd.Series, target: pd.Series) -> dict[str, float | None]:
    out = pd.DataFrame({"pred": pred, "target": target}).dropna()
    if out.empty:
        return {
            "mean_target": None,
            "top_quintile_mean": None,
            "bottom_quintile_mean": None,
            "spread": None,
            "corr": None,
        }
    out["bucket"] = pd.qcut(out["pred"], 5, labels=False, duplicates="drop")
    top = out[out["bucket"] == out["bucket"].max()]["target"].mean()
    bottom = out[out["bucket"] == out["bucket"].min()]["target"].mean()
    corr = out["pred"].corr(out["target"])
    return {
        "mean_target": round(float(out["target"].mean()), 4),
        "top_quintile_mean": round(float(top), 4),
        "bottom_quintile_mean": round(float(bottom), 4),
        "spread": round(float(top - bottom), 4),
        "corr": None if pd.isna(corr) else round(float(corr), 4),
    }


def _top_decile_summary(pred: pd.Series, target: pd.Series, extra: pd.Series | None = None) -> dict[str, float | int | None]:
    out = pd.DataFrame({"pred": pred, "target": target}).dropna()
    if extra is not None:
        out["extra"] = extra.loc[out.index]
    if out.empty:
        return {"count": 0, "hit_rate": None, "extra_mean": None}
    threshold = out["pred"].quantile(0.9)
    top = out[out["pred"] >= threshold]
    payload: dict[str, float | int | None] = {
        "count": int(len(top)),
        "hit_rate": round(float(top["target"].mean()), 4),
    }
    if "extra" in top:
        payload["extra_mean"] = round(float(top["extra"].mean()), 4)
    return payload


def run_probe(snapshot_root: Path) -> dict[str, object]:
    train = _load_split(snapshot_root, "train")
    valid = _load_split(snapshot_root, "valid")
    test = _load_split(snapshot_root, "test")

    availability = _availability_report(set(train.columns))

    num_pipe = Pipeline(
        [
            ("impute", SimpleImputer(strategy="median")),
            ("scale", StandardScaler()),
        ]
    )
    cat_pipe = Pipeline(
        [
            ("impute", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore")),
        ]
    )
    prep = ColumnTransformer(
        [
            ("num", num_pipe, NUMERIC_FEATURES),
            ("cat", cat_pipe, CAT_FEATURES),
        ]
    )
    logit = Pipeline(
        [
            ("prep", prep),
            ("model", LogisticRegression(max_iter=2000, class_weight="balanced")),
        ]
    )
    logit.fit(train[NUMERIC_FEATURES + CAT_FEATURES], train["target_cls_strong_drift_10d"])

    lgbm_cls = LGBMClassifier(
        n_estimators=400,
        learning_rate=0.03,
        num_leaves=31,
        min_child_samples=50,
        subsample=0.8,
        colsample_bytree=0.8,
        objective="binary",
        random_state=42,
        verbosity=-1,
    )
    lgbm_cls.fit(
        train[NUMERIC_FEATURES + CAT_FEATURES],
        train["target_cls_strong_drift_10d"],
        categorical_feature=CAT_FEATURES,
    )

    lgbm_reg = LGBMRegressor(
        n_estimators=300,
        learning_rate=0.05,
        num_leaves=31,
        min_child_samples=40,
        subsample=0.8,
        colsample_bytree=0.8,
        objective="regression",
        random_state=42,
        verbosity=-1,
    )
    lgbm_reg.fit(
        train[NUMERIC_FEATURES + CAT_FEATURES],
        train["target_reg_10d"],
        categorical_feature=CAT_FEATURES,
    )

    report: dict[str, object] = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "snapshot_root": str(snapshot_root),
        "target_notes": {
            "classification": "strong_drift_10d := (mfe_10d - mae_10d) > 8%",
            "regression": "fwd_return_10d used as proxy for direct return prediction; 14d label is not materialized yet",
        },
        "feature_availability": availability,
        "models": {},
    }

    for split_name, df in [("valid", valid), ("test", test)]:
        X = df[NUMERIC_FEATURES + CAT_FEATURES]
        cls_target = df["target_cls_strong_drift_10d"]
        reg_target = df["target_reg_10d"]

        logit_prob = pd.Series(logit.predict_proba(X)[:, 1], index=df.index)
        lgbm_prob = pd.Series(lgbm_cls.predict_proba(X)[:, 1], index=df.index)
        reg_pred = pd.Series(lgbm_reg.predict(X), index=df.index)

        report["models"].setdefault("logistic_strong_drift_10d", {})[split_name] = {
            "auc": round(float(roc_auc_score(cls_target, logit_prob)), 4),
            "bucket_summary": _bucket_summary(logit_prob, cls_target),
            "top_decile": _top_decile_summary(logit_prob, cls_target, reg_target),
        }
        report["models"].setdefault("lightgbm_strong_drift_10d", {})[split_name] = {
            "auc": round(float(roc_auc_score(cls_target, lgbm_prob)), 4),
            "bucket_summary": _bucket_summary(lgbm_prob, cls_target),
            "top_decile": _top_decile_summary(lgbm_prob, cls_target, reg_target),
        }
        report["models"].setdefault("lightgbm_return_10d_regression", {})[split_name] = {
            "bucket_summary": _bucket_summary(reg_pred, reg_target),
            "top_decile": _top_decile_summary(reg_pred, cls_target, reg_target),
        }

    importances = dict(
        sorted(
            zip(NUMERIC_FEATURES + CAT_FEATURES, lgbm_cls.feature_importances_.tolist(), strict=False),
            key=lambda x: x[1],
            reverse=True,
        )[:12]
    )
    report["models"]["lightgbm_strong_drift_10d"]["top_feature_importances"] = importances
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description="Feasibility probe for full-ML predictor replacement.")
    parser.add_argument(
        "--snapshot-root",
        default=str(SNAPSHOT_ROOT),
        help="Canonical snapshot root containing train/valid/test parquet files.",
    )
    parser.add_argument(
        "--output",
        default="runs/ml_predictor_feasibility_20260401.json",
        help="Path to write JSON report.",
    )
    args = parser.parse_args()

    snapshot_root = Path(args.snapshot_root)
    report = run_probe(snapshot_root)

    output_path = Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(report, indent=2))
    print(output_path)


if __name__ == "__main__":
    main()
