"""
SFT (Supervised Fine-Tuning) dataset for the Korean LLM project.

Reads JSONL files in three supported formats:

  1. Alpaca format
     {"instruction": "...", "input": "...", "output": "..."}

  2. Alpaca format without optional input
     {"instruction": "...", "output": "..."}

  3. Conversation format
     {"conversations": [{"role": "user", "content": "..."},
                        {"role": "assistant", "content": "..."}]}

Chat template applied:

    <|user|>
    {instruction or user turn}
    <|assistant|>
    {output or assistant turn}</s>

Loss masking: ``labels`` is -1 for all prompt tokens so
``nn.CrossEntropyLoss`` (ignore_index=-1) only trains on
the assistant responses.
"""

from __future__ import annotations

import json
import multiprocessing
import time
from pathlib import Path
from typing import Union

import torch
from torch.utils.data import Dataset
from tokenizers import Tokenizer  # HuggingFace tokenizers (fast, Rust-based)


# ---------------------------------------------------------------------------
# Role tags used in the chat template.
# ---------------------------------------------------------------------------
_USER_TAG      = "<|user|>\n"
_ASSISTANT_TAG = "<|assistant|>\n"
_EOS_STRING    = "</s>"


def _build_alpaca_turns(
    instruction: str,
    input_text: str,
    output: str,
) -> tuple[str, str]:
    """
    Convert an Alpaca-format sample into (prompt, response) strings.

    The *prompt* includes the user tag and instruction (+ optional input).
    The *response* includes the assistant tag and output, plus EOS.

    Args:
        instruction: The task instruction.
        input_text:  Optional additional input context. May be empty.
        output:      The expected assistant response.

    Returns:
        Tuple of (prompt_text, response_text).
    """
    user_body = instruction
    if input_text and input_text.strip():
        user_body = f"{instruction}\n{input_text.strip()}"

    prompt   = f"{_USER_TAG}{user_body}\n{_ASSISTANT_TAG}"
    response = f"{output}{_EOS_STRING}"
    return prompt, response


def _build_conversation_turns(
    conversations: list[dict],
) -> list[tuple[str, str]]:
    """
    Convert a conversation list into a sequence of (prompt, response) pairs.

    For a multi-turn conversation the prompt for turn *k* is the entire
    dialogue history up to (but not including) assistant turn *k*.

    Only user→assistant pairs contribute training samples.  Consecutive
    user messages are merged.  Conversations that start with an assistant
    turn, or that have no assistant turn, are skipped (return empty list).

    Args:
        conversations: List of dicts with ``role`` and ``content`` keys.
                       Roles are expected to be ``"user"`` or ``"assistant"``.

    Returns:
        List of (prompt_text, response_text) tuples, one per assistant turn.
    """
    pairs: list[tuple[str, str]] = []
    history = ""          # accumulated dialogue so far
    pending_user = ""     # user content not yet closed by an assistant turn

    for turn in conversations:
        role    = turn.get("role", "").lower()
        content = turn.get("content", "")

        if role == "user":
            if pending_user:
                # Two consecutive user turns — concatenate them.
                pending_user = f"{pending_user}\n{content}"
            else:
                pending_user = content

        elif role == "assistant":
            if not pending_user:
                # Assistant turn without a preceding user turn — skip.
                continue
            prompt   = f"{history}{_USER_TAG}{pending_user}\n{_ASSISTANT_TAG}"
            response = f"{content}{_EOS_STRING}"
            pairs.append((prompt, response))
            # Update history to include this full exchange (without the EOS
            # so the model does not treat it as a hard stop mid-context).
            history = f"{history}{_USER_TAG}{pending_user}\n{_ASSISTANT_TAG}{content}\n"
            pending_user = ""

    return pairs


# ---------------------------------------------------------------------------
# Multiprocessing worker for parallel tokenization.
# ---------------------------------------------------------------------------
_worker_tokenizer: Tokenizer | None = None
_worker_eos_id: int = -1
_worker_max_seq_len: int = 4096


def _worker_init(tokenizer_path: str, eos_string: str, max_seq_len: int) -> None:
    """Initializer for each pool worker — loads its own tokenizer instance."""
    global _worker_tokenizer, _worker_eos_id, _worker_max_seq_len
    _worker_tokenizer = Tokenizer.from_file(tokenizer_path)
    eos_id = _worker_tokenizer.token_to_id(eos_string)
    if eos_id is None:
        raise ValueError(f"EOS token '{eos_string}' not found in worker tokenizer.")
    _worker_eos_id = eos_id
    _worker_max_seq_len = max_seq_len


def _worker_tokenize_batch(
    batch: list[tuple[str, str]],
) -> list[tuple[list[int], list[int]] | None]:
    """
    Tokenize a batch of (prompt, response) pairs in a worker process.

    Returns a list of (prompt_ids, response_ids) as Python lists, or None
    for samples that should be skipped.
    """
    global _worker_tokenizer, _worker_eos_id, _worker_max_seq_len
    tok = _worker_tokenizer
    eos_id = _worker_eos_id
    max_seq_len = _worker_max_seq_len
    results = []

    for prompt_text, response_text in batch:
        prompt_ids = tok.encode(prompt_text).ids
        response_ids = tok.encode(response_text).ids

        # Skip samples where the prompt alone leaves no room for output.
        if len(prompt_ids) >= max_seq_len - 10:
            results.append(None)
            continue

        full_len = len(prompt_ids) + len(response_ids)

        # Truncate response if combined sequence is too long.
        if full_len > max_seq_len:
            allowed_response = max_seq_len - len(prompt_ids)
            if allowed_response <= 0:
                results.append(None)
                continue
            response_ids = response_ids[:allowed_response]
            # Force EOS at end after truncation.
            if response_ids[-1] != eos_id:
                response_ids[-1] = eos_id

        results.append((prompt_ids, response_ids))

    return results


class SFTDataset(Dataset):
    """
    Supervised Fine-Tuning dataset built from JSONL files.

    Each JSONL line must conform to one of three schemas described in the
    module docstring.  After tokenisation the sample is laid out as::

        [prompt tokens ...] [response tokens ...] [pad tokens ...]
        |<---- labels=-1 ---->| |<-- labels=token_id -->| |<- labels=-1 ->|

    The ``labels`` tensor uses -1 as the ignore value so that
    ``nn.CrossEntropyLoss(ignore_index=-1)`` only penalises the model on
    the assistant response tokens.

    Args:
        data_path:    Path to a single ``.jsonl`` file or a directory that
                      contains multiple ``.jsonl`` files (all are loaded).
        tokenizer:    A ``tokenizers.Tokenizer`` instance (HuggingFace fast
                      tokenizer loaded from ``tokenizer.json``).
        max_seq_len:  Maximum sequence length (tokens).  Samples exceeding
                      this are truncated from the *end of the response*.
                      Default: 4096.
        pad_token_id: Token id used for right-padding.  Default: 0.
    """

    def __init__(
        self,
        data_path: Union[str, Path],
        tokenizer: Tokenizer,
        max_seq_len: int = 4096,
        pad_token_id: int = 0,
        tokenizer_path: Union[str, Path, None] = None,
        num_workers: int = 60,
    ) -> None:
        super().__init__()

        self.tokenizer    = tokenizer
        self.max_seq_len  = max_seq_len
        self.pad_token_id = pad_token_id

        # Resolve EOS token id from the vocabulary.
        eos_id = tokenizer.token_to_id(_EOS_STRING)
        if eos_id is None:
            raise ValueError(
                f"EOS token '{_EOS_STRING}' not found in the tokenizer vocabulary. "
                "Check that the tokenizer was trained with this special token."
            )
        self.eos_token_id: int = eos_id

        # ------------------------------------------------------------------
        # Load raw JSONL samples.
        # ------------------------------------------------------------------
        data_path = Path(data_path)
        raw_samples = self._load_jsonl(data_path)

        # ------------------------------------------------------------------
        # Try loading from cache first.
        # ------------------------------------------------------------------
        cache_path = Path(f"{data_path}.sft_cache.pt")
        cache_key = self._make_cache_key(data_path, max_seq_len, tokenizer)
        cached = self._try_load_cache(cache_path, cache_key)
        if cached is not None:
            self.samples = cached
            return

        # ------------------------------------------------------------------
        # Tokenise and build (input_ids, labels) pairs.
        # ------------------------------------------------------------------
        t0 = time.time()

        if tokenizer_path is not None:
            self.samples = self._tokenize_parallel(
                raw_samples, str(tokenizer_path), max_seq_len, num_workers,
            )
        else:
            self.samples = self._tokenize_sequential(
                raw_samples, tokenizer, max_seq_len,
            )

        elapsed = time.time() - t0
        print(f"[SFTDataset] Tokenization took {elapsed:.1f}s")

        # ------------------------------------------------------------------
        # Save cache.
        # ------------------------------------------------------------------
        self._save_cache(cache_path, cache_key)

    # ------------------------------------------------------------------
    # Cache helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _make_cache_key(
        data_path: Path, max_seq_len: int, tokenizer: Tokenizer,
    ) -> tuple:
        """Build a cheap cache key from file metadata + settings."""
        if data_path.is_file():
            stat = data_path.stat()
            file_sig = (stat.st_size, stat.st_mtime)
        else:
            # Directory: combine stats of all jsonl files.
            parts = []
            for f in sorted(data_path.glob("*.jsonl")):
                s = f.stat()
                parts.append((str(f), s.st_size, s.st_mtime))
            file_sig = tuple(parts)
        return (file_sig, max_seq_len, tokenizer.get_vocab_size())

    def _try_load_cache(
        self, cache_path: Path, cache_key: tuple,
    ) -> list[tuple[torch.Tensor, torch.Tensor]] | None:
        """Load cached tokenized samples if cache is valid."""
        if not cache_path.exists():
            print(f"[SFTDataset] Cache miss — no cache file at {cache_path}")
            return None
        try:
            t0 = time.time()
            cache = torch.load(cache_path, map_location="cpu", weights_only=False)
            if cache.get("cache_key") != cache_key:
                print(f"[SFTDataset] Cache miss — stale cache (key mismatch)")
                return None
            samples = cache["samples"]
            elapsed = time.time() - t0
            print(
                f"[SFTDataset] Cache hit! Loaded {len(samples)} samples "
                f"from {cache_path} in {elapsed:.1f}s"
            )
            return samples
        except Exception as exc:
            print(f"[SFTDataset] Cache miss — failed to load: {exc}")
            return None

    def _save_cache(self, cache_path: Path, cache_key: tuple) -> None:
        """Save tokenized samples to cache file."""
        try:
            t0 = time.time()
            torch.save(
                {"cache_key": cache_key, "samples": self.samples},
                cache_path,
            )
            elapsed = time.time() - t0
            size_mb = cache_path.stat().st_size / (1024 * 1024)
            print(
                f"[SFTDataset] Saved cache ({size_mb:.0f} MB) "
                f"to {cache_path} in {elapsed:.1f}s"
            )
        except Exception as exc:
            print(f"[SFTDataset] WARNING: Failed to save cache: {exc}")

    # ------------------------------------------------------------------
    # Tokenization strategies
    # ------------------------------------------------------------------

    def _tokenize_sequential(
        self,
        raw_samples: list[tuple[str, str]],
        tokenizer: Tokenizer,
        max_seq_len: int,
    ) -> list[tuple[torch.Tensor, torch.Tensor]]:
        """Original sequential tokenization (fallback when no tokenizer_path)."""
        samples: list[tuple[torch.Tensor, torch.Tensor]] = []
        total_loaded = 0
        total_tokens = 0
        skipped_too_long = 0
        truncated_count = 0

        for prompt_text, response_text in raw_samples:
            total_loaded += 1

            prompt_ids   = tokenizer.encode(prompt_text).ids
            response_ids = tokenizer.encode(response_text).ids

            if len(prompt_ids) >= max_seq_len - 10:
                skipped_too_long += 1
                continue

            full_ids = prompt_ids + response_ids

            if len(full_ids) > max_seq_len:
                truncated_count += 1
                allowed_response = max_seq_len - len(prompt_ids)
                if allowed_response <= 0:
                    skipped_too_long += 1
                    continue
                response_ids = response_ids[:allowed_response]
                if response_ids[-1] != self.eos_token_id:
                    response_ids[-1] = self.eos_token_id
                full_ids = prompt_ids + response_ids

            seq_len = len(full_ids)
            total_tokens += seq_len

            input_ids = torch.tensor(full_ids, dtype=torch.int32)
            labels = torch.full((seq_len,), fill_value=-1, dtype=torch.int32)
            resp_start = len(prompt_ids)
            resp_label_start = max(0, resp_start - 1)
            resp_label_end   = resp_label_start + len(response_ids)
            labels[resp_label_start:resp_label_end] = torch.tensor(
                response_ids, dtype=torch.int32
            )
            samples.append((input_ids, labels))

        n = len(samples)
        avg_len = (total_tokens / n) if n > 0 else 0.0
        print(
            f"[SFTDataset] Loaded {n} samples "
            f"(raw={total_loaded}, "
            f"skipped_too_long={skipped_too_long}, "
            f"truncated={truncated_count})"
        )
        print(
            f"[SFTDataset] avg_seq_len={avg_len:.1f}, "
            f"max_seq_len={max_seq_len}, "
            f"pad_token_id={self.pad_token_id}, "
            f"eos_token_id={self.eos_token_id}"
        )
        return samples

    def _tokenize_parallel(
        self,
        raw_samples: list[tuple[str, str]],
        tokenizer_path: str,
        max_seq_len: int,
        num_workers: int,
    ) -> list[tuple[torch.Tensor, torch.Tensor]]:
        """Parallel tokenization using multiprocessing.Pool."""
        total = len(raw_samples)
        print(
            f"[SFTDataset] Starting parallel tokenization: "
            f"{total} samples, {num_workers} workers"
        )

        # Split raw_samples into chunks for imap_unordered.
        chunk_size = 1000
        chunks = []
        for i in range(0, total, chunk_size):
            chunks.append(raw_samples[i : i + chunk_size])

        # Collect tokenized results from workers.
        all_token_pairs: list[tuple[list[int], list[int]] | None] = []
        processed = 0

        # Use 'spawn' context to avoid fork+CUDA issues when called
        # after model is already on GPU (e.g., in DDP training).
        ctx = multiprocessing.get_context("spawn")
        with ctx.Pool(
            processes=num_workers,
            initializer=_worker_init,
            initargs=(tokenizer_path, _EOS_STRING, max_seq_len),
        ) as pool:
            for batch_results in pool.imap_unordered(
                _worker_tokenize_batch, chunks, chunksize=1,
            ):
                all_token_pairs.extend(batch_results)
                processed += len(batch_results)
                if processed % 100_000 < chunk_size:
                    print(
                        f"[SFTDataset] Tokenized {processed}/{total} "
                        f"({100.0 * processed / total:.1f}%)"
                    )

        # Print final progress if not already printed.
        if processed % 100_000 >= chunk_size:
            print(f"[SFTDataset] Tokenized {processed}/{total} (100.0%)")

        # Convert to tensors and build samples.
        samples: list[tuple[torch.Tensor, torch.Tensor]] = []
        total_tokens = 0
        skipped_too_long = 0
        truncated_count = 0

        for pair in all_token_pairs:
            if pair is None:
                skipped_too_long += 1
                continue

            prompt_ids, response_ids = pair
            full_ids = prompt_ids + response_ids

            # Count truncated: if combined length exactly equals max_seq_len,
            # the worker likely truncated the response.
            if len(full_ids) == max_seq_len:
                truncated_count += 1

            seq_len = len(full_ids)
            total_tokens += seq_len

            input_ids = torch.tensor(full_ids, dtype=torch.int32)
            labels = torch.full((seq_len,), fill_value=-1, dtype=torch.int32)
            resp_start = len(prompt_ids)
            resp_label_start = max(0, resp_start - 1)
            resp_label_end   = resp_label_start + len(response_ids)
            labels[resp_label_start:resp_label_end] = torch.tensor(
                response_ids, dtype=torch.int32
            )
            samples.append((input_ids, labels))

        n = len(samples)
        avg_len = (total_tokens / n) if n > 0 else 0.0
        print(
            f"[SFTDataset] Loaded {n} samples "
            f"(raw={total}, "
            f"skipped_too_long={skipped_too_long}, "
            f"truncated={truncated_count})"
        )
        print(
            f"[SFTDataset] avg_seq_len={avg_len:.1f}, "
            f"max_seq_len={max_seq_len}, "
            f"pad_token_id={self.pad_token_id}, "
            f"eos_token_id={self.eos_token_id}"
        )
        return samples

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _load_jsonl(self, path: Path) -> list[tuple[str, str]]:
        """
        Discover and parse JSONL files, returning (prompt, response) pairs.

        If ``path`` is a file, load that file only.  If it is a directory,
        load all ``*.jsonl`` files found directly inside it (non-recursive).

        Args:
            path: File or directory path.

        Returns:
            List of (prompt_text, response_text) tuples.

        Raises:
            FileNotFoundError: If ``path`` does not exist.
            ValueError:        If no ``.jsonl`` files are found under a
                               directory path.
        """
        if not path.exists():
            raise FileNotFoundError(f"Data path not found: {path}")

        if path.is_dir():
            jsonl_files = sorted(path.glob("*.jsonl"))
            if not jsonl_files:
                raise ValueError(f"No .jsonl files found in directory: {path}")
        else:
            jsonl_files = [path]

        pairs: list[tuple[str, str]] = []
        for jsonl_file in jsonl_files:
            pairs.extend(self._parse_jsonl_file(jsonl_file))

        return pairs

    def _parse_jsonl_file(self, path: Path) -> list[tuple[str, str]]:
        """
        Parse a single JSONL file into (prompt, response) pairs.

        Lines that are empty, whitespace-only, or fail JSON parsing are
        silently skipped with a warning.  Lines whose schema cannot be
        recognised are also skipped.

        Args:
            path: Path to a ``.jsonl`` file.

        Returns:
            List of (prompt_text, response_text) tuples extracted from
            the file.
        """
        pairs: list[tuple[str, str]] = []

        with path.open("r", encoding="utf-8") as fh:
            for lineno, line in enumerate(fh, start=1):
                line = line.strip()
                if not line:
                    continue

                try:
                    obj = json.loads(line)
                except json.JSONDecodeError as exc:
                    print(
                        f"[SFTDataset] WARNING: JSON parse error in "
                        f"{path}:{lineno} — {exc}"
                    )
                    continue

                # ---- Conversation format ------------------------------------
                # Support both "conversations" and "messages" keys
                conv_list = obj.get("conversations") or obj.get("messages")
                if conv_list and isinstance(conv_list, list):
                    turn_pairs = _build_conversation_turns(conv_list)
                    if not turn_pairs:
                        print(
                            f"[SFTDataset] WARNING: No valid user→assistant "
                            f"pairs in {path}:{lineno}, skipping."
                        )
                    pairs.extend(turn_pairs)

                # ---- Alpaca / Alpaca-no-input format -----------------------
                elif "instruction" in obj and "output" in obj:
                    prompt, response = _build_alpaca_turns(
                        instruction=obj["instruction"],
                        input_text=obj.get("input", ""),
                        output=obj["output"],
                    )
                    pairs.append((prompt, response))

                else:
                    print(
                        f"[SFTDataset] WARNING: Unrecognised schema at "
                        f"{path}:{lineno}, skipping."
                    )

        return pairs

    # ------------------------------------------------------------------
    # Dataset interface
    # ------------------------------------------------------------------

    def __len__(self) -> int:
        """Return the number of valid samples in the dataset."""
        return len(self.samples)

    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
        """
        Return a single training sample.

        Args:
            idx: Sample index.

        Returns:
            Tuple ``(input_ids, labels)`` where both tensors have shape
            ``[seq_len]`` (variable per sample) and dtype ``torch.long``.
            Use a collate function to pad batches dynamically.

            - ``input_ids``: Full token sequence (prompt + response),
              NO padding (raw length).
            - ``labels``:    Response token ids at response positions,
              ``-1`` everywhere else (prompt tokens).
              Use ``ignore_index=-1`` in your loss function.
        """
        input_ids, labels = self.samples[idx]
        return input_ids.long(), labels.long()
