import logging

from sqlalchemy import delete, select
from sqlalchemy.exc import (
    IntegrityError,
    SQLAlchemyError,
)
from sqlalchemy.orm import Session, joinedload

from media_manager.exceptions import ConflictError, NotFoundError
from media_manager.movies.models import Movie, MovieFile, MovieRequest
from media_manager.movies.schemas import (
    Movie as MovieSchema,
)
from media_manager.movies.schemas import (
    MovieFile as MovieFileSchema,
)
from media_manager.movies.schemas import (
    MovieId,
    MovieRequestId,
)
from media_manager.movies.schemas import (
    MovieRequest as MovieRequestSchema,
)
from media_manager.movies.schemas import (
    MovieTorrent as MovieTorrentSchema,
)
from media_manager.movies.schemas import (
    RichMovieRequest as RichMovieRequestSchema,
)
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId

log = logging.getLogger(__name__)


class MovieRepository:
    """
    Repository for managing movies in the database.
    Provides methods to retrieve, save, and delete movies.
    """

    def __init__(self, db: Session) -> None:
        self.db = db

    def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema:
        """
        Retrieve a movie by its ID.

        :param movie_id: The ID of the movie to retrieve.
        :return: A Movie object if found.
        :raises NotFoundError: If the movie with the given ID is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = select(Movie).where(Movie.id == movie_id)
            result = self.db.execute(stmt).unique().scalar_one_or_none()
            if not result:
                msg = f"Movie with id {movie_id} not found."
                raise NotFoundError(msg)
            return MovieSchema.model_validate(result)
        except SQLAlchemyError:
            log.exception(f"Database error while retrieving movie {movie_id}")
            raise

    def get_movie_by_external_id(
        self, external_id: int, metadata_provider: str
    ) -> MovieSchema:
        """
        Retrieve a movie by its external ID.

        :param external_id: The ID of the movie to retrieve.
        :param metadata_provider: The metadata provider associated with the ID.
        :return: A Movie object if found.
        :raises NotFoundError: If the movie with the given external ID and provider is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = (
                select(Movie)
                .where(Movie.external_id == external_id)
                .where(Movie.metadata_provider == metadata_provider)
            )
            result = self.db.execute(stmt).unique().scalar_one_or_none()
            if not result:
                msg = f"Movie with external_id {external_id} and provider {metadata_provider} not found."
                raise NotFoundError(msg)
            return MovieSchema.model_validate(result)
        except SQLAlchemyError:
            log.exception(
                f"Database error while retrieving movie by external_id {external_id}"
            )
            raise

    def get_movies(self) -> list[MovieSchema]:
        """
        Retrieve all movies from the database.

        :return: A list of Movie objects.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = select(Movie)
            results = self.db.execute(stmt).scalars().unique().all()
            return [MovieSchema.model_validate(movie) for movie in results]
        except SQLAlchemyError:
            log.exception("Database error while retrieving all movies")
            raise

    def save_movie(self, movie: MovieSchema) -> MovieSchema:
        """
        Save a new movie or update an existing one in the database.

        :param movie: The Movie object to save.
        :return: The saved Movie object.
        :raises ValueError: If a movie with the same primary key already exists (on insert).
        :raises SQLAlchemyError: If a database error occurs.
        """
        log.debug(f"Attempting to save movie: {movie.name} (ID: {movie.id})")
        db_movie = self.db.get(Movie, movie.id) if movie.id else None

        if db_movie:  # Update existing movie
            log.debug(f"Updating existing movie with ID: {movie.id}")
            db_movie.external_id = movie.external_id
            db_movie.metadata_provider = movie.metadata_provider
            db_movie.name = movie.name
            db_movie.overview = movie.overview
            db_movie.year = movie.year
            db_movie.original_language = movie.original_language
            db_movie.imdb_id = movie.imdb_id
        else:  # Insert new movie
            log.debug(f"Creating new movie: {movie.name}")
            db_movie = Movie(**movie.model_dump())
            self.db.add(db_movie)

        try:
            self.db.commit()
            self.db.refresh(db_movie)
            log.info(f"Successfully saved movie: {db_movie.name} (ID: {db_movie.id})")
            return MovieSchema.model_validate(db_movie)
        except IntegrityError as e:
            self.db.rollback()
            log.exception(f"Integrity error while saving movie {movie.name}")
            msg = (
                f"Movie with this primary key or unique constraint violation: {e.orig}"
            )
            raise ConflictError(msg) from e
        except SQLAlchemyError:
            self.db.rollback()
            log.exception(f"Database error while saving movie {movie.name}")
            raise

    def delete_movie(self, movie_id: MovieId) -> None:
        """
        Delete a movie by its ID.

        :param movie_id: The ID of the movie to delete.
        :raises NotFoundError: If the movie with the given ID is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        log.debug(f"Attempting to delete movie with id: {movie_id}")
        try:
            movie = self.db.get(Movie, movie_id)
            if not movie:
                log.warning(f"Movie with id {movie_id} not found for deletion.")
                msg = f"Movie with id {movie_id} not found."
                raise NotFoundError(msg)
            self.db.delete(movie)
            self.db.commit()
            log.info(f"Successfully deleted movie with id: {movie_id}")
        except SQLAlchemyError:
            self.db.rollback()
            log.exception(f"Database error while deleting movie {movie_id}")
            raise

    def add_movie_request(
        self, movie_request: MovieRequestSchema
    ) -> MovieRequestSchema:
        """
        Adds a Movie to the MovieRequest table, which marks it as requested.

        :param movie_request: The MovieRequest object to add.
        :return: The added MovieRequest object.
        :raises IntegrityError: If a similar request already exists or violates constraints.
        :raises SQLAlchemyError: If a database error occurs.
        """
        log.debug(f"Adding movie request: {movie_request.model_dump_json()}")
        db_model = MovieRequest(
            id=movie_request.id,
            movie_id=movie_request.movie_id,
            requested_by_id=movie_request.requested_by.id
            if movie_request.requested_by
            else None,
            authorized_by_id=movie_request.authorized_by.id
            if movie_request.authorized_by
            else None,
            wanted_quality=movie_request.wanted_quality,
            min_quality=movie_request.min_quality,
            authorized=movie_request.authorized,
        )
        try:
            self.db.add(db_model)
            self.db.commit()
            self.db.refresh(db_model)
            log.info(f"Successfully added movie request with id: {db_model.id}")
            return MovieRequestSchema.model_validate(db_model)
        except IntegrityError:
            self.db.rollback()
            log.exception("Integrity error while adding movie request")
            raise
        except SQLAlchemyError:
            self.db.rollback()
            log.exception("Database error while adding movie request")
            raise

    def set_movie_library(self, movie_id: MovieId, library: str) -> None:
        """
        Sets the library for a movie.

        :param movie_id: The ID of the movie to update.
        :param library: The library path to set for the movie.
        :raises NotFoundError: If the movie with the given ID is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            movie = self.db.get(Movie, movie_id)
            if not movie:
                msg = f"movie with id {movie_id} not found."
                raise NotFoundError(msg)
            movie.library = library
            self.db.commit()
        except SQLAlchemyError:
            self.db.rollback()
            log.exception(f"Database error setting library for movie {movie_id}")
            raise

    def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
        """
        Removes a MovieRequest by its ID.

        :param movie_request_id: The ID of the movie request to delete.
        :raises NotFoundError: If the movie request is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = delete(MovieRequest).where(MovieRequest.id == movie_request_id)
            result = self.db.execute(stmt)
            if result.rowcount == 0:
                self.db.rollback()
                msg = f"movie request with id {movie_request_id} not found."
                raise NotFoundError(msg)
            self.db.commit()
            # Successfully deleted movie request with id: {movie_request_id}
        except SQLAlchemyError:
            self.db.rollback()
            log.exception(
                f"Database error while deleting movie request {movie_request_id}"
            )
            raise

    def get_movie_requests(self) -> list[RichMovieRequestSchema]:
        """
        Retrieve all movie requests.

        :return: A list of RichMovieRequest objects.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = select(MovieRequest).options(
                joinedload(MovieRequest.requested_by),
                joinedload(MovieRequest.authorized_by),
                joinedload(MovieRequest.movie),
            )
            results = self.db.execute(stmt).scalars().unique().all()
            return [RichMovieRequestSchema.model_validate(x) for x in results]
        except SQLAlchemyError:
            log.exception("Database error while retrieving movie requests")
            raise

    def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
        """
        Adds a movie file record to the database.

        :param movie_file: The MovieFile object to add.
        :return: The added MovieFile object.
        :raises IntegrityError: If the record violates constraints.
        :raises SQLAlchemyError: If a database error occurs.
        """
        db_model = MovieFile(**movie_file.model_dump())
        try:
            self.db.add(db_model)
            self.db.commit()
            self.db.refresh(db_model)
            return MovieFileSchema.model_validate(db_model)
        except IntegrityError:
            self.db.rollback()
            log.exception("Integrity error while adding movie file")
            raise
        except SQLAlchemyError:
            self.db.rollback()
            log.exception("Database error while adding movie file")
            raise

    def remove_movie_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
        """
        Removes movie file records associated with a given torrent ID.

        :param torrent_id: The ID of the torrent whose movie files are to be removed.
        :return: The number of movie files removed.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = delete(MovieFile).where(MovieFile.torrent_id == torrent_id)
            result = self.db.execute(stmt)
            self.db.commit()
        except SQLAlchemyError:
            self.db.rollback()
            log.exception(
                f"Database error removing movie files for torrent_id {torrent_id}"
            )
            raise

        return result.rowcount

    def get_movie_files_by_movie_id(self, movie_id: MovieId) -> list[MovieFileSchema]:
        """
        Retrieve all movie files for a given movie ID.

        :param movie_id: The ID of the movie.
        :return: A list of MovieFile objects.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = select(MovieFile).where(MovieFile.movie_id == movie_id)
            results = self.db.execute(stmt).scalars().all()
            return [MovieFileSchema.model_validate(sf) for sf in results]
        except SQLAlchemyError:
            log.exception(
                f"Database error retrieving movie files for movie_id {movie_id}"
            )
            raise

    def get_torrents_by_movie_id(self, movie_id: MovieId) -> list[MovieTorrentSchema]:
        """
        Retrieve all torrents associated with a given movie ID.

        :param movie_id: The ID of the movie.
        :return: A list of Torrent objects.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = (
                select(Torrent, MovieFile.file_path_suffix)
                .distinct()
                .join(MovieFile, MovieFile.torrent_id == Torrent.id)
                .where(MovieFile.movie_id == movie_id)
            )
            results = self.db.execute(stmt).all()
            formatted_results = []
            for torrent, file_path_suffix in results:
                movie_torrent = MovieTorrentSchema(
                    torrent_id=torrent.id,
                    torrent_title=torrent.title,
                    status=torrent.status,
                    quality=torrent.quality,
                    imported=torrent.imported,
                    file_path_suffix=file_path_suffix,
                    usenet=torrent.usenet,
                )
                formatted_results.append(movie_torrent)

        except SQLAlchemyError:
            log.exception(f"Database error retrieving torrents for movie_id {movie_id}")
            raise

        return formatted_results

    def get_all_movies_with_torrents(self) -> list[MovieSchema]:
        """
        Retrieve all movies that are associated with a torrent, ordered alphabetically by movie name.

        :return: A list of Movie objects.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = (
                select(Movie)
                .distinct()
                .join(MovieFile, Movie.id == MovieFile.movie_id)
                .join(Torrent, MovieFile.torrent_id == Torrent.id)
                .order_by(Movie.name)
            )
            results = self.db.execute(stmt).scalars().unique().all()
            return [MovieSchema.model_validate(movie) for movie in results]
        except SQLAlchemyError:
            log.exception("Database error retrieving all movies with torrents")
            raise

    def get_movie_request(self, movie_request_id: MovieRequestId) -> MovieRequestSchema:
        """
        Retrieve a movie request by its ID.

        :param movie_request_id: The ID of the movie request.
        :return: A MovieRequest object.
        :raises NotFoundError: If the movie request is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            request = self.db.get(MovieRequest, movie_request_id)
            if not request:
                msg = f"Movie request with id {movie_request_id} not found."
                raise NotFoundError(msg)
            return MovieRequestSchema.model_validate(request)
        except SQLAlchemyError:
            log.exception(f"Database error retrieving movie request {movie_request_id}")
            raise

    def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
        """
        Retrieve a movie by a torrent ID.

        :param torrent_id: The ID of the torrent to retrieve the movie for.
        :return: A Movie object.
        :raises NotFoundError: If the movie for the given torrent ID is not found.
        :raises SQLAlchemyError: If a database error occurs.
        """
        try:
            stmt = (
                select(Movie)
                .join(MovieFile, Movie.id == MovieFile.movie_id)
                .where(MovieFile.torrent_id == torrent_id)
            )
            result = self.db.execute(stmt).unique().scalar_one_or_none()
            if not result:
                msg = f"Movie for torrent_id {torrent_id} not found."
                raise NotFoundError(msg)
            return MovieSchema.model_validate(result)
        except SQLAlchemyError:
            log.exception(f"Database error retrieving movie by torrent_id {torrent_id}")
            raise

    def update_movie_attributes(
        self,
        movie_id: MovieId,
        name: str | None = None,
        overview: str | None = None,
        year: int | None = None,
        imdb_id: str | None = None,
    ) -> MovieSchema:
        """
        Update attributes of an existing movie.

        :param imdb_id: The new IMDb ID for the movie.
        :param movie_id: The ID of the movie to update.
        :param name: The new name for the movie.
        :param overview: The new overview for the movie.
        :param year: The new year for the movie.
        :return: The updated MovieSchema object.
        """
        db_movie = self.db.get(Movie, movie_id)
        if not db_movie:
            msg = f"Movie with id {movie_id} not found."
            raise NotFoundError(msg)

        updated = False
        if name is not None and db_movie.name != name:
            db_movie.name = name
            updated = True
        if overview is not None and db_movie.overview != overview:
            db_movie.overview = overview
            updated = True
        if year is not None and db_movie.year != year:
            db_movie.year = year
            updated = True
        if imdb_id is not None and db_movie.imdb_id != imdb_id:
            db_movie.imdb_id = imdb_id
            updated = True

        if updated:
            self.db.commit()
            self.db.refresh(db_movie)
        return MovieSchema.model_validate(db_movie)
