import os
import math
import hashlib
from typing import BinaryIO, Iterator, List, Optional, Tuple, cast

from ... import utils


HASH_TABLES_SIZE = 0x0400


class EndOfInputError(Exception):
    pass


class AppBlockReader:
    def __init__(self, h3: Optional[bytes], app: BinaryIO, content_hash: bytes, real_app_size: int, tmd_app_size: int, verify: bool = True):
        self._app = app
        self._content_hash = content_hash
        self._real_app_size = real_app_size
        self._tmd_app_size = tmd_app_size
        self._verify = verify

        assert self._real_app_size >= self._tmd_app_size and self._tmd_app_size > 0

        self._h3_table = h3
        if self._h3_table and self._verify:
            utils.crypto.verify_sha1(self._h3_table, content_hash)
        self._is_hashed = self._h3_table is not None

        self.data_size = 0xfc00  # size of data blocks
        self.block_size = self.data_size  # size of physical blocks (data or (hash + data))
        if self._is_hashed:
            self.block_size += HASH_TABLES_SIZE

        self._curr_block = 0
        self._unhashed_data = None  # type: Optional[List[bytes]]

    def write_all(self, output: BinaryIO) -> None:
        '''
        Writes the entire .app file to the provided output stream
        '''

        # calculate total number of blocks
        num_blocks = math.ceil(self._real_app_size / self.block_size)

        # read and write each block
        for _ in range(num_blocks - self._curr_block):
            for data in self.load_next_block():
                output.write(data)

    def load_block(self, block_index: int) -> Tuple[bytes, bytes]:
        '''
        Loads a single block at the given index
        '''

        self.__seek_to_index(block_index)
        return self.load_next_block()

    def load_next_block(self) -> Tuple[bytes, bytes]:
        '''
        Loads the next block
        '''

        # choose hashed/unhashed based on presence of h3 table
        if self._is_hashed:
            return self.__load_next_block_hashed()
        else:
            return self.__load_next_block_unhashed()

    def _read(self, length: int, check_length: bool = True) -> bytes:
        data = self._app.read(length)
        if check_length:
            if len(data) != length:
                raise EndOfInputError(self._curr_block)
        return data

    def _init_iv(self, iv: bytes) -> None:
        pass

    def _init_iv_unhashed(self) -> None:
        pass

    def __load_next_block_hashed(self) -> Tuple[bytes, bytes]:
        '''
        Internal function for loading the next block in a hashed .app file
        '''

        # load hash tables
        self._init_iv(bytes(16))
        hash_table_data = self._read(HASH_TABLES_SIZE)
        # split into tables
        h0_table, h1_table, h2_table = utils.misc.chunk(hash_table_data[:20 * 16 * 3], 20 * 16)

        # obtain current hashes from tables, verify tree
        if self._verify:
            h3_table = cast(bytes, self._h3_table)
            h3_hash = utils.misc.get_chunk(h3_table, self._curr_block >> 12 & 0xf, 20)
            h2_hash = utils.misc.get_chunk(h2_table, self._curr_block >> 8 & 0xf, 20)
            h1_hash = utils.misc.get_chunk(h1_table, self._curr_block >> 4 & 0xf, 20)
            utils.crypto.verify_sha1(h2_table, h3_hash)
            utils.crypto.verify_sha1(h1_table, h2_hash)
            utils.crypto.verify_sha1(h0_table, h1_hash)

        h0_hash = utils.misc.get_chunk(h0_table, self._curr_block & 0xf, 20)

        # load content
        self._init_iv(h0_hash[:16])
        app_data = self._read(self.data_size)
        if self._verify:
            utils.crypto.verify_sha1(app_data, h0_hash)
        self._curr_block += 1
        return (hash_table_data, app_data)

    def __load_next_block_unhashed(self) -> Tuple[bytes, bytes]:
        '''
        Internal function for loading the next block in an unhashed .app file (i.e. without a corresponding .h3 file)
        '''

        # this part is an implementation detail and not really relevant to the actual file structure;
        # the entire file is processed at once, as the hash is calculated over the entire file.
        # this rests on the assumption that unhashed .app files will generally not be too large

        if not self._unhashed_data:
            # load entire file
            assert self._real_app_size < 128 * 1024 * 1024  # 128MB, arbitrary limit to avoid using too much memory

            self._unhashed_data = []
            self._init_iv_unhashed()
            if self._verify:
                sha1 = hashlib.sha1()
                sha1_bytes_left = self._tmd_app_size

            num_blocks = math.ceil(self._real_app_size / self.block_size)
            for i in range(num_blocks):
                # load
                dec = self._read(self.block_size, i != num_blocks - 1)
                self._unhashed_data.append(dec)

                # update hash
                if self._verify:
                    sha1_input_len = min(len(dec), sha1_bytes_left)
                    sha1.update(dec[:sha1_input_len])
                    sha1_bytes_left -= sha1_input_len

            # verify hash
            if self._verify:
                digest = sha1.digest()
                if digest != self._content_hash:
                    raise utils.crypto.ChecksumVerifyError('hash mismatch', self._content_hash, digest)

        # if loaded, return chunk from cache
        if self._curr_block >= len(self._unhashed_data):
            raise EndOfInputError(self._curr_block)
        data = self._unhashed_data[self._curr_block]
        self._curr_block += 1
        return (b'', data)

    def __seek_to_index(self, block_index: int) -> None:
        '''
        Seeks the underlying stream to the specified block.

        If the stream is not seekable (e.g. HTTP response streams), only
        forward 'seeking' (by reading and discarding data) is supported
        '''

        if self._curr_block == block_index:
            # nothing to be done
            return

        if not self._is_hashed:
            # no need to seek for unhashed files, as they're read in one go
            return

        # seek to block
        target_offset = block_index * self.block_size
        if self._app.seekable():
            # just seek to target
            self._app.seek(target_offset, os.SEEK_SET)
        else:
            if self._app.tell() > target_offset:
                raise RuntimeError('app stream is not seekable, cannot go backwards')
            # if not seekable, read until target reached
            while True:
                left = target_offset - self._app.tell()
                if left <= 0:
                    assert left == 0
                    break
                # discard data (not using `self._read` since that gets overridden in subclasses, and we don't need to decrypt)
                AppBlockReader._read(self, min(left, self.block_size))
        self._curr_block = block_index


class AppDataReader:
    def __init__(self, block_reader: AppBlockReader):
        self.block_reader = block_reader

        self.__cache = None  # type: Optional[bytes]
        self.__cache_block_index = -1

    def get_data(self, data_offset: int, length: int) -> Iterator[bytes]:
        def handle_block(block: bytes, start_offset: int) -> bytes:
            nonlocal length
            # required length or blocksize, whichever is smaller
            slice_length = min(length, len(block) - start_offset)
            length -= slice_length
            return block[start_offset:start_offset + slice_length]

        block_index = data_offset // self.block_reader.data_size
        # load new block if no block is cached or index changed
        if not self.__cache or block_index != self.__cache_block_index:
            self.__cache = self.block_reader.load_block(block_index)[1]
            self.__cache_block_index = block_index

        # add offset in first block
        offset_in_data = data_offset % self.block_reader.data_size
        yield handle_block(self.__cache, offset_in_data)

        # yield blocks until done
        while length > 0:
            self.__cache = self.block_reader.load_next_block()[1]
            self.__cache_block_index += 1
            yield handle_block(self.__cache, 0)
