from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Sequence

import numpy as np
import torch
import torch.nn.functional as F

from evaluate_auc_v2 import (
    build_model_from_dataset,
    load_checkpoint_state_dict,
    load_json_config,
    load_model_state,
    resolve_dist_mode_for_checkpoint,
    validate_dataset_metadata,
)
from eval_data import load_sequence_eval_dataset
from targets import CHECKUP_IDX, NO_EVENT_IDX, PAD_IDX


FormedBurdenMode = Literal["observed", "model_weighted"]


@dataclass(frozen=True)
class BurdenContext:
    model: torch.nn.Module
    dataset: Any
    cfg: dict[str, Any]
    dist_mode: str
    device: torch.device
    run_path: Path


@dataclass(frozen=True)
class DiseaseBurdenResult:
    disease_id: int
    formed: float
    future: float
    total: float
    formed_mode: str
    horizon: float


@dataclass(frozen=True)
class BurdenIndexResult:
    historical: np.ndarray
    future: np.ndarray
    total: np.ndarray
    disease_ids: np.ndarray
    formed: np.ndarray
    disease_future: np.ndarray
    disease_total: np.ndarray
    formed_mode: str
    horizon: float


def load_burden_context(
    run_path: str | Path,
    *,
    device: str | torch.device | None = None,
) -> BurdenContext:
    run_path = Path(run_path)
    config_path = run_path / "train_config.json"
    model_ckpt_path = run_path / "best_model.pt"
    if not config_path.exists():
        raise FileNotFoundError(f"train_config.json not found in {run_path}")
    if not model_ckpt_path.exists():
        raise FileNotFoundError(f"best_model.pt not found in {run_path}")

    cfg = load_json_config(config_path)
    model_target_mode = str(cfg.get("model_target_mode", "next_token")).lower()
    if model_target_mode == "next_token":
        raise RuntimeError(
            "Burden Index computation requires an all_future checkpoint because "
            "it uses p_d(h, Delta). The provided run is model_target_mode='next_token'."
        )
    if model_target_mode != "all_future":
        raise ValueError(
            "train_config.json model_target_mode must be all_future for burden "
            f"computation, got {model_target_mode!r}."
        )

    device_obj = torch.device(
        device if device is not None else ("cuda" if torch.cuda.is_available() else "cpu")
    )
    if device_obj.type == "cuda" and not torch.cuda.is_available():
        raise RuntimeError(f"Requested device {device_obj}, but CUDA is not available.")

    data_prefix = cfg.get("data_prefix", "ukb")
    labels_file = cfg.get("labels_file", "labels.csv")
    extra_info_types = cfg.get("extra_info_types", None)
    dataset = load_sequence_eval_dataset(
        model_target_mode="all_future",
        data_prefix=data_prefix,
        labels_file=labels_file,
        no_event_interval_years=float(cfg.get("no_event_interval_years", 5.0)),
        include_no_event_in_uts_target=bool(
            cfg.get("include_no_event_in_uts_target", False)
        ),
        min_history_events=int(cfg.get("all_future_min_history_events", 1)),
        min_future_events=int(cfg.get("all_future_min_future_events", 1)),
        extra_info_types=extra_info_types,
    )
    validate_dataset_metadata(dataset, cfg)

    state_dict = load_checkpoint_state_dict(model_ckpt_path, map_location="cpu")
    dist_mode = resolve_dist_mode_for_checkpoint(
        str(cfg.get("dist_mode", "exponential")),
        state_dict,
    )
    if dist_mode not in {"exponential", "weibull", "mixed"}:
        raise ValueError(f"Unsupported dist_mode={dist_mode!r}")

    cfg_model = dict(cfg)
    cfg_model["dist_mode"] = dist_mode
    args = _ConfigNamespace()
    model = build_model_from_dataset(args, cfg_model, dataset)
    load_model_state(model, state_dict)
    model.eval().to(device_obj)

    return BurdenContext(
        model=model,
        dataset=dataset,
        cfg=cfg,
        dist_mode=dist_mode,
        device=device_obj,
        run_path=run_path,
    )


@torch.inference_mode()
def compute_disease_burden(
    *,
    run_path: str | Path,
    disease_id: int,
    event_seq: Sequence[int] | np.ndarray,
    time_seq: Sequence[float] | np.ndarray,
    sex: int,
    other_type: Sequence[int] | np.ndarray,
    other_value: Sequence[float] | np.ndarray,
    other_value_kind: Sequence[int] | np.ndarray,
    other_time: Sequence[float] | np.ndarray,
    t_query: float,
    horizon: float,
    formed_mode: FormedBurdenMode,
    device: str | torch.device | None = None,
    context: BurdenContext | None = None,
) -> DiseaseBurdenResult:
    ctx = context or load_burden_context(run_path, device=device)
    disease_ids = np.asarray([int(disease_id)], dtype=np.int64)
    formed, future_prob = _compute_disease_components(
        ctx=ctx,
        disease_ids=disease_ids,
        event_seq=event_seq,
        time_seq=time_seq,
        sex=sex,
        other_type=other_type,
        other_value=other_value,
        other_value_kind=other_value_kind,
        other_time=other_time,
        t_query=float(t_query),
        horizon=float(horizon),
        formed_mode=formed_mode,
    )
    future = (1.0 - formed) * future_prob
    total = formed + future
    return DiseaseBurdenResult(
        disease_id=int(disease_id),
        formed=float(formed[0]),
        future=float(future[0]),
        total=float(total[0]),
        formed_mode=str(formed_mode),
        horizon=float(horizon),
    )


@torch.inference_mode()
def compute_burden_index(
    *,
    run_path: str | Path,
    burden_matrix: np.ndarray,
    disease_ids: Sequence[int] | np.ndarray,
    event_seq: Sequence[int] | np.ndarray,
    time_seq: Sequence[float] | np.ndarray,
    sex: int,
    other_type: Sequence[int] | np.ndarray,
    other_value: Sequence[float] | np.ndarray,
    other_value_kind: Sequence[int] | np.ndarray,
    other_time: Sequence[float] | np.ndarray,
    t_query: float,
    horizon: float,
    formed_mode: FormedBurdenMode,
    device: str | torch.device | None = None,
    context: BurdenContext | None = None,
) -> BurdenIndexResult:
    ctx = context or load_burden_context(run_path, device=device)
    disease_ids_arr = np.asarray(disease_ids, dtype=np.int64)
    if disease_ids_arr.ndim != 1 or disease_ids_arr.size == 0:
        raise ValueError("disease_ids must be a non-empty 1D sequence.")

    A = np.asarray(burden_matrix, dtype=np.float64)
    if A.ndim != 2:
        raise ValueError(f"burden_matrix must be 2D, got shape {A.shape}")
    if A.shape[1] != disease_ids_arr.size:
        raise ValueError(
            "burden_matrix columns must match disease_ids length, got "
            f"{A.shape[1]} columns vs {disease_ids_arr.size} disease ids."
        )

    formed, future_prob = _compute_disease_components(
        ctx=ctx,
        disease_ids=disease_ids_arr,
        event_seq=event_seq,
        time_seq=time_seq,
        sex=sex,
        other_type=other_type,
        other_value=other_value,
        other_value_kind=other_value_kind,
        other_time=other_time,
        t_query=float(t_query),
        horizon=float(horizon),
        formed_mode=formed_mode,
    )
    disease_future = (1.0 - formed) * future_prob
    disease_total = formed + disease_future

    historical = A @ formed
    future = A @ disease_future
    total = A @ disease_total
    return BurdenIndexResult(
        historical=historical,
        future=future,
        total=total,
        disease_ids=disease_ids_arr.copy(),
        formed=formed,
        disease_future=disease_future,
        disease_total=disease_total,
        formed_mode=str(formed_mode),
        horizon=float(horizon),
    )


class _ConfigNamespace:
    def __getattr__(self, _name: str) -> None:
        return None


def _compute_disease_components(
    *,
    ctx: BurdenContext,
    disease_ids: np.ndarray,
    event_seq: Sequence[int] | np.ndarray,
    time_seq: Sequence[float] | np.ndarray,
    sex: int,
    other_type: Sequence[int] | np.ndarray,
    other_value: Sequence[float] | np.ndarray,
    other_value_kind: Sequence[int] | np.ndarray,
    other_time: Sequence[float] | np.ndarray,
    t_query: float,
    horizon: float,
    formed_mode: FormedBurdenMode,
) -> tuple[np.ndarray, np.ndarray]:
    formed_mode = _validate_formed_mode(formed_mode)
    disease_ids = np.asarray(disease_ids, dtype=np.int64)
    _validate_disease_ids(ctx, disease_ids)
    event_seq_arr, time_seq_arr = _validate_event_inputs(event_seq, time_seq)
    other_type_arr, other_value_arr, other_value_kind_arr, other_time_arr = (
        _validate_other_inputs(other_type, other_value, other_value_kind, other_time)
    )
    if horizon < 0:
        raise ValueError(f"horizon must be non-negative, got {horizon}")

    if formed_mode == "observed":
        formed = _observed_formed_burden(
            disease_ids=disease_ids,
            event_seq=event_seq_arr,
            time_seq=time_seq_arr,
            t_query=t_query,
        )
    else:
        formed = _model_weighted_formed_burden(
            ctx=ctx,
            disease_ids=disease_ids,
            event_seq=event_seq_arr,
            time_seq=time_seq_arr,
            sex=sex,
            other_type=other_type_arr,
            other_value=other_value_arr,
            other_value_kind=other_value_kind_arr,
            other_time=other_time_arr,
            t_query=t_query,
        )

    hidden_query = _query_hidden(
        ctx=ctx,
        event_seq=event_seq_arr,
        time_seq=time_seq_arr,
        sex=sex,
        other_type=other_type_arr,
        other_value=other_value_arr,
        other_value_kind=other_value_kind_arr,
        other_time=other_time_arr,
        query_times=np.asarray([t_query], dtype=np.float32),
    )
    future_prob = _probabilities_from_hidden(
        ctx=ctx,
        hidden=hidden_query,
        disease_ids=disease_ids,
        deltas=np.asarray([horizon], dtype=np.float32),
    )[0]
    return formed.astype(np.float64), future_prob.astype(np.float64)


def _model_weighted_formed_burden(
    *,
    ctx: BurdenContext,
    disease_ids: np.ndarray,
    event_seq: np.ndarray,
    time_seq: np.ndarray,
    sex: int,
    other_type: np.ndarray,
    other_value: np.ndarray,
    other_value_kind: np.ndarray,
    other_time: np.ndarray,
    t_query: float,
) -> np.ndarray:
    grid = _build_readout_grid(
        event_seq=event_seq,
        time_seq=time_seq,
        other_type=other_type,
        other_time=other_time,
        t_query=t_query,
    )
    if grid.size == 0:
        return np.zeros(disease_ids.size, dtype=np.float64)

    end_times = np.concatenate([grid[1:], np.asarray([t_query], dtype=np.float32)])
    deltas = np.maximum(end_times - grid, 0.0).astype(np.float32)
    if np.all(deltas <= 0):
        return np.zeros(disease_ids.size, dtype=np.float64)

    hidden = _query_hidden(
        ctx=ctx,
        event_seq=event_seq,
        time_seq=time_seq,
        sex=sex,
        other_type=other_type,
        other_value=other_value,
        other_value_kind=other_value_kind,
        other_time=other_time,
        query_times=grid.astype(np.float32),
    )
    interval_prob = _probabilities_from_hidden(
        ctx=ctx,
        hidden=hidden,
        disease_ids=disease_ids,
        deltas=deltas,
    ).astype(np.float64)
    survival = np.prod(1.0 - np.clip(interval_prob, 0.0, 1.0), axis=0)
    return 1.0 - survival


def _observed_formed_burden(
    *,
    disease_ids: np.ndarray,
    event_seq: np.ndarray,
    time_seq: np.ndarray,
    t_query: float,
) -> np.ndarray:
    valid = (
        (time_seq <= np.float32(t_query))
        & (event_seq > PAD_IDX)
        & (event_seq != CHECKUP_IDX)
        & (event_seq != NO_EVENT_IDX)
    )
    observed = set(int(x) for x in event_seq[valid].tolist())
    return np.asarray([1.0 if int(d) in observed else 0.0 for d in disease_ids])


def _build_readout_grid(
    *,
    event_seq: np.ndarray,
    time_seq: np.ndarray,
    other_type: np.ndarray,
    other_time: np.ndarray,
    t_query: float,
) -> np.ndarray:
    event_mask = (event_seq > PAD_IDX) & (time_seq <= np.float32(t_query))
    other_mask = (other_type > 0) & (other_time <= np.float32(t_query))
    times = np.concatenate(
        [
            time_seq[event_mask].astype(np.float32, copy=False),
            other_time[other_mask].astype(np.float32, copy=False),
        ]
    )
    if times.size == 0:
        return np.zeros(0, dtype=np.float32)
    return np.unique(times)


def _query_hidden(
    *,
    ctx: BurdenContext,
    event_seq: np.ndarray,
    time_seq: np.ndarray,
    sex: int,
    other_type: np.ndarray,
    other_value: np.ndarray,
    other_value_kind: np.ndarray,
    other_time: np.ndarray,
    query_times: np.ndarray,
) -> torch.Tensor:
    if query_times.ndim != 1:
        raise ValueError("query_times must be 1D.")
    batch_size = int(query_times.size)
    if batch_size == 0:
        return torch.empty(0, ctx.model.n_embd, device=ctx.device)

    event = torch.from_numpy(event_seq[None, :].repeat(batch_size, axis=0)).long()
    times = torch.from_numpy(time_seq[None, :].repeat(batch_size, axis=0)).float()
    other_t = torch.from_numpy(other_type[None, :].repeat(batch_size, axis=0)).long()
    other_v = torch.from_numpy(other_value[None, :].repeat(batch_size, axis=0)).float()
    other_k = torch.from_numpy(
        other_value_kind[None, :].repeat(batch_size, axis=0)
    ).long()
    other_tm = torch.from_numpy(other_time[None, :].repeat(batch_size, axis=0)).float()
    sex_t = torch.full((batch_size,), int(sex), dtype=torch.long)
    tq = torch.from_numpy(query_times.astype(np.float32, copy=False)).float()

    event = event.to(ctx.device)
    times = times.to(ctx.device)
    other_t = other_t.to(ctx.device)
    other_v = other_v.to(ctx.device)
    other_k = other_k.to(ctx.device)
    other_tm = other_tm.to(ctx.device)
    sex_t = sex_t.to(ctx.device)
    tq = tq.to(ctx.device)

    return ctx.model(
        event_seq=event,
        time_seq=times,
        sex=sex_t,
        padding_mask=event > PAD_IDX,
        t_query=tq,
        other_type=other_t,
        other_value=other_v,
        other_value_kind=other_k,
        other_time=other_tm,
        target_mode="all_future",
    )


def _probabilities_from_hidden(
    *,
    ctx: BurdenContext,
    hidden: torch.Tensor,
    disease_ids: np.ndarray,
    deltas: np.ndarray,
) -> np.ndarray:
    if hidden.ndim != 2:
        raise ValueError(f"hidden must have shape (N, H), got {tuple(hidden.shape)}")
    if deltas.ndim != 1 or deltas.size != hidden.shape[0]:
        raise ValueError(
            "deltas must be 1D with the same length as hidden rows, got "
            f"{deltas.shape} vs {tuple(hidden.shape)}"
        )
    ids = torch.as_tensor(disease_ids, dtype=torch.long, device=ctx.device)
    logits = ctx.model.calc_risk(hidden)[:, ids]
    rate = F.softplus(logits).clamp_min(1e-8)
    delta_t = torch.as_tensor(deltas, dtype=rate.dtype, device=ctx.device).clamp_min(0)

    if ctx.dist_mode == "weibull":
        rho = ctx.model.calc_weibull_rho(hidden)[:, ids]
        exposure = torch.pow(delta_t[:, None], rho)
    elif ctx.dist_mode == "mixed":
        exposure = delta_t[:, None].expand_as(rate)
        death_idx = int(getattr(ctx.model, "death_idx", getattr(ctx.model, "vocab_size", 0) - 1))
        death_cols = [j for j, token in enumerate(disease_ids.tolist()) if int(token) == death_idx]
        if death_cols:
            death_rho = ctx.model.calc_death_rho(hidden)
            for col in death_cols:
                exposure[:, int(col)] = torch.pow(delta_t, death_rho)
    else:
        exposure = delta_t[:, None].expand_as(rate)

    prob = -torch.expm1(-rate * exposure)
    return prob.detach().cpu().numpy().astype(np.float64, copy=False)


def _validate_formed_mode(mode: str) -> FormedBurdenMode:
    if mode not in {"observed", "model_weighted"}:
        raise ValueError(
            "formed_mode must be either 'observed' or 'model_weighted', "
            f"got {mode!r}."
        )
    return mode  # type: ignore[return-value]


def _validate_disease_ids(ctx: BurdenContext, disease_ids: np.ndarray) -> None:
    if disease_ids.ndim != 1 or disease_ids.size == 0:
        raise ValueError("disease_ids must be a non-empty 1D array.")
    vocab_size = int(getattr(ctx.model, "vocab_size", ctx.model.risk_head.out_features))
    if np.any(disease_ids < 0) or np.any(disease_ids >= vocab_size):
        raise ValueError(f"disease_ids must be in [0, {vocab_size}), got {disease_ids}")


def _validate_event_inputs(
    event_seq: Sequence[int] | np.ndarray,
    time_seq: Sequence[float] | np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    events = np.asarray(event_seq, dtype=np.int64)
    times = np.asarray(time_seq, dtype=np.float32)
    if events.ndim != 1 or times.ndim != 1:
        raise ValueError("event_seq and time_seq must be 1D.")
    if events.shape != times.shape:
        raise ValueError(
            f"event_seq and time_seq must have the same shape, got {events.shape} vs {times.shape}"
        )
    if events.size == 0:
        raise ValueError("event_seq must contain at least one token.")
    if not np.all(np.isfinite(times)):
        raise ValueError("time_seq contains non-finite values.")
    return events, times


def _validate_other_inputs(
    other_type: Sequence[int] | np.ndarray,
    other_value: Sequence[float] | np.ndarray,
    other_value_kind: Sequence[int] | np.ndarray,
    other_time: Sequence[float] | np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    typ = np.asarray(other_type, dtype=np.int64)
    val = np.asarray(other_value, dtype=np.float32)
    kind = np.asarray(other_value_kind, dtype=np.int64)
    tm = np.asarray(other_time, dtype=np.float32)
    if not (typ.shape == val.shape == kind.shape == tm.shape):
        raise ValueError(
            "other_type, other_value, other_value_kind, and other_time must "
            f"have the same shape, got {typ.shape}, {val.shape}, {kind.shape}, {tm.shape}."
        )
    if typ.ndim != 1:
        raise ValueError("other_* inputs must be 1D.")
    if not np.all(np.isfinite(tm)):
        raise ValueError("other_time contains non-finite values.")
    return typ, val, kind, tm
