# coding: utf-8

import os, json, argparse, importlib.util, re
from pathlib import Path
from functools import lru_cache
import torch
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from tqdm import tqdm
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
from peft import PeftModel  # LoRA support

def find_subsequence(full, sub):
    n, m = len(full), len(sub)
    if m == 0 or m > n:
        return -1
    for i in range(n - m + 1):
        if full[i : i + m] == sub:
            return i
    return -1

@lru_cache(maxsize=None)
def clean_token(tok: str) -> str:
    return tok.lstrip("▁")

def build_token_ranges(tokenizer, full_ids, sys_ids, usr_ids):
    # First try exact sub-sequence match
    s0 = find_subsequence(full_ids, sys_ids)
    if s0 != -1:
        s1 = s0 + len(sys_ids) - 1
        if usr_ids:
            u0 = find_subsequence(full_ids, usr_ids)
            u1 = u0 + len(usr_ids) - 1 if u0 != -1 else None
            usr_range = (u0, u1) if u0 != -1 else None
        else:
            usr_range = None
        return (s0, s1), usr_range

    # Try known chat templates
    tid = tokenizer.convert_tokens_to_ids
    start_header = tid("<|start_header_id|>")
    end_header   = tid("<|end_header_id|>")
    eot_id       = tokenizer.eos_token_id
    sys_tok      = tid("<|system|>")
    end_tok      = tid("<|end|>")
    im_start     = tid("<|im_start|>")
    im_end       = tid("<|im_end|>")
    inst_start   = tid("[INST]")
    inst_end     = tid("[/INST]")
    row = full_ids

    def find_token_range(row, start_token, end_token, start_offset=1):
        try:
            s = row.index(start_token) + start_offset
            e = row.index(end_token, s)
            return s, e
        except ValueError:
            return None

    if start_header in row:
        try:
            s = row.index(end_header) + 1
            e = row.index(eot_id)
            return (s, e), None
        except ValueError:
            pass

    if sys_tok in row:
        try:
            s = row.index(sys_tok) + 1
            e = row.index(end_tok, s)
            return (s, e), None
        except ValueError:
            pass

    if im_start in row and im_end in row:
        for pos in [i for i, t in enumerate(row) if t == im_start]:
            if pos + 1 < len(row) and tokenizer.decode([row[pos + 1]]).strip() == "system":
                s = pos + 2
                try:
                    e = row.index(im_end, s)
                    return (s, e), None
                except ValueError:
                    pass

    if inst_start in row and inst_end in row:
        try:
            ist = row.index(inst_start) + 1
            iend = row.index(inst_end)
            split = None
            for i in range(ist, iend - 1):
                if row[i] == eot_id and row[i + 1] == eot_id:
                    split = i
                    break
            if split is None:
                for i in range(ist, iend):
                    if tokenizer.decode([row[i]]).isspace():
                        split = i
                        break
            if split and ist < split:
                return (ist, split), (split + 1, iend)
            else:
                return (ist, iend), None
        except ValueError:
            pass

    # Fallback
    s0 = find_subsequence(row, sys_ids)
    s1 = s0 + len(sys_ids) - 1 if s0 != -1 else -1
    u0 = find_subsequence(row, usr_ids) if usr_ids else -1
    u1 = u0 + len(usr_ids) - 1 if u0 != -1 else -1
    sys_range = (s0, s1) if s0 != -1 else None
    usr_range = (u0, u1) if u0 != -1 else None
    return sys_range, usr_range

def load_important_heads(path):
    suffix = Path(path).suffix.lower()
    if suffix == ".py":
        spec = importlib.util.spec_from_file_location("viz_heads_cfg", path)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)  # type: ignore[attr-defined]
        raw_heads = getattr(module, "HEADS", None)
        if raw_heads is None:
            raise ValueError(f"HEADS not defined in {path}")
        head_list = raw_heads
    else:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        head_list = data.get("important_heads")
        if head_list is None:
            raise ValueError(f"important_heads missing in {path}")

    pairs, tags = [], []
    for entry in head_list:
        if isinstance(entry, (list, tuple)) and len(entry) == 2:
            tag, _ = entry
        elif isinstance(entry, dict) and "tag" in entry:
            tag = entry["tag"]
        else:
            raise ValueError(f"Invalid head entry: {entry}")
        l = int(tag.split("_")[0][1:])
        h = int(tag.split("_")[1][1:])
        pairs.append((l, h))
        tags.append(tag)
    return pairs, tags

def last_token_selected_heads(attentions, selected_pairs):
    S = attentions[0].shape[-1]
    last = S - 1
    rows = []
    for (l, h) in selected_pairs:
        vec = attentions[l][0, h, last, :].to(torch.float32).cpu().numpy()
        rows.append(vec)
    return np.stack(rows)

def average_heads_last_token(attentions):
    L = len(attentions)
    S = attentions[0].shape[-1]
    last = S - 1
    mat = np.zeros((L, S), dtype=np.float32)
    for l, attn in enumerate(attentions):
        mat[l] = attn[0, :, last, :].mean(dim=0).to(torch.float32).cpu().numpy()
    return mat

def plot_heatmap(mat, tokens, row_labels, out_path, title):
    from matplotlib.colors import LinearSegmentedColormap
    custom_cmap = LinearSegmentedColormap.from_list("custom_red", ["#FEFFDA", "#CC3F39"], N=256)
    cbar_font = {'size': 18}
    xtick_font = {'fontsize': 10}
    ytick_font = {'fontsize': 10}

    plt.figure(figsize=(max(6, mat.shape[0] * 0.6), max(4, len(tokens) * 0.35)))
    ax = sns.heatmap(
        mat.T,
        cmap=custom_cmap,
        vmin=0.0,
        vmax=1,
        xticklabels=row_labels,
        yticklabels=[clean_token(t) for t in tokens],
        cbar_kws={"label": "Attention Score", "format": '%.2f'}
    )
    ax.set_xlabel("Important Heads (x)")
    ax.set_ylabel("Input Tokens (y)")
    ax.set_title(title, fontsize=14)
    ax.tick_params(axis='x', labelsize=xtick_font["fontsize"])
    ax.tick_params(axis='y', labelsize=ytick_font["fontsize"])
    cbar = ax.collections[0].colorbar
    cbar.ax.tick_params(labelsize=cbar_font["size"])
    cbar.set_label("Attention Score", fontsize=cbar_font["size"])

    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close()
    print(f"✅ Saved heatmap to: {out_path}")


def visualize_samples(model, tokenizer, samples, out_dir, device, selected_pairs, head_tags, prefix=""):
    os.makedirs(out_dir, exist_ok=True)
    has_selected = bool(selected_pairs)
    prefix = (prefix or "").strip()
    fname_prefix = f"{prefix}_" if prefix else ""

    for sample in tqdm(samples, desc=f"Processing Samples → {Path(out_dir).name}"):
        sys_msg = sample["system_message"]
        usr_msg = sample.get("user_message", "") or ""
        messages = [{"role": "system", "content": sys_msg}]
        if usr_msg.strip():
            messages.append({"role": "user", "content": usr_msg})

        with torch.no_grad():
            chat_input = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
            inputs = tokenizer(chat_input, return_tensors="pt")
            if device is not None:
                inputs = {k: v.to(device) for k, v in inputs.items()}

        sys_ids = tokenizer(sys_msg, add_special_tokens=False)["input_ids"]
        usr_ids = tokenizer(usr_msg, add_special_tokens=False)["input_ids"] if usr_msg.strip() else []
        full_ids = inputs["input_ids"][0].tolist()
        sys_range, usr_range = build_token_ranges(tokenizer, full_ids, sys_ids, usr_ids)
        s0, s1 = sys_range

        with torch.no_grad():
            outputs = model(**inputs, output_attentions=True)

        mat = average_heads_last_token(outputs.attentions)
        tokens = [tokenizer.decode([t]) for t in full_ids]
        wanted_idx = list(range(len(tokens)))
        sub_mat = mat[:, wanted_idx]
        sub_tokens = [tokens[i] for i in wanted_idx]

        sample_id = sample.get('id', 'unknown')
        title = f"Last-Token → System/User Tokens  (sample id: {sample_id})"

        row_labels = head_tags if has_selected else [f"L{l}" for l in range(mat.shape[0])]
        plot_heatmap(
            sub_mat,
            sub_tokens,
            row_labels,
            os.path.join(out_dir, f"{fname_prefix}{sample_id}_attn_map.png"),
            title,
        )

        if has_selected:
            mat_all = last_token_selected_heads(outputs.attentions, selected_pairs)
            sub_mat2 = mat_all[:, wanted_idx]
            sub_tokens2 = [tokens[i] for i in wanted_idx]
            plot_heatmap(
                sub_mat2,
                sub_tokens2,
                head_tags,
                os.path.join(out_dir, f"{fname_prefix}{sample_id}_imp_heads.png"),
                " ",
            )


def main(args):
    json_file = Path(args.json_file)
    if not json_file.exists():
        raise RuntimeError(f"JSON file not found: {json_file}")
    with open(json_file, "r", encoding="utf-8") as f:
        samples = json.load(f)

    if not isinstance(samples, list):
        raise ValueError("Expected a list of samples in the JSON file.")

    if len(args.cuda) > 1:
        os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, args.cuda))
        device = None
    else:
        device = torch.device(f"cuda:{args.cuda[0]}" if torch.cuda.is_available() else "cpu")

    print(f"🔵 Loading base model from {args.model_path}")
    config = AutoConfig.from_pretrained(args.model_path, trust_remote_code=True)
    tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
    model = AutoModelForCausalLM.from_pretrained(
        args.model_path,
        config=config,
        torch_dtype=torch.bfloat16,
        device_map="auto" if device is None else {"": device.index},
        trust_remote_code=True,
        attn_implementation="eager"
    )
    model.eval()

    if args.important_file and os.path.exists(args.important_file):
        selected_pairs, head_tags = load_important_heads(args.important_file)
        print("✔ Loaded important heads:", head_tags)
    else:
        selected_pairs, head_tags = [], []
        print("⚠ No important_heads.json found, visualizing average over all heads")

    base_out = args.output_path
    visualize_samples(
        model,
        tokenizer,
        samples,
        base_out,
        device,
        selected_pairs,
        head_tags,
        prefix=args.base_prefix,
    )

    lora_path = (args.lora_path or "").strip()
    if lora_path:
        print(f"🟣 Applying LoRA adapter from {lora_path}")
        model = PeftModel.from_pretrained(
            model,
            lora_path,
            device_map="auto" if device is None else {"": device.index}
        )
        lora_out = args.lora_output_path or base_out
        lora_prefix = args.lora_prefix.strip() if args.lora_prefix else Path(lora_path.rstrip("/")).name
        visualize_samples(
            model,
            tokenizer,
            samples,
            lora_out,
            device,
            selected_pairs,
            head_tags,
            prefix=lora_prefix,
        )
    else:
        print("⚪️ No LoRA adapter applied, using base model only.")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--important_file", type=str, default="important_heads.json",
                        help="Path to important_heads.json file with selected attention heads.")
    parser.add_argument("--model_path", type=str, default="/home/user/models/Llama-3-8B",
                        help="Path to base pretrained model.")
    parser.add_argument("--lora_path", type=str, default="", help="Optional LoRA adapter path.")
    parser.add_argument("--json_file", type=str, default="samples.json", help="Input JSON file.")
    parser.add_argument("--output_path", type=str, default="./attn_vis", help="Base output folder for heatmaps.")
    parser.add_argument("--lora_output_path", type=str, default="",
                        help="Optional output folder for the LoRA adapter visualizations.")
    parser.add_argument("--base_prefix", type=str, default="", help="Filename prefix for base outputs.")
    parser.add_argument("--lora_prefix", type=str, default="", help="Filename prefix for LoRA outputs.")
    parser.add_argument("--cuda", type=int, nargs='+', default=[0],
                        help="CUDA device indices, e.g. 0 or 0 1.")
    args = parser.parse_args()
    main(args)
