import os
import secrets
import zipfile
from abc import abstractmethod
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import IO, TYPE_CHECKING, Dict, List, Optional, Tuple, Union

from flask import current_app

from fittrackee import appLog, db
from fittrackee.equipments.exceptions import InvalidEquipmentsException
from fittrackee.equipments.models import Equipment
from fittrackee.files import get_absolute_file_path
from fittrackee.users.models import Notification, User, UserTask
from fittrackee.visibility_levels import get_calculated_visibility
from fittrackee.workouts.models import (
    DESCRIPTION_MAX_CHARACTERS,
    NOTES_MAX_CHARACTERS,
)

from ..exceptions import (
    WorkoutExceedingValueException,
    WorkoutException,
    WorkoutFileException,
    WorkoutNoFileException,
)
from .base_workout_service import (
    BaseWorkoutService,
)
from .mixins import WorkoutFileMixin
from .workout_from_file.services import WORKOUT_FROM_FILE_SERVICES

if TYPE_CHECKING:
    from werkzeug.datastructures import FileStorage

    from fittrackee.visibility_levels import VisibilityLevel
    from fittrackee.workouts.models import Workout


@dataclass
class WorkoutsData:
    sport_id: int
    analysis_visibility: Optional["VisibilityLevel"] = None
    description: Optional[str] = None
    equipment_ids: Optional[List[str]] = None
    map_visibility: Optional["VisibilityLevel"] = None
    notes: Optional[str] = None
    title: Optional[str] = None
    workout_visibility: Optional["VisibilityLevel"] = None


class AbstractWorkoutsCreationService(BaseWorkoutService, WorkoutFileMixin):
    def __init__(
        self,
        auth_user: "User",
        workouts_data: Dict,
        file: Optional["FileStorage"] = None,
    ):
        super().__init__(
            auth_user,
            workouts_data.get("sport_id"),
            workouts_data.get("equipment_ids"),
        )
        self.file: Optional["FileStorage"] = file
        self.workouts_data = WorkoutsData(**workouts_data)

    @abstractmethod
    def process(self) -> Tuple[List["Workout"], Dict]:
        pass

    def _get_user_path(
        self,
    ) -> str:
        user_path = os.path.join("workouts", str(self.auth_user.id))
        absolute_user_path = get_absolute_file_path(user_path)
        if not os.path.exists(absolute_user_path):
            os.makedirs(absolute_user_path)
        return user_path

    def get_file_path(
        self,
        workout_date: str,
        extension: str,
    ) -> str:
        suffix = secrets.token_urlsafe(8)
        new_filename = (
            f"{workout_date}_{self.workouts_data.sport_id}_{suffix}{extension}"
        )
        dir_path = self._get_user_path()
        file_path = os.path.join(dir_path, new_filename.split("/")[-1])
        return file_path

    def _get_workout_title(
        self, service_workout_name: Optional[str], workout_date: datetime
    ) -> Optional[str]:
        title = ""
        if self.workouts_data.title:
            title = self.workouts_data.title
        elif service_workout_name:
            title = service_workout_name
        return self._get_title(workout_date, title)

    def _get_workout_description(
        self, service_workout_description: Optional[str]
    ) -> Optional[str]:
        if self.workouts_data.description:
            return self.workouts_data.description[:DESCRIPTION_MAX_CHARACTERS]

        if service_workout_description:
            return service_workout_description[:DESCRIPTION_MAX_CHARACTERS]

        return None

    def _get_workout_notes(self) -> Optional[str]:
        if self.workouts_data.notes is None:
            return None
        return self.workouts_data.notes[:NOTES_MAX_CHARACTERS]

    def _store_file(
        self,
        new_workout: "Workout",
        workout_file: Optional["IO[bytes]"],
        *,
        extension: str,
    ) -> str:
        if not workout_file and not self.file:
            return ""

        relative_path = self.get_file_path(
            workout_date=new_workout.workout_date.strftime(
                "%Y-%m-%d_%H-%M-%S"
            ),
            extension=extension,
        )

        absolute_workout_filepath = get_absolute_file_path(relative_path)
        try:
            if workout_file:  # when file from archive
                with open(absolute_workout_filepath, "wb") as f:
                    workout_file.seek(0)
                    f.write(workout_file.read())
            elif self.file:
                self.file.stream.seek(0)
                self.file.save(absolute_workout_filepath)
            else:
                return ""
        except Exception as e:
            error = "error when storing workout file"
            appLog.exception(error)
            raise WorkoutException("error", error) from e

        new_workout.original_file = relative_path
        return absolute_workout_filepath

    def _get_archive_content(self) -> Union[BytesIO, IO[bytes]]:
        if not self.file:
            raise WorkoutNoFileException()

        # handle Python < 3.11, see:
        # - https://github.com/python/cpython/issues/70363
        # - https://docs.python.org/3.11/whatsnew/3.11.html#tempfile
        if not hasattr(self.file.stream, "seekable"):
            return BytesIO(self.file.getvalue())
        return self.file.stream

    def create_workout_from_file(
        self,
        extension: str,
        equipments: Union[List["Equipment"], None],
        workout_file: Optional["IO[bytes]"] = None,
        get_weather: bool = True,
    ) -> "Workout":
        """
        Return map absolute file path in order to delete file on error
        """
        if workout_file is None and self.file is None:
            raise WorkoutNoFileException()

        if extension == "kmz" and workout_file is None:
            workout_file = self._get_archive_content()

        workout_service = WORKOUT_FROM_FILE_SERVICES[extension](
            auth_user=self.auth_user,
            workout_file=(
                self.file.stream  # type: ignore
                if workout_file is None
                else workout_file
            ),
            sport=self.sport,
            stopped_speed_threshold=self.stopped_speed_threshold,
            get_weather=get_weather,
            workout=None,
        )

        # extract and calculate data from provided file
        try:
            new_workout = workout_service.process_workout()
        except (WorkoutExceedingValueException, WorkoutFileException) as e:
            appLog.exception(f"workout exception: {e!s}")
            db.session.rollback()
            raise e
        except Exception as e:
            appLog.exception(f"exception: {e!s}")
            db.session.rollback()
            if (
                "duplicate key value violates unique constraint "
                '"workout_id_start_date_unique"' in str(e)
            ):
                raise WorkoutFileException(
                    "error", "some segments have same start date"
                ) from e
            raise WorkoutException(
                "error", "error when processing workout"
            ) from e

        # store title, description and notes
        new_workout.title = self._get_workout_title(
            workout_service.workout_name, new_workout.workout_date
        )
        new_workout.description = self._get_workout_description(
            workout_service.workout_description
        )
        new_workout.notes = self._get_workout_notes()

        # add equipments if ids provided
        if equipments is not None:
            new_workout.equipments = equipments

        # update visibility
        new_workout.workout_visibility = (
            self.workouts_data.workout_visibility
            if self.workouts_data.workout_visibility
            else self.auth_user.workouts_visibility
        )
        new_workout.analysis_visibility = get_calculated_visibility(
            visibility=(
                self.workouts_data.analysis_visibility
                if self.workouts_data.analysis_visibility
                else self.auth_user.analysis_visibility
            ),
            parent_visibility=new_workout.workout_visibility,
        )
        new_workout.map_visibility = get_calculated_visibility(
            visibility=(
                self.workouts_data.map_visibility
                if self.workouts_data.map_visibility
                else self.auth_user.map_visibility
            ),
            parent_visibility=new_workout.analysis_visibility,
        )

        # store original workout file
        absolute_workout_filepath = self._store_file(
            new_workout,
            workout_file,
            extension=f".{extension}",
        )

        # generate and store map image
        map_filepath = self.get_file_path(
            workout_date=new_workout.workout_date.strftime(
                "%Y-%m-%d_%H-%M-%S"
            ),
            extension=".png",
        )
        new_workout.map = map_filepath
        absolute_map_filepath = get_absolute_file_path(map_filepath)
        try:
            workout_service.generate_map_image(
                map_filepath=absolute_map_filepath,
                coordinates=workout_service.coordinates,
            )
            new_workout.map_id = workout_service.get_map_hash(map_filepath)
        except Exception as e:
            if os.path.exists(absolute_map_filepath):
                os.remove(absolute_map_filepath)
            if os.path.exists(absolute_workout_filepath):
                os.remove(absolute_workout_filepath)
            raise WorkoutException(
                "error", "error when generating map image"
            ) from e
        db.session.commit()
        return new_workout

    def process_archive_content(
        self,
        archive_content: Union[BytesIO, IO[bytes]],
        files_to_process: List[str],
        equipments: Union[List["Equipment"], None],
        upload_task: Optional["UserTask"] = None,
        get_weather: bool = True,
    ) -> Tuple[List["Workout"], Dict]:
        if not files_to_process:
            raise WorkoutFileException(
                "error", "No files from archive to process"
            )
        appLog.debug(" > starting archive processing...")

        new_workouts = []
        errored_workouts = {}
        total_files = len(files_to_process)
        with zipfile.ZipFile(archive_content, "r") as zip_ref:
            for index, file in enumerate(files_to_process, start=1):
                appLog.debug(f"  - file {index}/{total_files}")
                try:
                    extension = self._get_extension(file)
                    file_content = zip_ref.open(file)
                    new_workout = self.create_workout_from_file(
                        extension, equipments, file_content, get_weather
                    )
                except Exception as e:
                    db.session.rollback()
                    error = e.args[0]
                    errored_workouts[file] = error
                    appLog.debug(f"    > error occurred: {error}")
                    if upload_task:
                        upload_task.progress = int(100 * index / total_files)
                        db.session.commit()
                    continue

                new_workouts.append(new_workout)
                appLog.debug("    > upload done")
                if upload_task:
                    upload_task.data = {
                        **upload_task.data,
                        "new_workouts_count": len(new_workouts),
                    }
                    upload_task.progress = int(100 * index / total_files)
                    db.session.commit()

        return new_workouts, errored_workouts


class WorkoutsFromFileCreationService(AbstractWorkoutsCreationService):
    def __init__(
        self,
        auth_user: "User",
        workouts_data: Dict,
        file: Optional["FileStorage"] = None,
    ):
        super().__init__(auth_user, workouts_data, file)

    def get_files_from_archive(self) -> List[str]:
        if not self.file:
            raise WorkoutNoFileException()
        files_to_process = []
        max_file_size = current_app.config["max_single_file_size"]
        try:
            with zipfile.ZipFile(self.file.stream, "r") as zip_ref:
                for zip_info in zip_ref.infolist():
                    if self._is_workout_file(zip_info.filename):
                        if zip_info.file_size > max_file_size:
                            raise WorkoutFileException(
                                "fail",
                                (
                                    "at least one file in zip archive "
                                    "exceeds size limit, "
                                    "please check the archive"
                                ),
                            )
                        files_to_process.append(zip_info.filename)

                        if (
                            len(files_to_process)
                            > current_app.config["file_limit_import"]
                        ):
                            raise WorkoutFileException(
                                "fail",
                                (
                                    "the number of files in the archive "
                                    "exceeds the limit"
                                ),
                            )

        except zipfile.BadZipFile as e:
            raise WorkoutFileException("error", "invalid zip file") from e

        if not files_to_process:
            raise WorkoutFileException(
                "fail", "archive does not contain valid workout files"
            )

        return files_to_process

    def add_workouts_upload_task(
        self,
        files_to_process: List[str],
        equipments: Union[List["Equipment"], None],
    ) -> str:
        from fittrackee.workouts.tasks import upload_workouts_archive

        if self.file is None:
            raise WorkoutNoFileException()
        if not files_to_process:
            raise WorkoutFileException(
                "error", "No files from archive to process"
            )
        if UserTask.query.filter(
            UserTask.user_id == self.auth_user.id,
            UserTask.task_type == "workouts_archive_upload",
            UserTask.aborted == False,  # noqa
            UserTask.errored == False,  # noqa
            UserTask.progress != 100,
        ).first():
            raise WorkoutException("invalid", "ongoing upload task exists")

        relative_path = os.path.join(
            self._get_user_path(),
            f"archive_{secrets.token_urlsafe(20)}.zip",
        )
        path = get_absolute_file_path(relative_path)
        self.file.stream.seek(0)
        self.file.save(path)

        upload_task = UserTask(
            user_id=self.auth_user.id,
            task_type="workouts_archive_upload",
            data={
                "workouts_data": asdict(self.workouts_data),
                "files_to_process": files_to_process,
                "new_workouts_count": 0,
                "equipment_ids": (
                    None
                    if equipments is None
                    else [equipment.id for equipment in equipments]
                ),
                "original_file_name": self.file.filename,
            },
            file_path=relative_path,
        )
        upload_task.errors = {
            "archive": None,
            "files": {},
        }
        upload_task.file_size = os.path.getsize(path)
        db.session.add(upload_task)
        db.session.commit()

        if current_app.config["TASKS_PROCESSING_AVAILABLE"]:
            message = upload_workouts_archive.send(task_id=upload_task.id)
            upload_task.message_id = message.message_id
            db.session.commit()

        return upload_task.short_id

    def process_zip_archive(
        self, equipments: Union[List["Equipment"], None]
    ) -> Tuple[List["Workout"], Dict]:
        if not self.file:
            raise WorkoutNoFileException()
        files_to_process = self.get_files_from_archive()
        if (
            len(files_to_process)
            > current_app.config["file_sync_limit_import"]
        ):
            task_short_id = self.add_workouts_upload_task(
                files_to_process, equipments
            )
            return [], {"task_short_id": task_short_id}

        new_workouts, errored_workouts = self.process_archive_content(
            archive_content=self._get_archive_content(),
            files_to_process=files_to_process,
            equipments=equipments,
        )
        return new_workouts, {
            "errored_workouts": errored_workouts,
            "task_short_id": None,
        }

    def process(self) -> Tuple[List["Workout"], Dict]:
        if self.file is None:
            raise WorkoutNoFileException()
        if self.file.filename is None:
            raise WorkoutFileException("error", "workout file has no filename")

        extension = self._get_extension(self.file.filename)
        if extension not in current_app.config["WORKOUT_ALLOWED_EXTENSIONS"]:
            raise WorkoutFileException(
                "error", "workout file invalid extension"
            )
        equipments = self.get_equipments()

        try:
            if extension == "zip":
                new_workouts, processing_output = self.process_zip_archive(
                    equipments
                )
                return new_workouts, processing_output
            else:
                new_workout = self.create_workout_from_file(
                    extension, equipments
                )
                return [new_workout], {
                    "task_short_id": None,
                    "errored_workouts": [],
                }
        except InvalidEquipmentsException as e:
            raise WorkoutException("error", str(e)) from e


class WorkoutsFromArchiveCreationAsyncService(AbstractWorkoutsCreationService):
    def __init__(
        self,
        task_id: int,
    ) -> None:
        upload_task = UserTask.query.filter_by(id=task_id).first()
        if (
            not upload_task
            or upload_task.task_type != "workouts_archive_upload"
        ):
            raise WorkoutException("error", "no import task found")
        auth_user = User.query.filter_by(id=upload_task.user_id).one()
        import_data = upload_task.data
        super().__init__(auth_user, import_data["workouts_data"], None)
        self.file_path = get_absolute_file_path(upload_task.file_path)
        self.files_to_process = import_data["files_to_process"]
        self.equipment_ids = import_data["equipment_ids"]
        self.upload_task = upload_task

    def process(self) -> Tuple[List["Workout"], Dict]:
        if self.equipment_ids is None:
            equipments = None
        elif not self.equipment_ids:
            equipments = []
        else:
            equipments = Equipment.query.filter(
                Equipment.id.in_(self.equipment_ids)
            ).all()

        errored_workouts: Dict = {}
        new_workouts: List["Workout"] = []
        try:
            with open(self.file_path, "rb") as zip_file:
                new_workouts, errored_workouts = self.process_archive_content(
                    archive_content=zip_file,
                    files_to_process=self.files_to_process,
                    equipments=equipments,
                    upload_task=self.upload_task,
                    get_weather=False,
                )
        except FileNotFoundError:
            self.upload_task.errored = True
            self.upload_task.errors = {
                **self.upload_task.errors,
                "archive": "archive file does not exist",
            }
        except Exception:
            self.upload_task.errored = True
            self.upload_task.errors = {
                **self.upload_task.errors,
                "archive": "error during archive processing",
            }

        if errored_workouts:
            self.upload_task.errored = True
            self.upload_task.errors = {
                **self.upload_task.errors,
                "files": errored_workouts,
            }
        db.session.commit()

        if os.path.exists(self.file_path):
            os.remove(self.file_path)

        notification = Notification(
            from_user_id=self.upload_task.user_id,
            to_user_id=self.upload_task.user_id,
            created_at=datetime.now(tz=timezone.utc),
            event_object_id=self.upload_task.id,
            event_type=self.upload_task.task_type,
        )
        db.session.add(notification)
        db.session.commit()

        return new_workouts, {
            "errored_workouts": errored_workouts,
            "task_short_id": self.upload_task.short_id,
        }
