"""Classifier / reranker / cross_encoder trainer (v0.53.2 #132).

Wraps :class:`transformers.AutoModelForSequenceClassification` for the three
classifier-family tasks declared in v0.52.0 Part B:

* ``classifier`` — single-input sequence classification.
* ``reranker`` — single-input reranker (typically score head); shares the
  classifier head topology.
* ``cross_encoder`` — paired-input scoring (passage / query, NLI, etc.).

Single-label uses ``CrossEntropyLoss``; multi-label uses ``BCEWithLogitsLoss``
(automatically when ``training.classifier_kind='multi_label'``).

Data format expected per row:

* ``text`` (or ``messages`` joined into a string) — single-input tasks.
* ``text_a`` + ``text_b`` — paired-input ``cross_encoder``.
* ``label`` — int (single-label), list[int] (multi-label), or string in
  ``training.label_names``.

Mirrors the BCO / pretrain wrapper pattern. Lazy imports for heavy deps;
trust_remote_code threaded through the v0.36.0 resolver.
"""

from __future__ import annotations

import math
import time
from pathlib import Path
from typing import Any, List, Union

from rich.console import Console

from soup_cli.config.schema import SoupConfig

console = Console()

# Cap on multi-label list entries — defense against malformed dataset rows
# (security review v0.53.2 H2). Matches v0.52.0 ``_MAX_LABELS=1024``.
_MAX_MULTI_LABEL_ENTRIES: int = 1024


def _row_to_text(row: dict) -> str:
    """Extract the single-input text from a row (``text`` or joined messages).

    Raises:
        TypeError: non-string ``content`` inside a messages list — silent skip
            could poison training data with empty strings (security review
            v0.53.2 M3).
        ValueError: neither ``text`` nor ``messages`` present.
    """
    if "text" in row and isinstance(row["text"], str):
        return row["text"]
    msgs = row.get("messages")
    if isinstance(msgs, list):
        parts: list[str] = []
        for msg in msgs:
            if not isinstance(msg, dict):
                # Non-dict entries are silently skipped — caller's loader
                # would normally have produced these; keep loud-fail at the
                # row-level (missing text) rather than per-message.
                continue
            content = msg.get("content", "")
            if not isinstance(content, str):
                raise TypeError(
                    "Classifier row messages[i]['content'] must be str, got "
                    f"{type(content).__name__!r}"
                )
            parts.append(content)
        return "\n".join(parts)
    raise ValueError(
        "Classifier row missing 'text' field and no joinable 'messages' list. "
        f"Row keys: {sorted(row)!r}"
    )


def _row_to_pair(row: dict) -> tuple[str, str]:
    """Extract (text_a, text_b) for paired ``cross_encoder`` rows.

    Raises:
        TypeError: either field present but not a string (security review M4 —
            silent ``str()`` coercion of dicts/lists produced garbage training
            text).
        ValueError: neither pair of fields present.
    """
    if "text_a" in row and "text_b" in row:
        a, b = row["text_a"], row["text_b"]
        if not isinstance(a, str) or not isinstance(b, str):
            raise TypeError(
                "cross_encoder rows require 'text_a' and 'text_b' to be str; "
                f"got text_a={type(a).__name__}, text_b={type(b).__name__}"
            )
        return a, b
    if "question" in row and "answer" in row:
        q, ans = row["question"], row["answer"]
        if not isinstance(q, str) or not isinstance(ans, str):
            raise TypeError(
                "cross_encoder rows require 'question' and 'answer' to be "
                f"str; got question={type(q).__name__}, answer={type(ans).__name__}"
            )
        return q, ans
    raise ValueError(
        "cross_encoder row requires 'text_a' + 'text_b' (or 'question' + "
        f"'answer'). Row keys: {sorted(row)!r}"
    )


def _normalise_label(
    raw: object,
    label_names: List[str] | None,
    num_labels: int,
    multi_label: bool,
) -> Union[int, list[float]]:
    """Convert a raw label (int / str / list) to the trainer-expected form.

    Single-label → int in [0, num_labels). Multi-label → list[float] of
    length ``num_labels``.

    Raises:
        ValueError: invalid index, oversize multi-label list (defense-in-depth
            against malformed datasets — security review H2).
        TypeError: bool / unsupported scalar type.
    """
    if multi_label:
        if isinstance(raw, list):
            if len(raw) > _MAX_MULTI_LABEL_ENTRIES:
                raise ValueError(
                    f"multi-label list too long: {len(raw)} entries "
                    f"(max {_MAX_MULTI_LABEL_ENTRIES})"
                )
            vec = [0.0] * num_labels
            for entry in raw:
                idx = _label_index(entry, label_names, num_labels)
                vec[idx] = 1.0
            return vec
        # Single label silently broadcast to one-hot multi-label.
        idx = _label_index(raw, label_names, num_labels)
        vec = [0.0] * num_labels
        vec[idx] = 1.0
        return vec
    return _label_index(raw, label_names, num_labels)


def _label_index(
    raw: object, label_names: List[str] | None, num_labels: int
) -> int:
    if isinstance(raw, bool):
        # Project policy (v0.30.0 Candidate / v0.39.0 ReLoRAPolicy / v0.41.0
        # Part B): bool-as-int violations raise TypeError, not ValueError.
        raise TypeError(f"label must not be bool, got {raw!r}")
    if isinstance(raw, int):
        if raw < 0 or raw >= num_labels:
            raise ValueError(
                f"label index {raw} out of range [0, {num_labels})"
            )
        return raw
    if isinstance(raw, str):
        if label_names is None:
            raise ValueError(
                f"label is str {raw!r} but training.label_names is unset"
            )
        try:
            return label_names.index(raw)
        except ValueError as exc:
            raise ValueError(
                f"label {raw!r} not in training.label_names={label_names!r}"
            ) from exc
    raise TypeError(
        f"label must be int / str / list, got {type(raw).__name__}"
    )


class ClassifierTrainerWrapper:
    """High-level wrapper for classifier / reranker / cross_encoder training."""

    def __init__(
        self,
        config: SoupConfig,
        device: str = "cuda",
        report_to: str = "none",
        deepspeed_config: str | None = None,
        fsdp_config: dict | None = None,
        trust_remote_code: bool = False,
    ) -> None:
        self.config = config
        self.device = device
        self.report_to = report_to
        self.deepspeed_config = deepspeed_config
        self.fsdp_config = fsdp_config
        self.trust_remote_code = trust_remote_code

        from soup_cli.utils.trust_remote import (
            model_requires_trust_remote_code,
            resolve_trust_remote_code,
        )

        requires = model_requires_trust_remote_code(config.base) or False
        self._trust_remote_code = resolve_trust_remote_code(
            config.base,
            requested=trust_remote_code,
            console=console,
            requires_remote_code=requires,
        )

        self.model: Any = None
        self.tokenizer: Any = None
        self.trainer: Any = None
        self._output_dir: str | None = None

    def setup(self, dataset: dict) -> None:
        """Load model + tokenizer, tokenise dataset, build HF Trainer."""
        from datasets import Dataset
        from transformers import (
            AutoModelForSequenceClassification,
            AutoTokenizer,
            Trainer,
            TrainingArguments,
        )

        cfg = self.config
        tcfg = cfg.training

        if tcfg.num_labels is None:
            raise ValueError(
                f"task={cfg.task!r} requires training.num_labels to be set"
            )
        num_labels = int(tcfg.num_labels)
        multi_label = (tcfg.classifier_kind == "multi_label")
        problem_type = (
            "multi_label_classification" if multi_label else "single_label_classification"
        )

        console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
        self.tokenizer = AutoTokenizer.from_pretrained(
            cfg.base, trust_remote_code=self._trust_remote_code
        )
        if self.tokenizer.pad_token is None and self.tokenizer.eos_token is not None:
            self.tokenizer.pad_token = self.tokenizer.eos_token

        console.print(f"[dim]Loading classifier model: {cfg.base}[/]")
        self.model = AutoModelForSequenceClassification.from_pretrained(
            cfg.base,
            num_labels=num_labels,
            problem_type=problem_type,
            trust_remote_code=self._trust_remote_code,
        )

        is_paired = (cfg.task == "cross_encoder")
        label_names = (
            list(tcfg.label_names) if tcfg.label_names is not None else None
        )

        def encode(row: dict) -> dict:
            if is_paired:
                a, b = _row_to_pair(row)
                enc = self.tokenizer(
                    a, b,
                    truncation=True,
                    max_length=cfg.data.max_length,
                )
            else:
                text = _row_to_text(row)
                enc = self.tokenizer(
                    text,
                    truncation=True,
                    max_length=cfg.data.max_length,
                )
            label = _normalise_label(
                row.get("label"), label_names, num_labels, multi_label
            )
            enc["labels"] = label
            return enc

        raw_train = Dataset.from_list(dataset["train"])
        train_ds = raw_train.map(encode, remove_columns=raw_train.column_names)
        eval_ds = None
        if "val" in dataset and dataset["val"]:
            raw_val = Dataset.from_list(dataset["val"])
            eval_ds = raw_val.map(encode, remove_columns=raw_val.column_names)

        output_dir = Path(cfg.output)
        if cfg.experiment_name:
            output_dir = output_dir / cfg.experiment_name
        output_dir.mkdir(parents=True, exist_ok=True)

        batch_size = tcfg.batch_size if tcfg.batch_size != "auto" else 8
        total_steps = (
            math.ceil(len(train_ds) / batch_size / tcfg.gradient_accumulation_steps)
            * tcfg.epochs
        )
        warmup_steps = int(total_steps * tcfg.warmup_ratio)

        args = TrainingArguments(
            output_dir=str(output_dir),
            num_train_epochs=tcfg.epochs,
            per_device_train_batch_size=batch_size,
            gradient_accumulation_steps=tcfg.gradient_accumulation_steps,
            learning_rate=tcfg.lr,
            warmup_steps=warmup_steps,
            weight_decay=tcfg.weight_decay,
            max_grad_norm=tcfg.max_grad_norm,
            optim=tcfg.optimizer,
            lr_scheduler_type=tcfg.scheduler,
            logging_steps=tcfg.logging_steps,
            save_steps=tcfg.save_steps,
            save_total_limit=3,
            bf16=self.device == "cuda",
            report_to=self.report_to,
            deepspeed=self.deepspeed_config,
            **(self.fsdp_config or {}),
        )

        from transformers import DataCollatorWithPadding

        self.trainer = Trainer(
            model=self.model,
            args=args,
            train_dataset=train_ds,
            eval_dataset=eval_ds,
            tokenizer=self.tokenizer,
            data_collator=DataCollatorWithPadding(tokenizer=self.tokenizer),
        )
        self._output_dir = str(output_dir)

    def train(
        self,
        display: object | None = None,
        tracker: object | None = None,
        run_id: str = "",
        resume_from_checkpoint: str | None = None,
    ) -> dict:
        if self.trainer is None:
            raise RuntimeError(
                "ClassifierTrainerWrapper.train() called before setup(). "
                "Call setup(dataset) first."
            )
        start = time.time()
        if display is not None:
            from soup_cli.monitoring.callback import SoupTrainerCallback

            self.trainer.add_callback(
                SoupTrainerCallback(
                    display, tracker=tracker, run_id=run_id,
                    loss_watchdog=self.config.training.loss_watchdog,
                    loss_watchdog_threshold=self.config.training.loss_watchdog_threshold,
                    loss_watchdog_patience=self.config.training.loss_watchdog_patience,
                )
            )
        self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
        duration = time.time() - start

        self.trainer.save_model(self._output_dir)
        self.tokenizer.save_pretrained(self._output_dir)

        logs = self.trainer.state.log_history
        train_losses = [entry["loss"] for entry in logs if "loss" in entry]

        hours = int(duration // 3600)
        minutes = int((duration % 3600) // 60)
        duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
        return {
            "initial_loss": train_losses[0] if train_losses else 0,
            "final_loss": train_losses[-1] if train_losses else 0,
            "duration": duration_str,
            "duration_secs": duration,
            "output_dir": self._output_dir,
            "total_steps": self.trainer.state.global_step,
        }
