import math
import os
import random
import time
import json

from sage.base_app import BaseApp

if __package__ in (None, ""):
    from email_service import EmailService
    from report_generator import HandFlopReportGenerator
    from scoreboard_client import ScoreboardClient
else:
    from .email_service import EmailService
    from .report_generator import HandFlopReportGenerator
    from .scoreboard_client import ScoreboardClient


class Core(BaseApp):
    def __init__(self, my_sage):
        BaseApp.__init__(self, my_sage, __file__)

        self.iteration = 0
        self.inside_node = self.info["sensors"].index("inside_wrist")
        self.outside_node = self.info["sensors"].index("outside_wrist")
        self.angle_node = self.inside_node

        self.cue_interval_min = max(float(self.config["cue_interval_min"]), 0.25)
        self.cue_interval_max = max(float(self.config["cue_interval_max"]), self.cue_interval_min)
        self.total_intervals = max(2, int(self.config.get("total_intervals", 5)))
        if self.total_intervals % 2:
            self.total_intervals += 1

        self.repeat_probability = min(max(float(self.config["repeat_probability"]), 0), 1)
        self.goal_angle_degrees = abs(float(self.config.get("goal_angle_degrees", 45.0)))
        self.calibrated_neutral_angle_offset_degrees = 0.0
        self.angle_sign = -1.0 if bool(self.config.get("invert_angle", False)) else 1.0
        self.neutral_ready_threshold_degrees = 20.0
        self.sensor_frame_correction_degrees = 90.0
        self.calibration_duration_seconds = 2.0
        self.calibration_angle_sum = 0.0
        self.calibration_sample_count = 0
        self.calibration_complete = False
        self.calibration_start_monotonic = None

        self.vibration_duration = max(float(self.config["vibration_duration"]), 0.05)

        self.cue_sequence = self._build_cue_sequence()
        self.cue_sequence_index = 0
        self.current_target = self.cue_sequence[self.cue_sequence_index]
        self.previous_target = None
        self.current_interval = 0
        self.running_score = 0.0
        self.correct_count = 0
        self.completed_cue_count = 0
        self.timed_out_cue_count = 0
        self.reaction_time_total = 0.0

        self.last_score_change = 0.0
        self.last_correct = 0
        self.last_repeated_target = 0
        self.last_cue_changed = 1
        self.last_ready_for_cue = 0
        self.last_goal_met = 0
        self.last_reaction_time = 0.0
        self.last_current_angle_deg = 0.0
        self.last_target_angle_deg = self._target_angle_deg()
        self.pending_stop = False
        self.cue_active = False
        self.current_interval_start_time = None
        self.current_interval_cue_len = 0.0
        self.neutral_hold_start_time = None
        self.current_interval_cue_wait = 0.0
        self.secrets = self._load_secrets()

    def run_in_loop(self):
        data = self.my_sage.get_next_data()
        time_now = self.iteration / self.info["datarate"]

        if not self.calibration_complete:
            if self.calibration_start_monotonic is None:
                self.calibration_start_monotonic = time.monotonic()

            calibration_elapsed = time.monotonic() - self.calibration_start_monotonic
            if calibration_elapsed < self.calibration_duration_seconds:
                self.calibration_angle_sum += self._quat_y_axis_degrees(data[self.angle_node])
                self.calibration_sample_count += 1
                self.last_current_angle_deg = 0.0
                self.last_target_angle_deg = self._target_angle_deg()
                self.last_ready_for_cue = 0
                self.last_goal_met = 0
                self.last_reaction_time = 0.0
                self.last_correct = 0
                self.last_score_change = 0.0
                user_defined_status = (
                    f"Avg Time: n/a | "
                    f"Cue {min(self.current_interval + 1, self.total_intervals)}/{self.total_intervals} | "
                    f"Target: {self.current_target} {self.last_target_angle_deg:+.0f}deg | "
                    f"Angle: calibrating | "
                    f"State: calibrating | "
                )

                my_data = {
                    "time": [time_now],
                    "interval_index": [self.current_interval],
                    "intervals_remaining": [int(self.total_intervals - self.current_interval)],
                    "running_score": [self.running_score],
                    "correct_count": [self.correct_count],
                    "timed_out_cue_count": [self.timed_out_cue_count],
                    "ready_for_cue": [self.last_ready_for_cue],
                    "target_direction": [self._target_code(self.current_target)],
                    "goal_met": [self.last_goal_met],
                    "cue_changed": [self.last_cue_changed],
                    "repeated_target": [self.last_repeated_target],
                    "score_change": [self.last_score_change],
                    "correct_this_interval": [self.last_correct],
                    "reaction_time": [self.last_reaction_time],
                    "current_angle_deg": [self.last_current_angle_deg],
                    "target_angle_deg": [self.last_target_angle_deg],
                    "user_defined_status": [user_defined_status],
                }

                self.my_sage.save_data(data, my_data)
                self.my_sage.send_stream_data(data, my_data)

                self.iteration += 1
                return True

            self.calibrated_neutral_angle_offset_degrees = (
                self.calibration_angle_sum / self.calibration_sample_count
            )
            self.calibration_complete = True

        current_angle_deg = self._current_angle_degrees(data[self.angle_node])
        self.last_current_angle_deg = current_angle_deg
        self.last_target_angle_deg = self._target_angle_deg()
        frame_target_direction = self._target_code(self.current_target)
        frame_target_angle_deg = self.last_target_angle_deg
        self.last_ready_for_cue = int(
            abs(current_angle_deg) <= self.neutral_ready_threshold_degrees
        )
        self.last_goal_met = 0
        self.last_reaction_time = 0.0
        self.last_correct = 0
        previous_score = self.running_score

        if not self.cue_active and not self.pending_stop:
            if self.last_ready_for_cue:
                if self.neutral_hold_start_time is None:
                    self.neutral_hold_start_time = time_now
                    self.current_interval_cue_wait = random.uniform(
                        self.cue_interval_min, self.cue_interval_max
                    )
                elif time_now - self.neutral_hold_start_time >= self.current_interval_cue_wait:
                    self.current_interval_start_time = time_now
                    self.current_interval_cue_len = random.uniform(
                        self.cue_interval_min, self.cue_interval_max
                    )
                    self.neutral_hold_start_time = None
                    self.cue_active = True
                    self._trigger_cue()
            else:
                self.neutral_hold_start_time = None

        if self.cue_active:
            interval_elapsed = time_now - self.current_interval_start_time
            if self._goal_met(current_angle_deg):
                self.last_goal_met = 1
                self.last_reaction_time = interval_elapsed
                self._finish_cue(success=True, reaction_time=interval_elapsed)
            elif interval_elapsed >= self.current_interval_cue_len:
                self.last_reaction_time = self.current_interval_cue_len
                self._finish_cue(success=False, reaction_time=self.current_interval_cue_len)

        self.last_score_change = self.running_score - previous_score

        avg_time_text = (
            f"{self.running_score:.2f}s" if self.completed_cue_count else "n/a"
        )
        cue_state = "active"
        if not self.cue_active:
            if not self.last_ready_for_cue:
                cue_state = "return_to_neutral"
            elif self.neutral_hold_start_time is None:
                cue_state = "ready"
            else:
                cue_state = "waiting_for_cue"

        user_defined_status = (
            f"Avg Time: {avg_time_text} | "
            f"Cue {min(self.current_interval + 1, self.total_intervals)}/{self.total_intervals} | "
            f"Target: {self.current_target} {self.last_target_angle_deg:+.0f}deg | "
            f"Angle: {self.last_current_angle_deg:+.1f}deg | "
            f"State: {cue_state} | "
        )

        my_data = {
            "time": [time_now],
            "interval_index": [self.current_interval],
            "intervals_remaining": [int(self.total_intervals - self.current_interval)],
            "running_score": [self.running_score],
            "correct_count": [self.correct_count],
            "timed_out_cue_count": [self.timed_out_cue_count],
            "ready_for_cue": [self.last_ready_for_cue],
            "target_direction": [frame_target_direction],
            "goal_met": [self.last_goal_met],
            "cue_changed": [self.last_cue_changed],
            "repeated_target": [self.last_repeated_target],
            "score_change": [self.last_score_change],
            "correct_this_interval": [self.last_correct],
            "reaction_time": [self.last_reaction_time],
            "current_angle_deg": [self.last_current_angle_deg],
            "target_angle_deg": [frame_target_angle_deg],
            "user_defined_status": [user_defined_status],
        }

        self.my_sage.save_data(data, my_data)
        self.my_sage.send_stream_data(data, my_data)

        self.iteration += 1
        if self.pending_stop:
            return False
        return True

    def on_stop_event(self, _):
        self._stop_all_vibration()
        final_message = (
            f"Final average reaction time: {self.running_score:.2f}s. "
            f"Successful cues: {self.correct_count}/{self.completed_cue_count}. "
            f"Timed out cues: {self.timed_out_cue_count}."
        )
        self.logger.info(final_message)

        try:
            trial_file_name, trial_data_dict, trial_info_dict = self.get_trial_data()
        except Exception as exc:
            self.logger.error(f"Unable to load trial data for results workflow: {exc}")
            return

        if not trial_file_name:
            trial_file_name = self._fallback_trial_file_name()

        report_path = None
        should_generate_report = bool(self.config.get("save_report")) or bool(
            self.config.get("send_report")
        )
        if should_generate_report:
            try:
                report_trial_file_name = trial_file_name
                if not self.config.get("save_report") and self.config.get("send_report"):
                    report_trial_file_name = self._fallback_trial_file_name(directory="/tmp")
                report_generator = HandFlopReportGenerator(report_trial_file_name)
                report_path = report_generator.generate_report(trial_data_dict, trial_info_dict)
                if self.config.get("save_report"):
                    report_file_name = os.path.basename(report_path)
                    self.logger.info(
                        "Report saved. You can find it "
                        f"<a id='hostLink' href='#'>here</a> or in the data management tab"
                        f"<script> document.getElementById('hostLink').href = 'http://' + "
                        f"window.location.host + '/files/data/{report_file_name}';</script>"
                    )
            except Exception as exc:
                self.logger.error(f"Unable to generate Hand Flop report: {exc}")
                report_path = None

        if self.config.get("send_report") and self.config.get("participant_email"):
            self._send_results_email(report_path)

        if self.config.get("post_scores"):
            self._post_score(trial_file_name)

    def _finish_cue(self, success, reaction_time):
        self.cue_active = False
        self._stop_all_vibration()
        self.completed_cue_count += 1
        self.reaction_time_total += reaction_time
        self.running_score = self.reaction_time_total / self.completed_cue_count

        if success:
            self.correct_count += 1
            self.last_correct = 1
        else:
            self.timed_out_cue_count += 1

        self.current_interval += 1
        if self.current_interval >= self.total_intervals:
            self.pending_stop = True
        else:
            self._choose_next_target()

    def _build_cue_sequence(self):
        cue_sequence = ["inside_wrist"] * (self.total_intervals // 2)
        cue_sequence.extend(["outside_wrist"] * (self.total_intervals // 2))
        random.shuffle(cue_sequence)
        return cue_sequence

    def _choose_next_target(self):
        self.previous_target = self.current_target
        self.cue_sequence_index += 1
        self.current_target = self.cue_sequence[self.cue_sequence_index]
        self.last_repeated_target = int(self.previous_target == self.current_target)
        self.last_cue_changed = int(not self.last_repeated_target)
        self.last_target_angle_deg = self._target_angle_deg()

    def _trigger_cue(self):
        self._stop_all_vibration()
        target_node = (
            self.inside_node
            if self.current_target == "inside_wrist"
            else self.outside_node
        )
        duration = self.vibration_duration
        self.my_sage.sensor_vibration_on(
            target_node,
            duration,
            4
        )

    def _vibrating_target_label(self):
        return self.current_target

    def _target_sign(self):
        return 1.0 if self.current_target == "inside_wrist" else -1.0

    def _target_angle_deg(self):
        return self._target_sign() * self.goal_angle_degrees

    def _goal_met(self, current_angle_deg):
        return self._target_sign() * current_angle_deg >= self.goal_angle_degrees

    def _current_angle_degrees(self, node_data):
        return self.angle_sign * (
            self._quat_y_axis_degrees(node_data)
            - self.calibrated_neutral_angle_offset_degrees
        )

    def _quat_y_axis_degrees(self, node_data):
        qw = node_data["Quat1"]
        qx = node_data["Quat2"]
        qy = node_data["Quat3"]
        qz = node_data["Quat4"]
        qw, qx, qy, qz = self._apply_y_axis_frame_correction(
            qw, qx, qy, qz, self.sensor_frame_correction_degrees
        )
        sinp = 2.0 * (qw * qy - qz * qx)
        sinp = max(-1.0, min(1.0, sinp))
        return math.degrees(math.asin(sinp))

    def _apply_y_axis_frame_correction(self, qw, qx, qy, qz, correction_degrees):
        half_angle = math.radians(correction_degrees) / 2.0
        cw = math.cos(half_angle)
        cy = math.sin(half_angle)
        return (
            qw * cw - qy * cy,
            qx * cw - qz * cy,
            qw * cy + qy * cw,
            qx * cy + qz * cw,
        )

    def _stop_all_vibration(self):
        self.my_sage.sensor_vibration_off(self.inside_node)
        self.my_sage.sensor_vibration_off(self.outside_node)

    def _target_code(self, label):
        if label == "inside_wrist":
            return 1
        if label == "outside_wrist":
            return 2
        return 0

    def _load_secrets(self):
        secrets_path = "/persist/settings/secrets.json"
        if not os.path.exists(secrets_path):
            return {}

        try:
            with open(secrets_path, "r", encoding="utf-8") as secrets_file:
                return json.load(secrets_file)
        except Exception as exc:
            self.logger.warning(f"Unable to load secrets for reporting integrations: {exc}")
            return {}

    def _send_results_email(self, report_path):
        email_secrets = self.secrets.get("GMAIL", {})
        email_user = email_secrets.get("EMAIL_USER") or self.secrets.get("email_user")
        email_password = (
            email_secrets.get("EMAIL_APP_PASSWORD")
            or self.secrets.get("email_app_password")
        )
        if not email_user or not email_password:
            self.logger.warning("Skipping Hand Flop results email because GMAIL secrets are missing.")
            return

        try:
            chart_attachment_path = None
            if report_path:
                report_root, _ = os.path.splitext(report_path)
                sibling_chart_path = f"{report_root}_chart.png"
                if os.path.exists(sibling_chart_path):
                    chart_attachment_path = sibling_chart_path

            email_service = EmailService(
                email_user=email_user,
                email_app_password=email_password,
                smtp_host=email_secrets.get("SMTP_HOST") or self.secrets.get("smtp_host", "smtp.gmail.com"),
                smtp_port=email_secrets.get("SMTP_PORT") or self.secrets.get("smtp_port", 465),
                timeout_seconds=email_secrets.get("TIMEOUT_SECONDS")
                or self.secrets.get("smtp_timeout_seconds", 10),
                logger=self.logger,
            )
            email_service.send_participant_results(
                to_email=self.config["participant_email"],
                participant_name=self.config.get("participant_name", ""),
                app_name=self.info["app_name"],
                score=self.running_score,
                successful_cues=self.correct_count,
                timed_out_cues=self.timed_out_cue_count,
                scoreboard_url=self.info.get("scoreboard_url", ""),
                attachment_path=chart_attachment_path,
            )
        except Exception as exc:
            self.logger.error(f"An error occurred sending the Hand Flop results email: {exc}")

    def _post_score(self, trial_file_name):
        app_secrets = self.secrets.get("HAND_FLOP_GAME", {})
        raw_base_url = (
            app_secrets.get("db_url")
            or self.info.get("restdb_url")
            or self.secrets.get("db_url")
        )
        collection = self._scoreboard_collection()
        api_key = app_secrets.get("api_key") or self.secrets.get("api_key")
        base_url, inferred_collection = self._normalize_scoreboard_target(
            raw_base_url, collection
        )
        if not collection and inferred_collection:
            collection = inferred_collection

        missing_fields = []
        if not collection or not api_key or not base_url:
            if not api_key:
                missing_fields.append("HAND_FLOP_GAME.api_key")
            if not collection:
                missing_fields.append(
                    "HAND_FLOP_GAME.db_collection or config.scoreboard_collection or info.db_collection"
                )
            if not base_url:
                missing_fields.append("HAND_FLOP_GAME.db_url or info.restdb_url or db_url")

            details = ", ".join(missing_fields) if missing_fields else "unknown fields"
            self.logger.warning(
                "Skipping score post because scoreboard configuration is incomplete. "
                f"Missing: {details}. "
                "Expected either a base RestDB URL like "
                "'https://.../rest' plus a collection name, or a full collection URL "
                "from which the collection can be inferred."
            )
            return

        payload = {
            "name": self.config.get("participant_name", ""),
            "institution": self.config.get("participant_institution", ""),
            "score": float(f"{self.running_score:.2f}"),
            "successful_cues": int(self.correct_count),
            "timed_out_cues": int(self.timed_out_cue_count),
            "completed_cues": int(self.completed_cue_count),
        }
        trial_root, _ = os.path.splitext(trial_file_name)
        trial_save_name = f"{trial_root}_scores.json"
        with open(trial_save_name, "w", encoding="utf-8") as score_file:
            json.dump(payload, score_file, indent=2)

        score_client = ScoreboardClient(
            api_key=api_key,
            collection=collection,
            base_url=base_url,
            logger=self.logger,
        )
        if score_client.post_score(payload):
            self.logger.info("Posted Hand Flop score successfully.")
        else:
            self.logger.info("There was a problem posting the Hand Flop score.")

    def _scoreboard_collection(self):
        app_secrets = self.secrets.get("HAND_FLOP_GAME", {})
        return (
            app_secrets.get("db_collection")
            or self.config.get("scoreboard_collection", "")
            or self.info.get("db_collection")
            or self.secrets.get("db_collection")
        )

    def _normalize_scoreboard_target(self, base_url, collection):
        if not base_url:
            return "", ""

        normalized_base = str(base_url).rstrip("/")
        normalized_collection = str(collection).strip()

        marker = "/rest/"
        if marker in normalized_base:
            prefix, suffix = normalized_base.rsplit(marker, 1)
            if suffix and "/" not in suffix and not normalized_collection:
                return f"{prefix}{marker[:-1]}", suffix

        return normalized_base, normalized_collection

    def _fallback_trial_file_name(self, directory="/tmp"):
        trial_name = self.config.get("trial_name", "hand_flop_game")
        local_time = self.config.get("local_time", time.strftime("%Y-%m-%d-%H-%M-%S"))
        safe_trial_name = "".join(
            character if character.isalnum() or character in ("-", "_") else "_"
            for character in str(trial_name)
        ).strip("_")
        return os.path.join(directory, f"{safe_trial_name}_{local_time}.csv")
