import codecs
import logging
import urllib.parse
from abc import ABC, abstractmethod
from io import SEEK_END, SEEK_SET
from pathlib import Path
from typing import BinaryIO, Callable, List, Optional, Tuple

import charset_normalizer
from charset_normalizer import CharsetMatch

from ggshield.utils.git_shell import Filemode


logger = logging.getLogger(__name__)


# Our worse encoding (UTF-32) would take 4 bytes to encode ASCII, where UTF-8 would take
# only 1. If the file is longer than byte_size / UTF8_TO_WORSE_OTHER_ENCODING_RATIO, no
# need to look into it: it's too big.
UTF8_TO_WORSE_OTHER_ENCODING_RATIO = 4


class DecodeError(Exception):
    """
    Raised when a Scannable cannot determine the encoding of its content.

    Similar to UnicodeDecodeError, but easier to instantiate.
    """

    pass


class Scannable(ABC):
    """Base class for content that can be scanned by GGShield"""

    def __init__(self, filemode: Filemode = Filemode.FILE):
        self.filemode = filemode

    @property
    @abstractmethod
    def url(self) -> str:
        """Act as a unique identifier for the Scannable. May use custom protocols if
        required."""
        raise NotImplementedError

    @property
    @abstractmethod
    def filename(self) -> str:
        """To avoid breakage with the rest of the code base, implementations currently
        return the URL or path of the instance for now, but it should really return
        just the filename, or be removed."""
        # TODO: make this really return the filename, or remove it
        raise NotImplementedError

    @property
    @abstractmethod
    def path(self) -> Path:
        raise NotImplementedError

    @abstractmethod
    def is_longer_than(self, max_utf8_encoded_size: int) -> bool:
        """Return true if the length of the *utf-8 encoded* content is greater than
        `max_utf8_encoded_size`.
        When possible, implementations must try to answer this without reading all
        content.
        Raise `DecodeError` if the content cannot be decoded.
        """
        raise NotImplementedError

    @property
    @abstractmethod
    def content(self) -> str:
        """Return the decoded content of the scannable"""
        raise NotImplementedError

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} url={self.url} filemode={self.filemode}>"

    @staticmethod
    def _decode_bytes(
        raw_document: bytes, charset_match: Optional[CharsetMatch] = None
    ) -> Tuple[str, int]:
        """Low level helper function to decode bytes using `charset_match`. If
        `charset_match` is not provided, tries to determine it itself.

        Returns a tuple of (decoded_content, utf8_encoded_size).

        Raises DecodeError if the document cannot be decoded."""
        if charset_match is None:
            charset_match = charset_normalizer.from_bytes(raw_document).best()
            if charset_match is None:
                # This means we were not able to detect the encoding
                raise DecodeError

        # Special case for utf_8 + BOM: `bytes.decode()` does not skip the BOM, so do it
        # ourselves
        if charset_match.encoding == "utf_8" and raw_document.startswith(
            codecs.BOM_UTF8
        ):
            raw_document = raw_document[len(codecs.BOM_UTF8) :]
        content = raw_document.decode(charset_match.encoding, errors="replace")

        if charset_match.encoding in {"utf_8", "ascii"}:
            # The document is already in UTF-8, no need to encode it as UTF-8 to
            # determine UTF-8 encoded size.
            utf8_encoded_size = len(raw_document)
        else:
            utf8_encoded_size = len(content.encode(errors="replace"))

        return content, utf8_encoded_size

    @staticmethod
    def METHOD_NAME(
        fp: BinaryIO, max_utf8_encoded_size: int
    ) -> Tuple[bool, Optional[str], Optional[int]]:
        """Helper function to implement is_longer_than() for file-based Scannable classes.

        Returns a tuple of:
        - True if file is longer than `size`, False otherwise
        - The decoded content as a string if the file has been fully read, None otherwise
        - The utf8-encoded size if we know it, None otherwise

        Raises DecodeError if the file cannot be decoded.
        """
        # Get the byte size
        assert fp.seekable()
        byte_size = fp.seek(0, SEEK_END)

        if byte_size > max_utf8_encoded_size * UTF8_TO_WORSE_OTHER_ENCODING_RATIO:
            # Even if the file used the worst encoding (UTF-32), encoding the content of
            # this file as UTF-8 would produce a file longer than
            # `max_utf8_encoded_size`, so bail out
            return True, None, None

        # Determine the encoding
        fp.seek(0, SEEK_SET)
        charset_matches = charset_normalizer.from_fp(fp)
        charset_match = charset_matches.best()
        if charset_match is None:
            raise DecodeError

        if charset_match.encoding in {"utf_8", "ascii"}:
            # Shortcut: the content is already in UTF-8 (or ASCII, which is a subset of
            # utf-8), no need to decode anything
            return byte_size > max_utf8_encoded_size, None, byte_size

        # We can't know if the file is longer without reading its content, do it now
        fp.seek(0, SEEK_SET)
        content, utf8_encoded_size = Scannable._decode_bytes(fp.read(), charset_match)
        if utf8_encoded_size > max_utf8_encoded_size:
            return True, None, utf8_encoded_size
        else:
            # We read the whole file, keep it
            return False, content, utf8_encoded_size


class StringScannable(Scannable):
    """Implementation of Scannable for content already loaded in memory"""

    def __init__(self, url: str, content: str, filemode: Filemode = Filemode.FILE):
        super().__init__(filemode)
        self._url = url
        self._path: Optional[Path] = None
        self._content = content
        self._utf8_encoded_size = None

    @property
    def url(self) -> str:
        return self._url

    @property
    def filename(self) -> str:
        return str(self._url)

    @property
    def path(self) -> Path:
        if self._path is None:
            result = urllib.parse.urlparse(self._url)
            self._path = Path(result.path)
        return self._path

    def is_longer_than(self, max_utf8_encoded_size: int) -> bool:
        if self._utf8_encoded_size is None:
            self._utf8_encoded_size = len(self._content.encode(errors="replace"))
        return self._utf8_encoded_size > max_utf8_encoded_size

    @property
    def content(self) -> str:
        return self._content


class Files:
    """
    Files is a list of files. Useful for directory scanning.

    TODO: Rename to something like ScannableCollection: this class is no longer limited
    to holding File instances.
    """

    def __init__(self, files: List[Scannable]):
        self._files = files

    @property
    def files(self) -> List[Scannable]:
        """The list of files owned by this instance. The same filename can appear twice,
        in case of a merge commit."""
        return self._files

    @property
    def paths(self) -> List[Path]:
        """Convenience property to list paths in the same order as files"""
        return [x.path for x in self.files]

    def __repr__(self) -> str:
        return f"<Files files={self.files}>"

    def apply_filter(self, filter_func: Callable[[Scannable], bool]) -> "Files":
        return Files([file for file in self.files if filter_func(file)])