import re

import click

from ffx.model.media_tag import MediaTag
from ffx.model.pattern import Pattern
from ffx.model.track import Track
from ffx.model.track_tag import TrackTag
from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition


class DuplicatePatternMatchError(click.ClickException):
    pass


class InvalidPatternSchemaError(click.ClickException):
    pass


class PatternController:
    _compiled_regex_cache: dict[str, re.Pattern] = {}

    def __init__(self, context):

        self.context = context
        self.Session = self.context["database"]["session"]

        self.__configurationData = self.context["config"].getData()

        metadataConfiguration = (
            self.__configurationData["metadata"]
            if "metadata" in self.__configurationData.keys()
            else {}
        )

        self.__removeTrackKeys = (
            metadataConfiguration["streams"]["remove"]
            if "streams" in metadataConfiguration.keys()
            and "remove" in metadataConfiguration["streams"].keys()
            else []
        )
        self.__ignoreTrackKeys = (
            metadataConfiguration["streams"]["ignore"]
            if "streams" in metadataConfiguration.keys()
            and "ignore" in metadataConfiguration["streams"].keys()
            else []
        )

    @classmethod
    def _clear_regex_cache(cls):
        cls._compiled_regex_cache.clear()

    @classmethod
    def _compile_pattern_expression(cls, pattern_id: int, expression: str) -> re.Pattern:
        expression_text = str(expression)
        compiled = cls._compiled_regex_cache.get(expression_text)
        if compiled is None:
            try:
                compiled = re.compile(expression_text)
            except re.error as ex:
                raise click.ClickException(
                    f"Pattern #{pattern_id} contains an invalid regex {expression_text!r}: {ex}"
                )
            cls._compiled_regex_cache[expression_text] = compiled
        return compiled

    def _coerce_pattern_fields(self, patternObj):
        return {
            "show_id": int(patternObj["show_id"]),
            "pattern": str(patternObj["pattern"]),
            "quality": int(patternObj.get("quality", 0) or 0),
            "notes": str(patternObj.get("notes", "")),
        }

    def _coerce_media_tags(self, mediaTags):
        return {
            str(tagKey): str(tagValue)
            for tagKey, tagValue in (mediaTags or {}).items()
        }

    def _normalize_track_descriptors(self, trackDescriptors):
        if trackDescriptors is None:
            raise InvalidPatternSchemaError(
                "Patterns must define at least one track before they can be stored."
            )

        normalized_descriptors = []
        for trackDescriptor in trackDescriptors:
            if type(trackDescriptor) is not TrackDescriptor:
                raise TypeError(
                    "PatternController: All track descriptors are required to be of type TrackDescriptor"
                )
            normalized_descriptors.append(trackDescriptor)

        if not normalized_descriptors:
            raise InvalidPatternSchemaError(
                "Patterns must define at least one track before they can be stored."
            )

        normalized_descriptors = sorted(
            normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
        )

        index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
        expected_indexes = set(range(len(normalized_descriptors)))
        if index_set != expected_indexes:
            raise click.ClickException(
                "Pattern tracks must use a contiguous zero-based index order."
            )

        return normalized_descriptors

    def _ensure_unique_pattern_definition(
        self,
        session,
        show_id: int,
        pattern_expression: str,
        exclude_pattern_id: int | None = None,
    ):
        query = session.query(Pattern).filter(
            Pattern.show_id == show_id,
            Pattern.pattern == pattern_expression,
        )
        if exclude_pattern_id is not None:
            query = query.filter(Pattern.id != int(exclude_pattern_id))

        existing_pattern = query.first()
        if existing_pattern is not None:
            raise click.ClickException(
                f"Pattern {pattern_expression!r} already exists for show #{show_id}."
            )

    def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
        track = Track(
            track_type=int(trackDescriptor.getType().index()),
            codec_name=str(trackDescriptor.getCodec().identifier()),
            index=int(trackDescriptor.getIndex()),
            source_index=int(trackDescriptor.getSourceIndex()),
            disposition_flags=int(
                TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
            ),
            audio_layout=trackDescriptor.getAudioLayout().index(),
        )

        for tagKey, tagValue in trackDescriptor.getTags().items():
            if tagKey in self.__ignoreTrackKeys or tagKey in self.__removeTrackKeys:
                continue
            track.track_tags.append(TrackTag(key=str(tagKey), value=str(tagValue)))

        return track

    def _replace_pattern_schema(
        self,
        session,
        pattern: Pattern,
        mediaTags: dict[str, str],
        trackDescriptors: list[TrackDescriptor],
    ):
        for mediaTag in list(pattern.media_tags):
            session.delete(mediaTag)
        for track in list(pattern.tracks):
            session.delete(track)
        session.flush()

        for tagKey, tagValue in mediaTags.items():
            pattern.media_tags.append(MediaTag(key=str(tagKey), value=str(tagValue)))

        for trackDescriptor in trackDescriptors:
            pattern.tracks.append(self._build_track_row(trackDescriptor))

    def _validate_persisted_pattern(self, pattern: Pattern):
        if not pattern.tracks:
            raise InvalidPatternSchemaError(
                f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
            )

    def savePatternSchema(
        self,
        patternObj,
        trackDescriptors,
        mediaTags=None,
        patternId: int | None = None,
    ) -> int:
        fields = self._coerce_pattern_fields(patternObj)
        normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
        normalized_tags = self._coerce_media_tags(mediaTags)
        session = None

        try:
            session = self.Session()
            self._ensure_unique_pattern_definition(
                session,
                fields["show_id"],
                fields["pattern"],
                exclude_pattern_id=patternId,
            )

            if patternId is None:
                pattern = Pattern(
                    show_id=fields["show_id"],
                    pattern=fields["pattern"],
                    quality=fields["quality"],
                    notes=fields["notes"],
                )
                session.add(pattern)
                session.flush()
            else:
                pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
                if pattern is None:
                    raise click.ClickException(
                        f"PatternController.savePatternSchema(): Pattern #{patternId} not found"
                    )
                pattern.show_id = fields["show_id"]
                pattern.pattern = fields["pattern"]
                pattern.quality = fields["quality"]
                pattern.notes = fields["notes"]

            self._replace_pattern_schema(
                session,
                pattern,
                normalized_tags,
                normalized_tracks,
            )

            session.commit()
            self._clear_regex_cache()
            return pattern.getId()

        except click.ClickException:
            raise
        except Exception as ex:
            raise click.ClickException(
                f"PatternController.savePatternSchema(): {repr(ex)}"
            )
        finally:
            if session is not None:
                session.close()

    def addPattern(self, patternObj, trackDescriptors=None, mediaTags=None):
        return self.savePatternSchema(
            patternObj,
            trackDescriptors=trackDescriptors,
            mediaTags=mediaTags,
        )

    def updatePattern(self, patternId, patternObj):

        fields = self._coerce_pattern_fields(patternObj)
        session = None

        try:
            session = self.Session()
            pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()

            if pattern is not None:
                self._ensure_unique_pattern_definition(
                    session,
                    fields["show_id"],
                    fields["pattern"],
                    exclude_pattern_id=patternId,
                )
                self._validate_persisted_pattern(pattern)

                pattern.show_id = fields["show_id"]
                pattern.pattern = fields["pattern"]
                pattern.quality = fields["quality"]
                pattern.notes = fields["notes"]

                session.commit()
                self._clear_regex_cache()
                return True

            return False

        except click.ClickException:
            raise
        except Exception as ex:
            raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()

    def findPattern(self, patternObj):
        session = None

        try:
            session = self.Session()
            pattern = (
                session.query(Pattern)
                .filter(
                    Pattern.show_id == int(patternObj["show_id"]),
                    Pattern.pattern == str(patternObj["pattern"]),
                )
                .first()
            )

            if pattern is not None:
                return int(pattern.id)
            return None

        except Exception as ex:
            raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()

    def getPatternsForShow(self, showId: int) -> list[Pattern]:

        if type(showId) is not int:
            raise ValueError(
                "PatternController.getPatternsForShow(): Argument showId is required to be of type int"
            )

        session = None
        try:
            session = self.Session()
            return (
                session.query(Pattern)
                .filter(Pattern.show_id == int(showId))
                .order_by(Pattern.id)
                .all()
            )

        except Exception as ex:
            raise click.ClickException(f"PatternController.getPatternsForShow(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()

    def getPattern(self, patternId: int):

        if type(patternId) is not int:
            raise ValueError(
                "PatternController.getPattern(): Argument patternId is required to be of type int"
            )

        session = None
        try:
            session = self.Session()
            return session.query(Pattern).filter(Pattern.id == int(patternId)).first()

        except Exception as ex:
            raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()

    def deletePattern(self, patternId):
        session = None
        try:
            session = self.Session()
            pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()

            if pattern is not None:
                session.delete(pattern)
                session.commit()
                self._clear_regex_cache()
                return True
            return False

        except Exception as ex:
            raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()

    def matchFilename(self, filename: str) -> dict:
        """Return {'match': regex match, 'pattern': Pattern} or {} when unmatched."""
        session = None

        try:
            session = self.Session()
            matches = []
            query = session.query(Pattern).order_by(Pattern.show_id, Pattern.id)

            for pattern in query.all():
                compiled = self._compile_pattern_expression(
                    pattern.getId(),
                    pattern.getPattern(),
                )
                patternMatch = compiled.search(str(filename))
                if patternMatch is None:
                    continue

                self._validate_persisted_pattern(pattern)
                matches.append({"match": patternMatch, "pattern": pattern})

            if not matches:
                return {}

            if len(matches) > 1:
                duplicateDescriptions = ", ".join(
                    [
                        f"show #{match['pattern'].getShowId()} pattern #{match['pattern'].getId()} {match['pattern'].getPattern()!r}"
                        for match in matches
                    ]
                )
                raise DuplicatePatternMatchError(
                    f"Filename {filename!r} matched more than one pattern: {duplicateDescriptions}"
                )

            return matches[0]

        except click.ClickException:
            raise
        except Exception as ex:
            raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}")
        finally:
            if session is not None:
                session.close()
