import json
import re
import time
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict, Tuple, Any, Callable, Optional
import numpy as np
from scipy import stats
from concurrent.futures import ThreadPoolExecutor, as_completed
import functools
import random

from utils import extract_answer_from_response, eva_video_list
from gpt_request import request_gemini_with_video, request_gemini
from prompts import get_unlearning_and_video_learning_prompt, get_unlearning_prompt


def retry(max_retries=3, base_delay=0.5, jitter=0.2):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            attempt = 0
            delay = base_delay
            while True:
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    attempt += 1
                    if attempt > max_retries:
                        raise
                    time.sleep(delay + random.uniform(0, jitter))
                    delay *= 2

        return wrapper

    return deco


@dataclass
class Question:
    """Educational question with multiple choice options"""

    question: str
    options: List[str]
    correct_answer: str
    difficulty: str = "medium"


@dataclass
class EvaluationResult:
    """Results from SKU evaluation"""

    concept: str
    pre_unlearning_score: float
    post_unlearning_score: float
    post_video_score: float
    unlearning_success: bool
    learning_gain: float
    detailed_responses: Dict[str, Any]


def load_questions_from_json(json_path: str) -> Dict[str, List[Question]]:
    with open(json_path, "r", encoding="utf-8") as f:
        raw = json.load(f)

    concept_questions: Dict[str, List[Question]] = {}
    for concept, qlist in raw.items():
        qs: List[Question] = []
        for q in qlist:
            # Normalize option order to A-D
            options_dict = q.get("options", {})
            ordered_keys = ["A", "B", "C", "D"]
            options = [options_dict[k] for k in ordered_keys if k in options_dict]
            # Convert correct answer from letter to text to match grading logic
            ans_letter = q.get("answer", "").strip().upper()
            if ans_letter not in ["A", "B", "C", "D"]:
                # Skip and log if error occurs instead of raising
                print(
                    f"[WARN] Invalid answer letter '{ans_letter}' for concept '{concept}' question '{q.get('question','')[:40]}...'"
                )
                continue
            ans_idx = ord(ans_letter) - ord("A")
            if ans_idx >= len(options):
                print(f"[WARN] Answer index out of range for concept '{concept}'")
                continue

            qs.append(
                Question(
                    question=q.get("question", ""),
                    options=options,
                    correct_answer=options[ans_idx],
                    difficulty=q.get("difficulty", "medium"),
                )
            )
        if qs:
            concept_questions[concept] = qs
    return concept_questions


@retry(max_retries=3, base_delay=0.6, jitter=0.3)
def _call_text_api(prompt: str) -> str:
    response = request_gemini(prompt=prompt)
    return extract_answer_from_response(response)


@retry(max_retries=3, base_delay=0.6, jitter=0.3)
def _call_video_api(prompt: str, video_path: str) -> str:
    response = request_gemini_with_video(prompt=prompt, video_path=video_path)
    return extract_answer_from_response(response)


def make_mllm_api(video_path: Optional[str]) -> Callable[[str], str]:
    if video_path:
        return lambda prompt: _call_video_api(prompt, video_path)
    else:
        return lambda prompt: _call_text_api(prompt)


class SelectiveKnowledgeUnlearning:
    def __init__(self, mllm_api_function, per_question_workers: int = 4):
        self.mllm_api = mllm_api_function
        # Concurrency within each individual concept at each stage (at the problem level)
        self.per_question_workers = max(1, per_question_workers)

    def _format_mcq_prompt_block(self, i: int, q: Question) -> str:
        opts = "\n".join([f"{chr(65+j)}) {opt}" for j, opt in enumerate(q.options)])
        return f"Question {i}: {q.question}\nOptions:\n{opts}\n"

    def _grade_batch(self, questions: List[Question], responses: List[str]) -> Tuple[float, List[str]]:
        correct = 0
        detailed = []
        for q, resp in zip(questions, responses):
            detailed.append(resp)
            m = re.search(r"\b[A-D]\b", resp)
            if m:
                idx = ord(m.group()) - ord("A")
                if 0 <= idx < len(q.options) and q.options[idx] == q.correct_answer:
                    correct += 1
        acc = correct / len(questions) if questions else 0.0
        return acc, detailed

    # Execute a set of questions in one stage in parallel
    def _assess_stage_parallel(
        self, prefix: str, questions: List[Question], use_video_api: Optional[Callable[[str], str]] = None
    ) -> Tuple[float, List[str]]:
        api = use_video_api if use_video_api else self.mllm_api

        def build_prompt(i: int, q: Question) -> str:
            return f"{prefix}\n\n{self._format_mcq_prompt_block(i, q)}Please answer with a single letter (A|B|C|D) then a brief explanation."

        responses: List[Optional[str]] = [None] * len(questions)
        with ThreadPoolExecutor(max_workers=self.per_question_workers) as pool:
            futures = {}
            for i, q in enumerate(questions, 1):
                prompt = build_prompt(i, q)
                fut = pool.submit(api, prompt)
                futures[fut] = i - 1  # Subscript
            for fut in as_completed(futures):
                idx = futures[fut]
                try:
                    responses[idx] = fut.result()
                except Exception as e:
                    responses[idx] = ""  # Failed responses are marked empty, counted as wrong
        # Fill None with empty strings
        responses = [r if r is not None else "" for r in responses]
        return self._grade_batch(questions, responses)

    def assess_baseline(self, concept: str, questions: List[Question]) -> Tuple[float, List[str]]:
        prefix = "You are taking a multiple-choice test. Output: letter on first line, then brief explanation."
        return self._assess_stage_parallel(prefix, questions)

    def assess_with_unlearning(self, concept: str, questions: List[Question]) -> Tuple[float, List[str]]:
        prefix = get_unlearning_prompt(concept)
        return self._assess_stage_parallel(prefix, questions)

    def assess_with_unlearning_and_video(self, concept: str, questions: List[Question], video_api_fn) -> Tuple[float, List[str]]:
        prefix = get_unlearning_and_video_learning_prompt(concept)
        return self._assess_stage_parallel(prefix, questions, use_video_api=video_api_fn)

    def evaluate_educational_video(
        self, concept: str, questions: List[Question], video_api_fn: Callable[[str], str]
    ) -> EvaluationResult:
        print(f"Start evaluation: {concept}")

        # Step 1：Baseline
        print("Step 1: Baseline (no unlearning, no video)")
        pre_score, pre_resps = self.assess_baseline(concept, questions)
        print(f"Baseline score: {pre_score:.3f}")

        # Step 2：Unlearning-only
        print("Step 2: Unlearning-only")
        post_unlearn_score, post_unlearn_resps = self.assess_with_unlearning(concept, questions)
        print(f"Unlearning-only score: {post_unlearn_score:.3f}")
        unlearn_success = post_unlearn_score <= pre_score  # 简单启发式

        # Step 3：Unlearning + Video
        print("Step 3: Unlearning + Video")
        post_video_score, post_video_resps = self.assess_with_unlearning_and_video(concept, questions, video_api_fn)
        print(f"Unlearning + Video score: {post_video_score:.3f}")

        # Overall Score
        gain = post_video_score - post_unlearn_score
        result = EvaluationResult(
            concept=concept,
            pre_unlearning_score=pre_score,
            post_unlearning_score=post_unlearn_score,
            post_video_score=post_video_score,
            unlearning_success=unlearn_success,
            learning_gain=gain,
            detailed_responses={"baseline": pre_resps, "post_unlearning": post_unlearn_resps, "post_video": post_video_resps},
        )
        print(f"Done: gain={gain:.3f}")
        return result


def format_evaluation_report(results: List[EvaluationResult]) -> str:
    report = """
========================================
SKU EDUCATIONAL VIDEO EVALUATION REPORT
========================================

"""

    if not results:
        return report + "No results.\n"

    total_concepts = len(results)
    successful_unlearning = sum(1 for r in results if r.unlearning_success)
    gains = [r.learning_gain for r in results]
    pre_scores = [r.pre_unlearning_score for r in results]
    post_unlearn_scores = [r.post_unlearning_score for r in results]
    post_video_scores = [r.post_video_score for r in results]

    def _safe_mean(xs):
        return float(np.mean(xs)) if len(xs) > 0 else float("nan")

    report += "DETAILED RESULTS BY CONCEPT:\n"

    for result in results:
        effectiveness_rating = "High" if result.learning_gain > 0.3 else "Medium" if result.learning_gain > 0.1 else "Low"
        report += f"""
        CONCEPT: {result.concept}
        ├── Unlearning Success: {'✓' if result.unlearning_success else '✗'}
        ├── Pre-unlearning Score: {result.pre_unlearning_score:.3f}
        ├── Post-unlearning Score: {result.post_unlearning_score:.3f}
        ├── Post-video Score: {result.post_video_score:.3f}
        ├── Learning Gain: {result.learning_gain:.3f}
        └── Video Effectiveness: {effectiveness_rating}

        """

    # statistical significance
    successful_results = [r for r in results if r.unlearning_success]
    if len(successful_results) > 1:
        successful_gains = [r.learning_gain for r in successful_results]
        t_stat, p_value = stats.ttest_1samp(successful_gains, 0)
        mu = float(np.mean(successful_gains))
        sd = float(np.std(successful_gains, ddof=1)) if len(successful_gains) > 1 else 0.0
        n = len(successful_gains)
        ci_low = mu - 1.96 * (sd / np.sqrt(n)) if n > 1 and sd > 0 else mu
        ci_high = mu + 1.96 * (sd / np.sqrt(n)) if n > 1 and sd > 0 else mu
        d = (mu / sd) if sd > 0 else float("inf")

        report += f"""
        STATISTICAL ANALYSIS (on successfully unlearned concepts):
        - Learning Gain Distribution: μ={mu:.3f}, σ={sd:.3f}, n={n}
        - Significance Test (H0: no learning): t={t_stat:.3f}, p={p_value:.3f}
        - Effect Size (Cohen's d): {d:.3f}
        - 95% Confidence Interval: [{ci_low:.3f}, {ci_high:.3f}]

        """

    report += "=" * 50 + "\n\n"
    report += f"""
SUMMARY STATISTICS:
- Total Concepts Evaluated: {total_concepts}
- Successful Unlearning Rate: {successful_unlearning}/{total_concepts} ({(successful_unlearning/total_concepts*100):.1f}%)
- Average Pre-unlearning Score: {_safe_mean(pre_scores):.3f}
- Average Post-unlearning Score: {_safe_mean(post_unlearn_scores):.3f}
- Average Post-video Score: {_safe_mean(post_video_scores):.3f}
- Average Learning Gain: {_safe_mean(gains)*100:.1f}

"""

    return report


def run_one_concept(concept: str, questions: List[Question], video_path: str, per_question_workers: int) -> EvaluationResult:
    text_api = make_mllm_api(video_path=None)
    video_api = make_mllm_api(video_path=video_path)
    sku = SelectiveKnowledgeUnlearning(mllm_api_function=text_api, per_question_workers=per_question_workers)
    return sku.evaluate_educational_video(concept=concept, questions=questions, video_api_fn=video_api)


def main():
    parser = argparse.ArgumentParser(description="Run SKU evaluation over a question JSON and generated videos (parallel).")
    parser.add_argument("--concept_workers", type=int, default=2, help="Parallel workers across concepts.")
    parser.add_argument("--per_question_workers", type=int, default=5, help="Parallel workers per concept per stage.")
    parser.add_argument(
        "--questions_json",
        type=str,
        default="/mlx_devbox/users/chenanno/playground/Code4Video/pipeline/json_files/questions_by_topic_10.json",
        help="Path to the questions JSON file.",
    )
    parser.add_argument(
        "--concepts",
        type=str,
        nargs="*",
        default=None,
        help="Optional subset of concepts to evaluate. If not set, evaluate all in JSON.",
    )
    # TODO: CASES 下的路径
    parser.add_argument(
        "--base_dir",
        type=str,
        default="/mlx_devbox/users/chenanno/playground/Code4Video/pipeline/CASES/Sep_Gemini",
        help="Base directory where per-knowledge-point video folders are located",
    )
    # TODO: Test the number of knowledge points. If None, test all of them
    parser.add_argument("--max_concepts", default=None)
    args = parser.parse_args()
    # 1) Load the question set
    concept_questions = load_questions_from_json(args.questions_json)
    all_concepts = list(concept_questions.keys())
    chosen_concepts = [c for c in all_concepts if (not args.concepts or c in args.concepts)]
    if args.max_concepts is not None:
        chosen_concepts = chosen_concepts[: args.max_concepts]
    if not chosen_concepts:
        print("[ERROR] No concepts to evaluate. Check --concepts or the JSON content.")
        return
    # 2) Generate a list of video paths
    video_items = eva_video_list(chosen_concepts, args.base_dir)
    concept2video = {item["knowledge_point"]: item["path"] for item in video_items}
    # 3) Parallel execution
    results: List[EvaluationResult] = []
    with ThreadPoolExecutor(max_workers=max(1, args.concept_workers)) as pool:
        futures = {}
        for concept in chosen_concepts:
            qs = concept_questions.get(concept, [])
            if not qs:
                print(f"[WARN] No questions for concept '{concept}', skip.")
                continue
            vpath = concept2video.get(concept)
            if not vpath:
                print(f"[WARN] No video path for concept '{concept}', skip.")
                continue
            if not Path(vpath).exists():
                print(f"[WARN] Video file not found: {vpath} (concept '{concept}'). API may fail.")
            fut = pool.submit(run_one_concept, concept, qs, vpath, args.per_question_workers)
            futures[fut] = concept
        for fut in as_completed(futures):
            concept = futures[fut]
            try:
                res = fut.result()
                results.append(res)
            except Exception as e:
                print(f"[ERROR] Concept '{concept}' failed with error: {e}")
    # 4) Summarize the report
    report = format_evaluation_report(results)
    print(report)


if __name__ == "__main__":
    main()
