from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

import numpy as np
import torch
from torch.nn.utils.rnn import pad_sequence

from dataset import (
    DISEASE_HISTORY_MODE_TIMED,
    AllFutureHealthDataset,
    HealthDataset,
    normalize_disease_history_mode,
)
from model_architectures import resolve_model_architecture
from models import DeepHealth
from targets import PAD_IDX


def load_json_config(path: str | Path | None) -> Dict[str, Any]:
    if path is None:
        return {}
    config_path = Path(path)
    if not config_path.exists():
        return {}
    with config_path.open("r", encoding="utf-8") as file:
        return json.load(file)


def cfg_get(
    args: argparse.Namespace | Dict[str, Any] | None,
    cfg: Dict[str, Any],
    name: str,
    default: Any,
) -> Any:
    if args is not None:
        value = (
            args.get(name)
            if isinstance(args, dict)
            else getattr(args, name, None)
        )
        if value is not None:
            return value
    return cfg.get(name, default)


def resolve_eval_device(device_arg: Optional[str]) -> torch.device:
    device_name = device_arg or ("cuda" if torch.cuda.is_available() else "cpu")
    device = torch.device(device_name)
    if device.type == "cuda" and not torch.cuda.is_available():
        raise RuntimeError(
            f"Requested device {device_name!r}, but CUDA is not available."
        )
    return device


def validate_training_mode_config(cfg: Dict[str, Any]) -> None:
    model_target_mode = str(
        cfg.get("model_target_mode", "next_token")
    ).lower()
    if model_target_mode not in {"next_token", "all_future"}:
        raise ValueError(
            "model_target_mode must be next_token or all_future, got "
            f"{model_target_mode!r}"
        )
    disease_history_mode = normalize_disease_history_mode(
        cfg.get("disease_history_mode", DISEASE_HISTORY_MODE_TIMED)
    )
    if disease_history_mode != DISEASE_HISTORY_MODE_TIMED:
        expected = {
            "model_target_mode": "all_future",
            "time_mode": "relative",
            "dist_mode": "weibull",
            "model_architecture": "traj_mixer_v5",
        }
        actual = {
            "model_target_mode": model_target_mode,
            "time_mode": str(cfg.get("time_mode", "")).lower(),
            "dist_mode": str(cfg.get("dist_mode", "")).lower(),
            "model_architecture": str(
                cfg.get("model_architecture", "")
            ).lower(),
        }
        mismatches = [
            f"{name}={actual[name]!r} (expected {value!r})"
            for name, value in expected.items()
            if actual[name] != value
        ]
        extra_info_types = cfg.get("extra_info_types", None)
        if extra_info_types != []:
            mismatches.append("extra_info_types must be []")
        if mismatches:
            raise ValueError(
                f"disease_history_mode={disease_history_mode!r} is only valid "
                "for the no-extra TrajMixer + all_future + relative + Weibull "
                "ablation; " + "; ".join(mismatches)
            )

    if model_target_mode != "next_token":
        return

    time_mode = str(cfg.get("time_mode", "")).lower()
    target_mode = str(cfg.get("target_mode", "")).lower()
    if time_mode != "absolute" or target_mode != "delphi2m":
        raise ValueError(
            "next_token is reserved for Delphi2M reproduction and requires "
            "time_mode='absolute' and target_mode='delphi2m'; got "
            f"time_mode={time_mode!r}, target_mode={target_mode!r}"
        )


def split_indices(
    n: int,
    train_ratio: float,
    val_ratio: float,
    test_ratio: float,
    seed: int,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    total = float(train_ratio) + float(val_ratio) + float(test_ratio)
    if not np.isclose(total, 1.0, atol=1e-6):
        raise ValueError(
            f"train/val/test ratios must sum to 1.0, got {total}"
        )
    indices = np.random.RandomState(seed).permutation(n)
    n_train = int(n * train_ratio)
    n_val = int(n * val_ratio)
    return (
        indices[:n_train],
        indices[n_train:n_train + n_val],
        indices[n_train + n_val:],
    )


def select_indices_by_eid_file(
    dataset: Any,
    eid_file: str | Path,
) -> Tuple[np.ndarray, Path]:
    """Return dataset indices whose patient EIDs occur in ``eid_file``."""
    from train_util import load_eid_file

    path = Path(eid_file)
    if not path.is_absolute():
        direct = Path.cwd() / path
        path = direct if direct.is_file() else Path(__file__).resolve().parent / path
    if not path.is_file():
        raise FileNotFoundError(f"EID split file not found: {path}")

    samples = getattr(dataset, "samples", None)
    if samples is None:
        raise TypeError("EID-based evaluation requires dataset.samples")
    selected_eids = load_eid_file(path)
    indices = np.asarray(
        [
            index
            for index, sample in enumerate(samples)
            if int(sample["eid"]) in selected_eids
        ],
        dtype=np.int64,
    )
    if indices.size == 0:
        raise ValueError(
            f"No dataset patients matched the EID split file: {path}"
        )
    return indices, path.resolve()


def build_model_from_dataset(
    args: argparse.Namespace,
    cfg: Dict[str, Any],
    dataset: HealthDataset,
    state_dict: Optional[Dict[str, Any]] = None,
) -> DeepHealth:
    validate_training_mode_config(cfg)
    model_target_mode = str(
        cfg_get(args, cfg, "model_target_mode", "next_token")
    ).lower()
    if model_target_mode not in {"next_token", "all_future"}:
        raise ValueError(
            "model_target_mode must be next_token or all_future, got "
            f"{model_target_mode!r}"
        )
    risk_head_bias = bool(cfg_get(args, cfg, "risk_head_bias", False))
    if state_dict is not None:
        # The checkpoint schema is authoritative. This keeps all older
        # bias-free checkpoints loadable while restoring the new baseline bias.
        risk_head_bias = "risk_head.bias" in state_dict
    model_architecture = resolve_model_architecture(cfg, state_dict)
    continuous_value_center = None
    continuous_value_scale = None
    if dataset.n_cont_types > 0:
        scaling = str(cfg.get("continuous_value_scaling", "")).lower()
        if scaling != "robust":
            raise RuntimeError(
                "Continuous-variable checkpoints must declare "
                "continuous_value_scaling='robust'; unscaled checkpoints are "
                "not supported"
            )
        if state_dict is None:
            raise RuntimeError(
                "A checkpoint state_dict is required to restore RobustScale buffers"
            )
        center_key = "tokenizer.continuous_value_center"
        scale_key = "tokenizer.continuous_value_scale"
        missing = [
            key for key in (center_key, scale_key)
            if key not in state_dict
        ]
        if missing:
            raise RuntimeError(
                "Checkpoint is missing required RobustScale buffers: "
                + ", ".join(missing)
            )
        continuous_value_center = state_dict[center_key]
        continuous_value_scale = state_dict[scale_key]
    return DeepHealth(
        vocab_size=dataset.vocab_size,
        n_embd=int(cfg_get(args, cfg, "n_embd", 120)),
        n_head=int(cfg_get(args, cfg, "n_head", 10)),
        n_layer=int(cfg["n_layer"]),
        n_types=dataset.n_types,
        n_cont_types=dataset.n_cont_types,
        n_categories=dataset.n_categories,
        cont_type_ids=dataset.cont_type_ids,
        n_bins=int(cfg_get(args, cfg, "n_bins", 16)),
        continuous_value_center=continuous_value_center,
        continuous_value_scale=continuous_value_scale,
        extra_pool_reduce=str(
            cfg_get(args, cfg, "extra_pool_reduce", "mean")
        ),
        target_mode=model_target_mode,
        time_mode=str(cfg_get(args, cfg, "time_mode", "absolute")),
        dist_mode=str(cfg_get(args, cfg, "dist_mode", "exponential")),
        dropout=float(cfg_get(args, cfg, "dropout", 0.0)),
        model_architecture=model_architecture,
        risk_head_bias=risk_head_bias,
    )


def validate_dataset_metadata(
    dataset: HealthDataset,
    cfg: Dict[str, Any],
) -> None:
    metadata = cfg.get("dataset_metadata")
    if not isinstance(metadata, dict):
        return

    actual: Dict[str, Any] = {
        "vocab_size": int(dataset.vocab_size),
        "n_types": int(dataset.n_types),
        "n_cont_types": int(dataset.n_cont_types),
        "n_categories": int(dataset.n_categories),
        "cont_type_ids": [int(x) for x in dataset.cont_type_ids],
        "extra_info_types": [int(x) for x in dataset.extra_info_types],
    }
    mismatches = [
        f"{key}: train_config={metadata.get(key)!r}, current_dataset={value!r}"
        for key, value in actual.items()
        if key in metadata and metadata.get(key) != value
    ]
    if mismatches:
        raise RuntimeError(
            "Current dataset metadata does not match train_config.json. "
            "Use the same prepared data and extra_info_types as training. "
            + "; ".join(mismatches)
        )


def build_first_occurrence_map(
    dataset: HealthDataset,
    subset_indices: np.ndarray,
) -> Dict[int, Tuple[np.ndarray, np.ndarray]]:
    first_lists: Dict[int, List[Tuple[int, float]]] = {}
    for patient_id, dataset_index in enumerate(subset_indices.tolist()):
        sample = dataset.samples[int(dataset_index)]
        sequence_events = np.asarray(sample["event_seq"], dtype=np.int64)
        sequence_times = np.asarray(sample["time_seq"], dtype=np.float32)
        target_events = np.asarray(
            sample["target_event_seq"], dtype=np.int64
        )
        target_times = np.asarray(
            sample["target_time_seq"], dtype=np.float32
        )
        if sequence_events.size == 0 or target_events.size == 0:
            continue

        full_events = np.concatenate(
            [sequence_events, target_events[-1:]]
        )
        full_times = np.concatenate([sequence_times, target_times[-1:]])
        unique_tokens, first_indices = np.unique(
            full_events, return_index=True
        )
        for token, first_index in zip(
            unique_tokens.tolist(), first_indices.tolist()
        ):
            first_lists.setdefault(int(token), []).append(
                (patient_id, float(full_times[int(first_index)]))
            )

    return {
        int(token): (
            np.asarray([patient for patient, _ in pairs], dtype=np.int32),
            np.asarray([time for _, time in pairs], dtype=np.float32),
        )
        for token, pairs in first_lists.items()
        if pairs
    }


class AllFutureSequenceEvalDataset:
    """
    Eval-only sequence view for all-future checkpoints.

    All-future training uses the observed history without reusing the
    next-step view that contains imputed <NO_EVENT> gap tokens. Legacy label-1
    assessment events are removed by the shared base dataset for every
    extra-info selection.
    """

    def __init__(
        self,
        data_prefix: str,
        labels_file: str,
        min_history_events: int = 1,
        min_future_events: int = 1,
        extra_info_types: Iterable[int] | None = None,
        disease_history_mode: str = DISEASE_HISTORY_MODE_TIMED,
    ) -> None:
        base = AllFutureHealthDataset(
            data_prefix=data_prefix,
            labels_file=labels_file,
            split="train",
            min_history_events=min_history_events,
            min_future_events=min_future_events,
            extra_info_types=extra_info_types,
            disease_history_mode=disease_history_mode,
        )

        self.base = base
        self.label_code_to_id = base.label_code_to_id
        self.label_id_to_code = base.label_id_to_code
        self.vocab_size = base.vocab_size
        self.n_types = base.n_types
        self.n_cont_types = base.n_cont_types
        self.n_categories = base.n_categories
        self.cont_type_ids = base.cont_type_ids
        self.extra_info_types = base.extra_info_types
        self.disease_history_mode = base.disease_history_mode

        self.samples: List[Dict[str, Any]] = []
        for patient in base.patients:
            labels = np.asarray(patient["labels"], dtype=np.int64)
            times = np.asarray(patient["times"], dtype=np.float32)
            if labels.size < 2:
                continue
            self.samples.append(
                {
                    "eid": int(patient["eid"]),
                    "event_seq": labels[:-1],
                    "time_seq": times[:-1],
                    "target_event_seq": labels[1:],
                    "target_time_seq": times[1:],
                    "sex": int(patient["sex"]),
                    "other_type": np.asarray(patient["other_type"], dtype=np.int64),
                    "other_value": np.asarray(patient["other_value"], dtype=np.float32),
                    "other_value_kind": np.asarray(patient["other_value_kind"], dtype=np.int64),
                    "other_time": np.asarray(patient["other_time"], dtype=np.float32),
                }
            )

    def __len__(self) -> int:
        return len(self.samples)

    def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
        s = self.samples[idx]
        return {
            "event_seq": torch.from_numpy(s["event_seq"]).long(),
            "time_seq": torch.from_numpy(s["time_seq"]).float(),
            "target_event_seq": torch.from_numpy(s["target_event_seq"]).long(),
            "target_time_seq": torch.from_numpy(s["target_time_seq"]).float(),
            "sex": torch.tensor(s["sex"], dtype=torch.long),
            "other_type": torch.from_numpy(s["other_type"]).long(),
            "other_value": torch.from_numpy(s["other_value"]).float(),
            "other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
            "other_time": torch.from_numpy(s["other_time"]).float(),
        }


def load_sequence_eval_dataset(
    *,
    model_target_mode: str,
    data_prefix: str,
    labels_file: str,
    no_event_interval_years: float,
    min_history_events: int,
    min_future_events: int,
    extra_info_types: Iterable[int] | None,
    disease_history_mode: str = DISEASE_HISTORY_MODE_TIMED,
):
    mode = str(model_target_mode).lower()
    if mode == "next_token":
        return HealthDataset(
            data_prefix=data_prefix,
            labels_file=labels_file,
            no_event_interval_years=no_event_interval_years,
            extra_info_types=extra_info_types,
        )
    if mode == "all_future":
        return AllFutureSequenceEvalDataset(
            data_prefix=data_prefix,
            labels_file=labels_file,
            min_history_events=min_history_events,
            min_future_events=min_future_events,
            extra_info_types=extra_info_types,
            disease_history_mode=disease_history_mode,
        )
    raise ValueError(f"Unknown model_target_mode: {model_target_mode!r}")


def sequence_eval_collate_fn(batch: List[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
    event_seq = pad_sequence(
        [s["event_seq"] for s in batch], batch_first=True, padding_value=PAD_IDX
    )
    time_seq = pad_sequence(
        [s["time_seq"] for s in batch], batch_first=True, padding_value=0.0
    )
    target_event_seq = pad_sequence(
        [s["target_event_seq"] for s in batch], batch_first=True, padding_value=PAD_IDX
    )
    target_time_seq = pad_sequence(
        [s["target_time_seq"] for s in batch], batch_first=True, padding_value=0.0
    )
    other_type = pad_sequence(
        [s["other_type"] for s in batch], batch_first=True, padding_value=0
    )
    other_value = pad_sequence(
        [s["other_value"] for s in batch], batch_first=True, padding_value=0.0
    )
    other_value_kind = pad_sequence(
        [s["other_value_kind"] for s in batch], batch_first=True, padding_value=0
    )
    other_time = pad_sequence(
        [s["other_time"] for s in batch], batch_first=True, padding_value=0.0
    )

    return {
        "event_seq": event_seq,
        "time_seq": time_seq,
        "padding_mask": event_seq > PAD_IDX,
        "target_event_seq": target_event_seq,
        "target_time_seq": target_time_seq,
        "readout_mask": event_seq > PAD_IDX,
        "sex": torch.stack([s["sex"] for s in batch]),
        "other_type": other_type,
        "other_value": other_value,
        "other_value_kind": other_value_kind,
        "other_time": other_time,
    }
