import argparse
import ast
import json
import os
from typing import List

import matplotlib.pyplot as plt
import numpy as np
import torch
from matplotlib.colors import LogNorm
from peft import PeftModel
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer

from lib.tokenize_data_mask import apply_chat_with_tokenize_with_mark


CUSTOM_MASK_IDENTIFIER = {
    "data": ["<data>", "</data>"],
    "inst": ["<inst>", "</inst>"],
}


def load_message_list(path: str) -> List[dict]:
    with open(path, "r", encoding="utf-8") as f:
        raw_text = f.read().strip()

    if not raw_text:
        raise ValueError(f"Input file is empty: {path}")

    candidates = []

    try:
        parsed = json.loads(raw_text)
        candidates.append(parsed)
    except json.JSONDecodeError:
        pass

    try:
        parsed = ast.literal_eval(raw_text)
        candidates.append(parsed)
    except Exception:
        pass

    jsonl_items = []
    jsonl_ok = True
    for line in raw_text.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            jsonl_items.append(json.loads(line))
        except json.JSONDecodeError:
            jsonl_ok = False
            break
    if jsonl_ok and jsonl_items:
        candidates.append(jsonl_items)

    for candidate in candidates:
        if isinstance(candidate, dict) and isinstance(candidate.get("messages"), list):
            candidate = candidate["messages"]
        if isinstance(candidate, list) and all(isinstance(item, dict) for item in candidate):
            return candidate

    raise ValueError(
        "Input must be a message list: JSON array of {role, content}, JSON object with `messages`, "
        "or JSONL with one message object per line."
    )


def load_model_and_tokenizer(model_path: str, lora_path: str):
    if not os.path.isdir(model_path):
        raise FileNotFoundError(f"Model path not found or not a directory: {model_path}")
    if lora_path and not os.path.isdir(lora_path):
        raise FileNotFoundError(f"LoRA path not found or not a directory: {lora_path}")

    cfg = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
    tok = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
    if tok.pad_token_id is None:
        tok.pad_token = tok.eos_token
        tok.pad_token_id = tok.eos_token_id
        tok.padding_side = "right"

    model = AutoModelForCausalLM.from_pretrained(
        model_path,
        config=cfg,
        device_map="auto",
        torch_dtype=torch.bfloat16,
        trust_remote_code=True,
        attn_implementation="eager",
    )
    if lora_path:
        model = PeftModel.from_pretrained(model, lora_path, device_map="auto")
        model = model.merge_and_unload()

    model.eval()
    return model, tok


def load_head_order(path: str) -> List[str]:
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)

    if not isinstance(data, list):
        raise ValueError(f"Head-order JSON must contain a list: {path}")

    ordered_heads = []
    for item in data:
        if isinstance(item, list) and item:
            ordered_heads.append(str(item[0]))
        else:
            ordered_heads.append(str(item))
    return ordered_heads


@torch.inference_mode()
def collect_prompt_and_attention(model, tok, messages: List[dict]):
    (
        input_ids,
        instruction_mask,
        _data_mask,
        segment_type,
        _is_normal_token,
        custom_mask,
        _rendered_prompt,
    ) = apply_chat_with_tokenize_with_mark(
        messages,
        tok,
        custom_mask_identifier=CUSTOM_MASK_IDENTIFIER,
        add_generation_prompt=True,
    )

    input_ids_tensor = torch.tensor([input_ids], dtype=torch.long, device=model.device)
    attention_mask = torch.ones_like(input_ids_tensor)
    out = model(
        input_ids=input_ids_tensor,
        attention_mask=attention_mask,
        output_attentions=True,
    )

    n_layers = len(out.attentions)
    n_heads = out.attentions[0].shape[1]
    token_len = len(input_ids)

    attn_rows = []
    head_names = []
    for layer_idx in range(n_layers):
        layer_attn = out.attentions[layer_idx][0]
        for head_idx in range(n_heads):
            head_names.append(f"L{layer_idx}_H{head_idx}")
            attn_rows.append(layer_attn[head_idx].to(torch.float32).cpu().numpy()[-1, :])

    attn_matrix = np.array(attn_rows, dtype=np.float32).T
    if attn_matrix.shape[0] != token_len:
        raise ValueError(
            f"Attention/token mismatch: attn rows={attn_matrix.shape[0]} tokens={token_len}"
        )

    user_mask = [seg == "usr" for seg in segment_type]
    inst_mask = [cust == "inst" for cust in custom_mask]
    instr_mask = [bool(v) for v in instruction_mask]

    tokens = [
        tok.decode([token_id], skip_special_tokens=False).replace("\n", "\\n")
        for token_id in input_ids
    ]

    return tokens, inst_mask, instr_mask, user_mask, head_names, attn_matrix


def reorder_heads(head_names: List[str], attn_matrix: np.ndarray, requested_order: List[str]):
    index_by_head = {name: idx for idx, name in enumerate(head_names)}
    ordered_indices = []
    seen = set()

    for head_name in requested_order:
        idx = index_by_head.get(head_name)
        if idx is None or idx in seen:
            continue
        ordered_indices.append(idx)
        seen.add(idx)

    for idx, head_name in enumerate(head_names):
        if idx in seen:
            continue
        ordered_indices.append(idx)

    reordered_head_names = [head_names[idx] for idx in ordered_indices]
    reordered_attn_matrix = attn_matrix[:, ordered_indices]
    return reordered_head_names, reordered_attn_matrix


def render_image(
    tokens: List[str],
    inst_mask: List[bool],
    instr_mask: List[bool],
    user_mask: List[bool],
    head_names: List[str],
    attn_matrix: np.ndarray,
    output_path: str,
    title: str,
):
    token_len = len(tokens)
    num_heads = len(head_names)

    text_panel_width = 4.0
    width_per_head = 0.25
    colorbar_pad = 1.5

    heatmap_width = max(2.0, num_heads * width_per_head)
    total_width = text_panel_width + heatmap_width + colorbar_pad
    total_height = max(8, token_len * 0.18)

    fig = plt.figure(figsize=(total_width, total_height))
    gs = fig.add_gridspec(1, 2, width_ratios=[text_panel_width, heatmap_width], wspace=0.05)

    ax_text = fig.add_subplot(gs[0, 0])
    ax_heat = fig.add_subplot(gs[0, 1])

    ax_text.set_axis_off()
    ax_text.set_xlim(0, 1)
    ax_text.set_ylim(token_len - 0.5, -0.5)
    ax_text.text(
        0.0,
        -1.0,
        "idx  inst  instr  user  token",
        fontsize=9,
        fontfamily="monospace",
        fontweight="bold",
    )

    for i, (token, im, inrm, um) in enumerate(zip(tokens, inst_mask, instr_mask, user_mask)):
        bg_color = "white"
        if um:
            bg_color = "#e6f2ff"
        elif inrm:
            bg_color = "#fff2e6"
        elif im:
            bg_color = "#f2ffe6"

        ax_text.text(
            0.0,
            i,
            f"{i:03d}  {int(im):4d}  {int(inrm):5d}  {int(um):4d}  {token}",
            fontsize=9,
            fontfamily="monospace",
            va="center",
            bbox=dict(facecolor=bg_color, edgecolor="none", pad=1),
        )

    positive_values = attn_matrix[attn_matrix > 0]
    vmin = float(max(1e-6, positive_values.min())) if positive_values.size else 1e-6
    vmax = float(max(1.0, attn_matrix.max()))
    norm = LogNorm(vmin=vmin, vmax=vmax)

    im = ax_heat.imshow(
        attn_matrix,
        aspect="auto",
        interpolation="nearest",
        cmap="viridis",
        norm=norm,
    )
    ax_heat.set_yticks(range(token_len))
    ax_heat.set_yticklabels([f"{i:03d}" for i in range(token_len)], fontsize=7)

    max_ticks = 40
    step = max(1, num_heads // max_ticks)
    indices = range(0, num_heads, step)
    labels = [head_names[i] for i in indices]

    ax_heat.set_xticks(list(indices))
    ax_heat.set_xticklabels(labels, rotation=90, ha="center", fontsize=8)
    ax_heat.set_xlabel(f"Top {num_heads} Heads")
    ax_heat.set_ylabel("Tokens")

    cbar = fig.colorbar(im, ax=ax_heat, fraction=0.046, pad=0.04)
    cbar.set_label("Attention Weight (log scale)")

    fig.suptitle(title, y=0.99, fontsize=12)
    os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
    fig.savefig(output_path, bbox_inches="tight", dpi=150)
    plt.close(fig)


def main():
    parser = argparse.ArgumentParser(
        description="Visualize last-token attention for one arbitrary message list."
    )
    parser.add_argument("--model-path", required=True)
    parser.add_argument("--lora-path", default="")
    parser.add_argument("--input-path", required=True, help="Message list file")
    parser.add_argument("--output-path", default="head_heatmap_any.png")
    parser.add_argument("--head-order-path", default="")
    parser.add_argument("--title", default="")
    args = parser.parse_args()

    messages = load_message_list(args.input_path)
    model, tok = load_model_and_tokenizer(args.model_path, args.lora_path)
    tokens, inst_mask, instr_mask, user_mask, head_names, attn_matrix = collect_prompt_and_attention(
        model, tok, messages
    )
    if args.head_order_path:
        requested_order = load_head_order(args.head_order_path)
        head_names, attn_matrix = reorder_heads(head_names, attn_matrix, requested_order)

    title = args.title.strip()
    if not title:
        model_name = os.path.basename(os.path.normpath(args.model_path))
        lora_name = os.path.basename(os.path.normpath(args.lora_path)) if args.lora_path else "base"
        title = f"Model: {model_name} | LoRA: {lora_name}"

    render_image(
        tokens=tokens,
        inst_mask=inst_mask,
        instr_mask=instr_mask,
        user_mask=user_mask,
        head_names=head_names,
        attn_matrix=attn_matrix,
        output_path=args.output_path,
        title=title,
    )
    print(f"Saved image to: {args.output_path}")


if __name__ == "__main__":
    main()
