from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Union

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
    QHBoxLayout,
    QLabel,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

from app.data_structures.processing import (
    Configurations,
    ProcessingStages,
    Stage,
    UnprocessedVideoResult,
    VideoProcessingResult,
)
from app.exceptions import OpenFileError
from app.gui.boxes.close import CloseBox
from app.loggers import processing as logger
from app.paths import COMMONS_DIR, RESULTS_DIR
from app.services.detection_processor import DetectionProcessor
from app.services.image_saver import ImageSaver
from app.services.motion_detector import MotionDetector
from app.services.object_detector import ObjectDetector
from app.services.processing_manager import ProcessingManager, prepare_and_save_report
from app.services.video_cutter import VideoCutter
from app.utils.format import create_safe_string, format_timedelta
from app.utils.video import EstimatedTimeCalculator, get_video_frames_number

from ...data_structures.time_interval import DatetimeInterval
from .base import DialogWindow

STAGES_ORDER: List[Stage] = [
    ProcessingStages.MOTION_DETECTION,
    ProcessingStages.CUTTING,
    ProcessingStages.OBJECT_DETECTION,
    ProcessingStages.FINISHED,
]


class ProgressBarController:
    """
    Entity responsible for managing progress bars (main and stage progress bars).
    """

    def __init__(
        self,
        main_progress_bar: QProgressBar,
        stage_progress_bar: QProgressBar,
        stage_order: List[Stage],
        files_number: int,
    ) -> None:
        self.main_progress_bar = main_progress_bar
        self.stage_progress_bar = stage_progress_bar
        self.stage_order = stage_order
        self.current_stage = stage_order[0]
        self.file_start_values = self._get_interval_start_values(files_number)
        self.stage_start_values = self._get_interval_start_values(len(stage_order))
        self.current_file_index = 0
        self.stage_frames_number = 0
        self._configure_progress_bars(files_number=files_number)

    def set_stage(
        self, file_index: int, stage: Stage, stage_frames_number: int
    ) -> None:
        """
        Sets stage progress bar value and current stage.
        :param file_index: index of file in queue
        :param stage: stage to set
        :param stage_frames_number: number of frames or intervals in stage
        :return:
        """
        stage_index = self.stage_order.index(stage)
        value = file_index * len(self.stage_order) + (stage_index + 1)
        self.main_progress_bar.setValue(value)
        self.current_stage = stage
        self.stage_frames_number = stage_frames_number

    def set_stage_progress(self, progress: int) -> None:
        """
        Sets stage progress bar value.
        :param progress: progress value
        :return:
        """
        value = (
            int(progress / self.stage_frames_number * 100)
            if self.stage_frames_number
            else 0
        )
        self.stage_progress_bar.setValue(value)

    def get_stage_frames_number(self) -> int:
        """
        Getter for current stage frames number
        :return: number of frames or intervals in current stage
        """
        return self.stage_frames_number

    def get_file_number(self) -> int:
        """
        Getter for current file number for displaying
        :return: number of current file (starting from 1)
        """
        return self.current_file_index + 1

    def _configure_progress_bars(self, files_number: int) -> None:
        """
        Configures progress bars. Sets range and initial value.
        :param files_number: number of files in queue
        :return:
        """
        self.main_progress_bar.setRange(0, len(self.stage_order) * files_number)
        self.main_progress_bar.setValue(0)
        self.stage_progress_bar.setRange(0, 100)
        self.stage_progress_bar.setValue(0)

    @staticmethod
    def _get_interval_start_values(intervals_number: int) -> List[int]:
        """
        Generates list of interval start values for dividing progress bar into intervals from 0 to 100.
        For example, if intervals_number is 5, the result will be [0, 20, 40, 60, 80].
        :param intervals_number: number of intervals
        :return: list of interval start values
        """
        return [i * (100 // intervals_number) for i in range(intervals_number)]


class AnalysisWindow(DialogWindow):
    """
    Window for video analysis. Here the user can see the progress of the analysis.
    """

    def __init__(
        self,
        file_paths: List[Path],
        configurations: Configurations,
    ) -> None:
        super().__init__((500, 300), title="Analýza videa")
        self._file_paths = file_paths
        self._configurations = configurations
        self.stage = ProcessingStages.INITIALIZING
        self.results: List[Union[VideoProcessingResult, UnprocessedVideoResult]] = []
        self.close_box: Optional[CloseBox] = None
        self.processing = False
        self.base_results_folder = RESULTS_DIR / generate_results_folder_name()
        self._processing_manager: Optional[ProcessingManager] = None
        self._valid_files: List[Path] = []
        self._frame_numbers_by_file: Dict[Path, int] = {}
        self._processing_time_interval = DatetimeInterval()
        self._validate_files()
        self._add_status()
        self._add_progress_bar()
        self._add_cancel_button()
        self._file_paths_queue = [(i, path) for i, path in enumerate(self._valid_files)]
        self._estimated_time_calculator = EstimatedTimeCalculator(
            frame_numbers_by_file_index={
                i: self._frame_numbers_by_file[path]
                for i, path in enumerate(self._valid_files)
            }
        )

    def _validate_files(self) -> None:
        """
        Validates files. Checks if they exist, are files and if they can be opened by OpenCV.
        Sets self._valid_files and self._frame_numbers_by_file attributes.
        :return:
        """
        frame_numbers_by_file = {}
        for file_path in self._file_paths:
            if not file_path.exists():
                self.results.append(
                    UnprocessedVideoResult(
                        video_path=file_path, error_message="Soubor neexistuje"
                    )
                )
                continue
            if not file_path.is_file():
                self.results.append(
                    UnprocessedVideoResult(
                        video_path=file_path, error_message="Toto není soubor"
                    )
                )
                continue
            try:
                frames_number = get_video_frames_number(file_path)
            except OpenFileError:
                self.results.append(
                    UnprocessedVideoResult(
                        video_path=file_path,
                        error_message="Nepodařilo se otevřít soubor",
                    )
                )
                continue
            frame_numbers_by_file[file_path] = frames_number
        self._valid_files = list(frame_numbers_by_file.keys())
        self._frame_numbers_by_file = frame_numbers_by_file

    def _add_status(self) -> None:
        status_info = QWidget()
        status_info.setFixedHeight(200)
        layout = QVBoxLayout(status_info)
        self.process_stage = QLabel(self.stage.label)
        self.process_stage.setFont(QFont("Arial", 16, 600))
        self.process_stage.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.process_status = QLabel("[0/0]")
        self.process_status.setFixedHeight(50)
        layout.addWidget(self.process_stage)
        layout.addWidget(self.process_status)
        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.general_layout.addWidget(status_info)

    def _add_progress_bar(self) -> None:
        self.main_progress_bar = QProgressBar()
        self.stage_progress_bar = QProgressBar()
        stages_order = STAGES_ORDER.copy()
        if self._configurations.object_detection is False:
            stages_order.remove(ProcessingStages.OBJECT_DETECTION)
        self.progress_bar_controller = ProgressBarController(
            main_progress_bar=self.main_progress_bar,
            stage_progress_bar=self.stage_progress_bar,
            stage_order=stages_order,
            files_number=len(self._valid_files),
        )
        self.general_layout.addWidget(self.main_progress_bar)
        self.general_layout.addWidget(self.stage_progress_bar)

    def _add_cancel_button(self) -> None:
        control_widget = QWidget()
        layout = QHBoxLayout(control_widget)
        self.close_btn = QPushButton("Zastavit")
        self.close_btn.clicked.connect(self.close)
        layout.setAlignment(Qt.AlignmentFlag.AlignRight)
        layout.addWidget(self.close_btn)
        self.general_layout.addWidget(control_widget)

    def closeEvent(self, e) -> None:
        """
        Overrides closeEvent method. If the processing is not finished, it shows a dialog box
        :param e: PyQT close event
        :return:
        """
        if not self.processing:
            e.accept()
            return
        self.close_box = CloseBox()
        answer_signal = self.close_box.exec()
        if answer_signal == QMessageBox.ButtonRole.NoRole:
            e.ignore()
        else:
            e.accept()

    def _update_progress_stage(self, progress_data: dict) -> None:
        stage = ProcessingStages.get(progress_data["stage"])
        self.stage = stage
        logger.info(
            f"Processing stage changed: {stage.value} ({progress_data['file_index']})"
        )
        self.progress_bar_controller.set_stage(
            file_index=progress_data["file_index"],
            stage=stage,
            stage_frames_number=progress_data["total_number"],
        )
        process_stage_text = (
            f"Soubor {progress_data['file_index'] + 1}"
            f"/{len(self._valid_files)}: {stage.label}"
        )
        self.process_stage.setText(process_stage_text)

    def _update_progress_status(self, progress_data: dict) -> None:
        total_stages_number = self.progress_bar_controller.get_stage_frames_number()
        status_text = generate_process_status_text(
            stage=self.stage,
            estimated_time_calculator=self._estimated_time_calculator,
            process_status_data=progress_data,
            total_number=total_stages_number,
        )
        self.process_status.setText(status_text)
        self.progress_bar_controller.set_stage_progress(progress_data["frame_number"])

    def _save_results(self, result: VideoProcessingResult) -> None:
        self.results.append(result)

    def _after_process_finished(
        self,
    ) -> None:
        if self.close_box is not None:
            self.close_box.close()
        self.processing = False
        self._processing_time_interval.end = datetime.now()
        prepare_and_save_report(
            results_folder=self.base_results_folder,
            results=self.results,
            processing_time_interval=self._processing_time_interval,
            object_detection_enabled=self._configurations.object_detection,
        )
        self.next_window()

    def _configure_processing_manager(
        self, file_index: int, file_path: Path, frames_number: int
    ) -> ProcessingManager:
        self._prepare_results_folder(file_name=file_path.stem)
        object_detector = ObjectDetector(
            model_path=COMMONS_DIR / "yolo" / "yolov8n.pt",
        )
        motion_detector = MotionDetector()
        current_results_dir = self.base_results_folder / create_safe_string(
            file_path.stem
        )
        video_cutter = VideoCutter(
            folder=current_results_dir / "clips",
            margin=self._configurations.cut_margin,
        )
        image_saver = ImageSaver(folder=current_results_dir / "images")
        detection_processor = DetectionProcessor(
            motion_detector=motion_detector,
            video_cutter=video_cutter,
            object_detector=object_detector,
            configurations=self._configurations,
            frames_number=frames_number,
        )
        processing_manager = ProcessingManager(
            file_index=file_index,
            file_path=file_path,
            detection_processor=detection_processor,
            image_saver=image_saver,
        )
        processing_manager.setNextProgressState.connect(self._update_progress_stage)
        processing_manager.updateProgressStatus.connect(self._update_progress_status)
        processing_manager.sendResult.connect(self._save_results)
        processing_manager.nextFile.connect(self.start_processing_next_file)
        return processing_manager

    def start_processing_next_file(self) -> None:
        """
        Starts processing next file from the queue
        :return:
        """
        if not self._file_paths_queue:
            self._after_process_finished()
            return
        file_index, file_path = self._file_paths_queue.pop(0)
        self.start_processing_file(file_index, file_path)

    def start_processing_file(self, file_index: int, file_path: Path) -> None:
        """
        Starts processing of the file with the given index and path
        :param file_index: index of the file in the list of valid files
        :param file_path: path to the file
        :return:
        """
        self._processing_manager = self._configure_processing_manager(
            file_index=file_index,
            file_path=file_path,
            frames_number=self._frame_numbers_by_file[file_path],
        )
        try:
            self._processing_manager.start()
        except Exception as err:
            logger.exception(f"Error while processing file: {err}")
            self.results.append(
                UnprocessedVideoResult(video_path=file_path, error_message=str(err))
            )

    def start_processing(self) -> None:
        """
        Starts processing of the selected files
        :return:
        """
        self.processing = True
        self._processing_time_interval.start = datetime.now()
        self.start_processing_next_file()

    def _prepare_results_folder(self, file_name: str) -> None:
        """
        Creates results folder for the given file name
        :param file_name: generated name of the results folder
        :return:
        """
        results_folder = self.base_results_folder / create_safe_string(file_name)
        folders_to_create = ("clips", "images")
        for folder in folders_to_create:
            (results_folder / folder).mkdir(parents=True, exist_ok=True)


def generate_process_status_text(
    stage: Stage,
    estimated_time_calculator: EstimatedTimeCalculator,
    process_status_data: dict,
    total_number: int,
) -> str:
    """
    Generates text for process status label.
    :param stage: current stage
    :param estimated_time_calculator: entity for calculating estimated time of processing
    :param process_status_data: data for generating status text
    :param total_number: total number of frames in stage
    :return: built status text
    """
    fps = process_status_data.get("fps", 1)
    if stage == ProcessingStages.MOTION_DETECTION:
        status_text = (
            "Zpracováno obrázků souboru [{frame_number}/{total_number}]".format(
                frame_number=process_status_data["frame_number"],
                total_number=total_number,
            )
        )
        estimated_time_calculator.set_processed_frames_number(
            file_index=process_status_data["file_index"],
            current_frame_number=process_status_data["frame_number"],
        )
        estimated_time = estimated_time_calculator.calculate(fps=fps)
        status_text += "\nZbývající čas celkem: ~{estimated_time}".format(
            estimated_time=format_timedelta(estimated_time)
        )
    elif stage in (ProcessingStages.CUTTING, ProcessingStages.OBJECT_DETECTION):
        status_text = "Zpracováno intervalů [{frame_number}/{total_number}]".format(
            frame_number=process_status_data["frame_number"],
            total_number=total_number,
        )
        status_text += "\nZbývající čas celkem: N/A"
    elif stage == ProcessingStages.FINISHED:
        status_text = ""
    else:
        status_text = "Neznámý stav"
    return status_text


def generate_results_folder_name() -> str:
    """
    Generates name of the results folder based on current time
    :return: generated name
    """
    now = datetime.now()
    now_str = now.strftime("%Y-%m-%d_%H-%M-%S")
    return f"results_{now_str}"
