import json
import re
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path

import pandas as pd

from rdagent.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import (
    CoSTEEREvaluator,
    CoSTEERSingleFeedback,
)
from rdagent.components.coder.data_science.conf import get_clear_ws_cmd, get_ds_env
from rdagent.components.coder.data_science.utils import remove_eda_part
from rdagent.core.evolving_framework import QueriedKnowledge
from rdagent.core.experiment import FBWorkspace, Task
from rdagent.log import rdagent_logger as logger
from rdagent.log.timer import RD_Agent_TIMER_wrapper
from rdagent.scenarios.data_science.dev.runner import DSRunnerCoSTEERSettings
from rdagent.scenarios.data_science.test_eval import (
    MLETestEval,
    NoTestEvalError,
    get_test_eval,
)
from rdagent.utils.agent.tpl import T
from rdagent.utils.agent.workflow import build_cls_from_json_with_retry
from rdagent.utils.fmt import shrink_text

DIRNAME = Path(__file__).absolute().resolve().parent


@dataclass
class DSRunnerFeedback(CoSTEERSingleFeedback):
    """
    Feedback for Data Science CoSTEER evaluation.
    This feedback is used to evaluate the code and execution of the Data Science CoSTEER task.
    """

    acceptable: bool | None = None
    hyperparameter_tuning_decision: bool | None = None
    hyperparameter_tuning_suggestion: str | None = None
    score: str | None = None

    def is_acceptable(self) -> bool:
        if self.acceptable is not None:
            return self.acceptable
        return super().is_acceptable()

    def __str__(self) -> str:
        parts = [
            "### Execution",
            str(self.execution),
            "### Return Check",
            self.return_checking if self.return_checking is not None else "No return checking",
            "### Code",
            str(self.code),
            "### Validation Score",
            f"{self.score}" if self.score else "Not available",
            "### Final Decision",
            f"This implementation is {'PASSED' if self.acceptable else 'FAILED'}.",
        ]
        if self.hyperparameter_tuning_decision:
            parts.append("### Hyperparameter Tuning Suggestion")
            parts.append(str(self.hyperparameter_tuning_suggestion))
        return "\n".join(parts)


DSCoSTEEREvalFeedback = DSRunnerFeedback  # FIXME: Alias for backward compatibility


class DSRunnerEvaluator(CoSTEEREvaluator):

    def evaluate(
        self,
        target_task: Task,
        implementation: FBWorkspace,
        gt_implementation: FBWorkspace,
        queried_knowledge: QueriedKnowledge = None,
        **kwargs,
    ) -> DSRunnerFeedback:
        env = get_ds_env(
            extra_volumes={
                f"{DS_RD_SETTING.local_data_path}/{self.scen.competition}": T(
                    "scenarios.data_science.share:scen.input_path"
                ).r()
            },
            running_timeout_period=self.scen.real_full_timeout(),
        )

        stdout = implementation.execute(
            env=env, entry=get_clear_ws_cmd()
        )  # Remove previous submission and scores files generated by worklfow.

        # get previous runner loops
        task_info = target_task.get_task_information()
        queried_former_failed_knowledge = (
            queried_knowledge.task_to_former_failed_traces[task_info] if queried_knowledge is not None else []
        )[0]

        # execute workflow
        result = implementation.run(env=env, entry="python -m coverage run main.py")
        stdout = result.stdout
        execute_ret_code = result.exit_code
        implementation.running_info.running_time = result.running_time

        match = re.search(r"(.*?)=== Start of EDA part ===(.*)=== End of EDA part ===", stdout, re.DOTALL)
        eda_output = match.groups()[1] if match else None
        if eda_output is None:
            eda_output = "No EDA output."
        implementation.inject_files(
            **{
                "EDA.md": eda_output,
                "stdout.txt": result.stdout if DSRunnerCoSTEERSettings().dump_stdout_type == "full" else stdout,
            }
        )  # stdout.txt is used for debugging. not used in any other place.
        stdout = remove_eda_part(stdout)
        stdout += f"The code executed {'successfully' if execute_ret_code == 0 else 'failed'}. {'The EDA output is removed from the stdout. ' if eda_output else ''}"

        # Check score file
        score_fp = implementation.workspace_path / "scores.csv"
        score_ret_code = 0
        score_check_text = ""
        if not score_fp.exists():
            logger.warning("Metrics file (scores.csv) is not generated!")
            score_check_text = "[Error] Metrics file (scores.csv) is not generated!"
            score_ret_code = 1
        else:
            try:
                score_df = pd.read_csv(score_fp, index_col=0)
                model_set_in_scores = set(score_df.index)
                model_set_in_folder = set(
                    f[:-3] for f in implementation.file_dict.keys() if re.match(r"^model_(?!test)\w+\.py$", f)
                )

                # Check model names (index)
                # in Pipeline task, we only check ensemble in scores.csv
                if DS_RD_SETTING.coder_on_whole_pipeline:
                    if not score_df.index.is_unique:
                        score_check_text += "\n[Error] The file 'scores.csv' contains duplicate model names."
                        score_ret_code = 1
                    if "ensemble" not in model_set_in_scores:
                        score_check_text += "\n[Error] The file 'scores.csv' doesn't contain the ensemble model."
                        score_ret_code = 1
                    if score_ret_code != 0:
                        score_check_text += f"The dataframe in file 'scores.csv' is:\n{score_df}"
                else:
                    if model_set_in_scores != model_set_in_folder.union({"ensemble"}):
                        score_check_text += f"\n[Error] The scores dataframe does not contain the correct model names as index.\ncorrect model names are: {model_set_in_folder.union({'ensemble'})}\nscore_df is:\n{score_df}"
                        score_ret_code = 1

                # Check metric name (columns) - case insensitive
                if [col.lower() for col in score_df.columns.tolist()] != [self.scen.metric_name.lower()]:
                    score_check_text += f"\n[Error] The scores dataframe does not contain the correct column names.\nCorrect columns is: ['{self.scen.metric_name}']\nBut got: {score_df.columns.tolist()}"
                    score_ret_code = 1

            except Exception as e:
                logger.error(f"Error in checking the scores.csv file: {e}")
                score_check_text += f"\n[Error] in checking the scores.csv file: {e}\nscores.csv's content:\n-----\n{score_fp.read_text()}\n-----"
                score_ret_code = 1

        # DockerEnv for MLEBench submission validation
        submission_check_out = ""
        submission_ret_code = 0
        test_eval = get_test_eval()

        if test_eval.enabled(self.scen.competition):
            submission_check_out, submission_ret_code = test_eval.valid(self.scen.competition, implementation)
            stdout += f"\n### Submission check:\n{submission_check_out}\nIf Submission check returns a 'Submission is valid' or similar message, despite some warning messages, you should still consider the submission as valid and give a positive final decision. "

        # Whether to enable hyperparameter tuning check
        # 1. This is the first loop of evaluation.
        if DS_RD_SETTING.only_first_loop_enable_hyperparameter_tuning:
            c1 = len(queried_knowledge.task_to_former_failed_traces[target_task.get_task_information()][0]) == 0
        else:
            c1 = True

        # 2. The current time spent on runner is less than the time limit ratio for runner timeout.
        time_spent_ratio = implementation.running_info.running_time / env.conf.running_timeout_period
        c2 = time_spent_ratio < DS_RD_SETTING.time_ratio_limit_to_enable_hyperparameter_tuning

        # 3. Only enable hyperparameter tuning during the merge stage if configured.
        # TODO: it is not restricted in merge stage now for fast implementation.
        timer = RD_Agent_TIMER_wrapper.timer
        res_time = timer.remain_time()
        if DS_RD_SETTING.only_enable_tuning_in_merge:
            c3 = res_time <= timedelta(hours=DS_RD_SETTING.merge_hours)
        else:
            c3 = True

        # 4. The current time spent on global is less than the time limit ratio for whole timeout.
        if timer.all_duration is not None and res_time is not None:
            res_ratio = res_time / timer.all_duration
            c4 = res_ratio <= DS_RD_SETTING.res_time_ratio_limit_to_enable_hyperparameter_tuning
        else:
            c4 = True

        # Only enable hyperparameter tuning check if all conditions are met
        enable_hyperparameter_tuning_check = c1 and c2 and c3 and c4

        system_prompt = T(".prompts:DSCoSTEER_eval.system").r(
            scenario=self.scen.get_scenario_all_desc(eda_output=implementation.file_dict.get("EDA.md", None)),
            task_desc=target_task.get_task_information(),
            enable_hyperparameter_tuning_check=enable_hyperparameter_tuning_check,
        )
        user_prompt = T(".prompts:DSCoSTEER_eval.user").r(
            code=implementation.all_codes,
            change_summary=implementation.change_summary,
            stdout=shrink_text(stdout),
            time_spent=f"{implementation.running_info.running_time:.2f} seconds",
            timeout=f"{env.conf.running_timeout_period} seconds",
            percent_of_timeout_used=f"{time_spent_ratio * 100:.2f}%",
            queried_former_failed_knowledge=queried_former_failed_knowledge,
        )

        feedback = build_cls_from_json_with_retry(
            DSRunnerFeedback,
            system_prompt=system_prompt,
            user_prompt=user_prompt,
            # init_kwargs_update_func=DSRunnerFeedback.val_and_update_init_dict,
        )
        try:
            feedback.score = score_df.loc["ensemble"].iloc[0] if score_ret_code == 0 else None
        except:
            logger.error("Failed to get the score from scores.csv.")
            feedback.score = None
        feedback.final_decision = feedback.acceptable and (
            not feedback.hyperparameter_tuning_decision
        )  # If hyperparameter_tuning_decision is None, it's considered as False, so the final_decision dependents on the acceptable

        if feedback and not DS_RD_SETTING.coder_on_whole_pipeline:
            # remove unused files
            implementation.execute(env=env, entry="python -m coverage json -o coverage.json")
            coverage_report_path = implementation.workspace_path / "coverage.json"
            if coverage_report_path.exists():
                used_files = set(json.loads(coverage_report_path.read_text())["files"].keys())
                coverage_report_path.unlink()
                logger.info(f"All used scripts: {used_files}")

                use_one_model = False
                for f in used_files:
                    if f.startswith("model_") and "test" not in f:
                        use_one_model = True
                        break

                if not use_one_model:
                    feedback.acceptable = feedback.final_decision = False
                    logger.warning("No model script is used in `main.py`.")
                    feedback.code += "\n[Error] No model script is used in `main.py`."

                all_python_files = set(Path(implementation.workspace_path).rglob("*.py"))
                must_have_files = ["load_data.py", "feature.py", "ensemble.py"]

                unused_files = [
                    py_file.name
                    for py_file in all_python_files
                    if not (py_file.name in used_files or py_file.name.endswith("test.py"))
                ]
                if unused_files:
                    logger.warning(f"Unused scripts: {unused_files}")
                    error_files = set(unused_files).intersection(set(must_have_files))
                    if error_files:
                        feedback.acceptable = feedback.final_decision = False
                        logger.warning(f"{error_files} must be used in `main.py`.")
                        feedback.code += f"\n[Error] {error_files} must be used in `main.py`."
                    elif use_one_model:
                        logger.info("Remove unused scripts.")
                        implementation.inject_files(**{file: implementation.DEL_KEY for file in unused_files})

        if score_ret_code != 0:
            feedback.acceptable = feedback.final_decision = False
            feedback.return_checking += "\n" + score_check_text
        if submission_ret_code != 0:
            feedback.acceptable = feedback.final_decision = False
            feedback.return_checking += "\nSubmission file check failed."
        return feedback
