# Copyright (c) ModelScope Contributors. All rights reserved.
"""Backend-agnostic GKD teacher / encoding helpers.

These functions are stateless: they operate on ``GKDSample`` objects and a
``Template`` and return encoded dicts / teacher requests / assembled teacher
outputs.  Shared by the HF and Megatron GKD trainers.
"""
import torch
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

from swift.rl_core.data import GKDSample, OnPolicySample
from swift.template.base import Template
from swift.utils import get_cu_seqlens_from_position_ids, get_logger, json_parse_to_dict
from .gkd_loss import TeacherOutput
from .utils import (assemble_teacher_topk_logprobs, encode_sample, get_response_prefix_ids,
                    replace_assistant_response_with_ids)

logger = get_logger()


def encode_teacher_view(
    sample: OnPolicySample,
    template: Template,
) -> Dict[str, Any]:
    """Encode the OPSD teacher view (teacher_prompt + the shared on-policy response).

    Does NOT mutate ``sample.teacher_messages`` — works on copied message dicts
    so the sample's teacher view can be re-encoded across steps_per_generation.
    Caller must have invoked ``sample.build_teacher_view()`` first.
    """
    teacher_row = sample.to_teacher_template_dict()
    msgs = teacher_row.get('messages')
    if msgs is not None:
        teacher_row['messages'] = [m.copy() for m in msgs]
    if teacher_row.get('response_token_ids'):
        loss_mask = teacher_row.get('response_loss_mask') or None
        ctk = sample.extra.get('chat_template_kwargs') or {}
        sample_et = ctk.get('enable_thinking')
        prefix_ids = get_response_prefix_ids(template, sample_enable_thinking=sample_et)
        teacher_row['messages'] = replace_assistant_response_with_ids(
            teacher_row['messages'],
            teacher_row['response_token_ids'],
            loss_mask=loss_mask,
            non_thinking_prefix_ids=prefix_ids)
    teacher_encoded = template.encode(teacher_row, return_length=True)
    teacher_encoded.pop('_extra_kwargs', None)
    return teacher_encoded


def encode_gkd_samples(
    samples: List[GKDSample],
    template: Template,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], bool]:
    """Encode student and (OPSD) teacher views for a list of GKDSample objects.

    Returns ``(student_encoded_list, teacher_encoded_list, has_opsd)``.
    Trainers are responsible for collation and device placement.
    """
    student_encoded_list: List[Dict[str, Any]] = []
    teacher_encoded_list: List[Dict[str, Any]] = []
    # Cannot use any() here because it short-circuits: build_teacher_view()
    # must be called for ALL samples to populate teacher_messages on each.
    has_opsd = False
    for s in samples:
        if s.build_teacher_view():
            has_opsd = True

    for s in samples:
        encoded = encode_sample(s, template)
        encoded.pop('_extra_kwargs', None)
        student_encoded_list.append(encoded)

        if has_opsd:
            teacher_encoded = encode_teacher_view(s, template)
            teacher_encoded_list.append(teacher_encoded)
        else:
            teacher_encoded_list.append(encoded.copy())

    return student_encoded_list, teacher_encoded_list, has_opsd


def build_teacher_requests(samples: List[OnPolicySample], template: Optional[Template] = None) -> List[Any]:
    """Build teacher API requests from samples (GKD or GRPO/OPD-RL).

    For GKD OPSD samples, ``teacher_messages`` replaces the request messages;
    GRPO samples have no OPSD view, so the on-policy request is used as-is.

    When ``template`` is given and the sample carries ``response_token_ids``, the assistant
    response is sent as raw token ids (mirroring :func:`encode_sample`) so the teacher server
    forwards the *same* tokens the student sampled instead of re-tokenizing the decoded text.
    Re-tokenization can split the response into a different number of tokens, breaking the
    token-in-token-out alignment the teacher KL relies on. Teacher and student must share the
    tokenizer/vocabulary (a prerequisite of distillation) for the ids to be meaningful.
    """
    requests = []
    for s in samples:
        req = s.to_infer_request()
        # OPSD: score the teacher on its privileged prompt instead of the student prompt.
        teacher_messages = getattr(s, 'teacher_messages', None)
        messages = teacher_messages if teacher_messages else req.messages
        if template is not None and s.response_token_ids:
            # Send the sampled response as raw token ids (token-in-token-out) so the teacher server
            # forwards the *same* tokens the student sampled instead of re-tokenizing the decoded text.
            # Re-tokenization can split the response into a different number of tokens, breaking the
            # alignment the teacher KL relies on. Applies to both the on-policy prompt (GRPO/OPD-RL)
            # and the OPSD teacher prompt (which shares the same response tokens).
            loss_mask = s.response_loss_mask or None
            ctk = s.extra.get('chat_template_kwargs') or {}
            prefix_ids = get_response_prefix_ids(template, sample_enable_thinking=ctk.get('enable_thinking'))
            messages = replace_assistant_response_with_ids([m.copy() for m in messages],
                                                           s.response_token_ids,
                                                           loss_mask,
                                                           non_thinking_prefix_ids=prefix_ids)
        req.messages = messages
        requests.append(req)
    return requests


def assemble_teacher_output(
    parsed: List[Tuple],
    teacher_model_inputs: Dict[str, torch.Tensor],
    topk: Optional[int],
    template_padding_free: bool,
    device: torch.device,
) -> TeacherOutput:
    """Build a full-sequence ``TeacherOutput`` (GKD) from parsed teacher prompt logprobs.

    Produces a ``[B, S, K]`` top-k frame aligned to the teacher's full input sequence,
    masked later by ``labels`` in :func:`gkd_loss`. Handles both packed (padding_free)
    and non-packed layouts, including mrope / position-id based cu_seqlens detection.
    GRPO/OPD-RL uses :func:`assemble_teacher_completion_logprobs` instead (completion frame).
    """
    K = topk or 1
    input_ids = teacher_model_inputs['input_ids']
    batch_size, seq_len = input_ids.shape

    cu_seqlens = None
    offsets = None
    if template_padding_free:
        cu_seqlens = [0]
        for lps, ixs in parsed:
            cu_seqlens.append(cu_seqlens[-1] + len(lps) + 1)
        trainer_seq_lens = teacher_model_inputs.get('cu_seq_lens_q')
        if trainer_seq_lens is None:
            position_ids = teacher_model_inputs.get('text_position_ids')
            if position_ids is None:
                position_ids = teacher_model_inputs.get('position_ids')
            if position_ids is not None:
                trainer_seq_lens = get_cu_seqlens_from_position_ids(position_ids)
        if trainer_seq_lens is not None and cu_seqlens[-1] != int(trainer_seq_lens[-1]):
            logger.warning('The number of tokens returned by the teacher server differs from that of the trainer. '
                           'This may be caused by non-aligned processing.')
    else:
        attention_mask = teacher_model_inputs.get('attention_mask')
        if attention_mask is not None:
            offsets = []
            for i in range(attention_mask.shape[0]):
                nz = attention_mask[i].nonzero().flatten()
                offsets.append(int(nz[0]) if nz.numel() else 0)
        else:
            offsets = None

    topk_logprobs, topk_indices = assemble_teacher_topk_logprobs(
        parsed,
        batch_size=batch_size,
        seq_len=seq_len,
        cu_seqlens=cu_seqlens,
        topk=K,
        device=device,
        offsets=offsets,
    )

    teacher_out = TeacherOutput(topk_logprobs=topk_logprobs, topk_indices=topk_indices)
    if 'labels' in teacher_model_inputs:
        teacher_out.labels = teacher_model_inputs['labels']
    return teacher_out


def assemble_teacher_completion_logprobs(
    parsed: List[Tuple],
    completion_mask: torch.Tensor,
    device: torch.device,
    response_token_ids: Optional[List[List[int]]] = None,
) -> TeacherOutput:
    """Align parsed teacher *sampled-token* prompt logprobs to a ``[B, T, 1]`` completion frame (GRPO/OPD-RL).

    ``parsed[i] = (lps, ixs)`` come from :func:`parse_prompt_logprobs` with ``topk=0``,
    i.e. the sampled token's logp/id at each position. They are placed on
    ``completion_mask``'s response region.

    When the teacher API returns logprobs for the **full sequence** (prompt + response
    + end tokens), ``response_token_ids`` is used to locate the response portion by
    matching token IDs from the end of the sequence (the response is the last
    ``count`` tokens before the end tokens).
    """
    batch_size, seq_len = completion_mask.shape
    out_lp = torch.zeros(batch_size, seq_len, 1, dtype=torch.float32, device=device)
    out_ix = torch.zeros(batch_size, seq_len, 1, dtype=torch.long, device=device)
    for i, (lps, ixs) in enumerate(parsed):
        completion_indices = completion_mask[i].nonzero(as_tuple=True)[0]
        count = completion_indices.numel()
        if count == 0:
            continue
        if len(lps) == count + 1:
            lps, ixs = lps[:count], ixs[:count]
        elif len(lps) > count + 1 and response_token_ids is not None:
            # Teacher returned logprobs for the full sequence (prompt + response + end tokens).
            # Locate the response portion by matching token IDs from the end.
            rti = response_token_ids[i]
            if isinstance(rti[0], list):
                rti = rti[0]  # flatten [[1,2,3]] -> [1,2,3]
            flat_ixs = [ix[0] for ix in ixs]  # each ix is a single-element list [token_id]
            # Search from the end: find the starting index where the last `count` tokens
            # match the response token IDs (allowing 0-2 end tokens after the response).
            start = None
            for offset in range(min(3, len(lps) - count + 1)):  # try 0, 1, 2 end tokens
                candidate_start = len(flat_ixs) - count - offset
                if candidate_start < 0:
                    continue
                if flat_ixs[candidate_start:candidate_start + count] == rti[:count]:
                    start = candidate_start
                    break
            if start is not None:
                lps = lps[start:start + count]
                ixs = ixs[start:start + count]
            else:
                # The response token ids did not align anywhere in the returned sequence: the teacher
                # tokenized differently than the student, so any slice would yield a meaningless
                # per-token KL. Fail loudly rather than silently mis-aligning.
                raise ValueError(
                    f'Teacher returned {len(lps)} logprobs but could not locate the {count} sampled response '
                    'tokens by id. The teacher server must use the same tokenizer as the student '
                    '(token-in-token-out).')
        assert len(lps) == count, (f'Teacher logp count {len(lps)} != sampled tokens {count}. The teacher server '
                                   'must use the same tokenizer as the student (token-in-token-out).')
        # lps/ixs are per-position single-element lists ([[lp], ...]) -> [count, 1].
        out_lp[i, completion_indices] = torch.tensor(lps, dtype=torch.float32, device=device)
        out_ix[i, completion_indices] = torch.tensor(ixs, dtype=torch.long, device=device)
    return TeacherOutput(topk_logprobs=out_lp, topk_indices=out_ix)


@dataclass
class TeacherServerConfig:
    """Configuration for a single teacher model server.

    Args:
        url: Teacher server URL (e.g., 'http://localhost:8000').
        tags: Tags this teacher handles. With multiple teachers each sample is routed to
            exactly one teacher by tag. Empty only for the single-teacher case (one teacher
            handles all samples). All teachers share the global ``--teacher_kl_coef``.
    """
    url: str
    tags: List[str] = field(default_factory=list)


def parse_teacher_model_server(val: Optional[str]) -> Optional[List[TeacherServerConfig]]:
    """Parse ``teacher_model_server``: single URL string or JSON multi-teacher config.

    Accepts two formats:
    - Single URL (backward compatible): ``'http://localhost:8000'`` -> one teacher, no tags,
      handles all samples.
    - Multi-teacher JSON: ``'[{"url":"http://localhost:8000","tags":["math"]}, ...]'``.
      With multiple teachers each entry MUST have non-empty ``tags`` and tags MUST NOT overlap
      across teachers (each sample routes to exactly one teacher).

    Returns ``None`` when *val* is ``None``. Otherwise returns a list of
    :class:`TeacherServerConfig` (always a list, even for single teacher).
    """
    if val is None:
        return None
    val = val.strip()
    if not val.startswith('['):
        return [TeacherServerConfig(url=val, tags=[])]

    # JSON list of teachers: reuse swift's shared JSON parser (repairs malformed JSON), matching
    # how other JSON-valued args (e.g. model_kwargs) are parsed rather than a bespoke json.loads.
    configs_raw = json_parse_to_dict(val)
    if not isinstance(configs_raw, list):
        raise ValueError(f'teacher_model_server must be a JSON list of {{url, tags}}, got {type(configs_raw)}.')
    if not configs_raw:
        raise ValueError('teacher_model_server JSON list is empty.')
    configs = []
    for i, c in enumerate(configs_raw):
        if not isinstance(c, dict):
            raise ValueError(f'teacher_model_server[{i}] must be a dict, got {type(c)}.')
        url = c.get('url')
        if not url:
            raise ValueError(f'teacher_model_server[{i}]: "url" is required.')
        tags = c.get('tags', [])
        if not isinstance(tags, list):
            raise ValueError(f'teacher_model_server[{i}]: "tags" must be a list, got {type(tags)}.')
        # get_tag returns str, so normalize tags to str for consistent matching (a numeric dataset
        # name would otherwise never match the routing tag).
        tags = [str(t) for t in tags]
        configs.append(TeacherServerConfig(url=url, tags=tags))

    if len(configs) > 1:
        seen = {}
        for i, cfg in enumerate(configs):
            if not cfg.tags:
                raise ValueError(f'teacher_model_server[{i}]: "tags" must be non-empty with multiple teachers '
                                 '(each sample routes to exactly one teacher).')
            for tag in cfg.tags:
                if tag in seen:
                    raise ValueError(f'teacher_model_server: tag "{tag}" appears in both teacher[{seen[tag]}] '
                                     f'and teacher[{i}]; tags must not overlap.')
                seen[tag] = i
    return configs


def route_samples_to_teachers(
    samples: List[OnPolicySample],
    teacher_configs: List[TeacherServerConfig],
    tag_key: str = 'dataset',
) -> Dict[int, List[int]]:
    """Route each sample to exactly one teacher, returning ``{teacher_index: [sample_indices]}``.

    Single teacher: all samples route to it (tags ignored). Multiple teachers: route by tag;
    ``parse_teacher_model_server`` guarantees tags are non-empty and non-overlapping, so a tag
    maps to at most one teacher. Fails fast when a sample's tag matches no teacher.
    """
    routing: Dict[int, List[int]] = {i: [] for i in range(len(teacher_configs))}
    if len(teacher_configs) == 1:
        routing[0] = list(range(len(samples)))
        return routing

    tag_to_teacher = {tag: t_idx for t_idx, cfg in enumerate(teacher_configs) for tag in cfg.tags}
    for s_idx, sample in enumerate(samples):
        tag = sample.get_tag(tag_key)
        t_idx = tag_to_teacher.get(tag)
        if t_idx is None:
            raise ValueError(f'sample[{s_idx}] tag {tag!r} (from "{tag_key}") matches no teacher; '
                             f'available tags: {sorted(tag_to_teacher)}')
        routing[t_idx].append(s_idx)
    return routing


def fetch_teacher_parsed_by_routing(
    samples: List[OnPolicySample],
    requests: List[Any],
    teacher_configs: List[TeacherServerConfig],
    teacher_clients: List[Any],
    fetch_fn=None,
    tag_key: str = 'dataset',
    *,
    gather_fn=None,
    infer_fn=None,
    scatter_fn=None,
    is_main_process: bool = False,
) -> List[Any]:
    routing = route_samples_to_teachers(samples, teacher_configs, tag_key=tag_key)
    num_teachers = len(teacher_configs)

    def subset_requests(t_idx):
        return [requests[i] for i in routing[t_idx]]

    def client_for(t_idx):
        # teacher_clients is empty on non-main ranks (client is only used during infer on the main
        # process); pass None there and let the fetcher resolve its default.
        return teacher_clients[t_idx] if t_idx < len(teacher_clients) else None

    parsed: List[Any] = [None] * len(requests)

    def scatter_back(t_idx, subset):
        for local_i, s_idx in enumerate(routing[t_idx]):
            parsed[s_idx] = subset[local_i]

    if gather_fn is not None:
        # Phase A: gather every teacher's requests (serial collectives, same order on all ranks).
        handles = [gather_fn(subset_requests(t_idx)) for t_idx in range(num_teachers)]
        # Phase B: infer concurrently on the main process (HTTP to distinct teacher servers).
        parsed_globals: List[Any] = [None] * num_teachers
        if is_main_process:

            def _handle_requests(handle):
                if isinstance(handle, dict):
                    return handle.get('all_requests', handle)
                return handle

            def _infer_or_empty(t_idx):
                handle = handles[t_idx]
                if not _handle_requests(handle):
                    return []
                return infer_fn(handle, client_for(t_idx))

            if num_teachers == 1:
                parsed_globals[0] = _infer_or_empty(0)
            else:
                from concurrent.futures import ThreadPoolExecutor
                with ThreadPoolExecutor(max_workers=num_teachers) as pool:
                    futures = {
                        pool.submit(_infer_or_empty, t): t
                        for t in range(num_teachers) if _handle_requests(handles[t])
                    }
                    for t in range(num_teachers):
                        if not _handle_requests(handles[t]):
                            parsed_globals[t] = []
                    for fut in futures:
                        parsed_globals[futures[fut]] = fut.result()
        # Phase C: broadcast + slice each teacher's result back (serial collectives, rank-ordered).
        for t_idx in range(num_teachers):
            scatter_back(t_idx, scatter_fn(handles[t_idx], parsed_globals[t_idx]))
        return parsed

    for t_idx in range(num_teachers):
        scatter_back(t_idx, fetch_fn(subset_requests(t_idx), client_for(t_idx)))
    return parsed


def resolve_dynamic_opd_self_distillation(
    *,
    has_teacher_explicit: bool,
    is_self_distillation: bool,
) -> bool:
    if has_teacher_explicit or not is_self_distillation:
        return False
    return True


def should_compute_local_teacher_logps(
    *,
    has_teacher_explicit: bool,
    is_dynamic_self_distillation: bool,
    use_teacher_api: bool,
    has_opsd_batch: bool,
) -> bool:
    """Per-step gate for a local (non-API) teacher forward in OPD-RL."""
    if use_teacher_api:
        return False
    if has_teacher_explicit:
        return True
    return is_dynamic_self_distillation and has_opsd_batch


def build_opsd_samples(samples: List[OnPolicySample]) -> bool:
    """Build each sample's OPSD teacher view; return True if ANY sample is OPSD.

    ``build_teacher_view`` must run for every sample (not short-circuited by ``any()``)
    so ``teacher_messages`` is populated wherever ``teacher_prompt`` is set.
    """
    has_opsd = False
    for s in samples:
        if s.build_teacher_view():
            has_opsd = True
    return has_opsd


def remap_teacher_logps_to_student_frame(
    teacher_logps: torch.Tensor,
    teacher_completion_mask: torch.Tensor,
    student_completion_mask: torch.Tensor,
) -> torch.Tensor:
    """Move per-token teacher logps from the OPSD teacher frame to the student frame.

    OPSD teacher and student share the *same* response tokens (same ids, same order)
    but different prompts, so their completion regions sit at different positions and
    the sequences differ in length. The OPD-RL advantage compares teacher vs. student
    logps position-by-position on the student ``completion_mask`` frame, so the teacher
    logps (returned aligned to the teacher frame) must be scattered onto the student
    frame: for each sample, the ``count`` valid teacher-completion values are placed at
    the student-completion positions in order. Returns ``[B, T_student]``.
    """
    out = torch.zeros_like(student_completion_mask, dtype=teacher_logps.dtype)
    for i in range(student_completion_mask.shape[0]):
        s_idx = student_completion_mask[i].nonzero(as_tuple=True)[0]
        t_idx = teacher_completion_mask[i].nonzero(as_tuple=True)[0]
        assert s_idx.numel() == t_idx.numel(), (
            f'OPSD response length mismatch at sample {i}: student={s_idx.numel()} teacher={t_idx.numel()}. '
            'Teacher and student must share the same response tokens.')
        out[i, s_idx] = teacher_logps[i, t_idx]
    return out
