# This file is a part of ninfs.
#
# Copyright (c) 2017-2019 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.

from hashlib import sha256
from struct import pack
from typing import TYPE_CHECKING, NamedTuple

from ..common import PyCTRError
from ..util import readbe

if TYPE_CHECKING:
    from typing import BinaryIO, Iterable

__all__ = ['CHUNK_RECORD_SIZE', 'TitleMetadataError', 'InvalidSignatureTypeError', 'InvalidHashError',
           'ContentInfoRecord', 'ContentChunkRecord', 'ContentTypeFlags', 'TitleVersion', 'TitleMetadataReader']

CHUNK_RECORD_SIZE = 0x30

# sig-type: (sig-size, padding)
signature_types = {
    # RSA_4096 SHA1 (unused on 3DS)
    0x00010000: (0x200, 0x3C),
    # RSA_2048 SHA1 (unused on 3DS)
    0x00010001: (0x100, 0x3C),
    # Elliptic Curve with SHA1 (unused on 3DS)
    0x00010002: (0x3C, 0x40),
    # RSA_4096 SHA256
    0x00010003: (0x200, 0x3C),
    # RSA_2048 SHA256
    0x00010004: (0x100, 0x3C),
    # ECDSA with SHA256
    0x00010005: (0x3C, 0x40),
}

BLANK_SIG_PAIR = (0x00010004, b'\xFF' * signature_types[0x00010004][0])


class TitleMetadataError(PyCTRError):
    """Generic exception for TitleMetadata operations."""


class InvalidTMDError(TitleMetadataError):
    """Title Metadata is invalid."""


class InvalidSignatureTypeError(InvalidTMDError):
    """Invalid signature type was used."""

    def __init__(self, sig_type):
        super().__init__(sig_type)

    def __str__(self):
        return f'{self.args[0]:#010x}'


class InvalidHashError(InvalidTMDError):
    """Hash mismatch in the Title Metadata."""


class InvalidInfoRecordError(InvalidHashError):
    """Hash mismatch in the Content Info Records."""

    def __init__(self, info_record):
        super().__init__(info_record)

    def __str__(self):
        return f'Invalid info record: {self.args[0]}'


class UnusualInfoRecordError(InvalidTMDError):
    """Encountered Content Info Record that attempts to hash a Content Chunk Record that has already been hashed."""

    def __init__(self, info_record, chunk_record):
        super().__init__(info_record, chunk_record)

    def __str__(self):
        return f'Attempted to hash twice: {self.args[0]}, {self.args[1]}'


class ContentTypeFlags(NamedTuple):
    encrypted: bool
    disc: bool
    cfm: bool
    optional: bool
    shared: bool

    def __index__(self) -> int:
        return self.encrypted | (self.disc << 1) | (self.cfm << 2) | (self.optional << 14) | (self.shared << 15)

    __int__ = __index__

    def __format__(self, format_spec: str) -> str:
        return self.__int__().__format__(format_spec)

    @classmethod
    def from_int(cls, flags: int) -> 'ContentTypeFlags':
        # noinspection PyArgumentList
        return cls(bool(flags & 1), bool(flags & 2), bool(flags & 4), bool(flags & 0x4000), bool(flags & 0x8000))


class ContentInfoRecord(NamedTuple):
    index_offset: int
    command_count: int
    hash: bytes

    def __bytes__(self) -> bytes:
        return b''.join((self.index_offset.to_bytes(2, 'big'), self.command_count.to_bytes(2, 'big'), self.hash))


class ContentChunkRecord(NamedTuple):
    id: str
    cindex: int
    type: ContentTypeFlags
    size: int
    hash: bytes

    def __bytes__(self) -> bytes:
        return b''.join((bytes.fromhex(self.id), self.cindex.to_bytes(2, 'big'), int(self.type).to_bytes(2, 'big'),
                         self.size.to_bytes(8, 'big'), self.hash))


class TitleVersion(NamedTuple):
    major: int
    minor: int
    micro: int

    def __str__(self) -> str:
        return f'{self.major}.{self.minor}.{self.micro}'

    def __index__(self) -> int:
        return (self.major << 10) | (self.minor << 4) | self.micro

    __int__ = __index__

    def __format__(self, format_spec: str) -> str:
        return self.__int__().__format__(format_spec)

    @classmethod
    def from_int(cls, ver: int) -> 'TitleVersion':
        # noinspection PyArgumentList
        return cls((ver >> 10) & 0x3F, (ver >> 4) & 0x3F, ver & 0xF)


class TitleMetadataReader:
    """
    Class for 3DS Title Metadata.

    https://www.3dbrew.org/wiki/Title_metadata
    """

    __slots__ = ('title_id', 'save_size', 'srl_save_size', 'title_version', 'info_records',
                 'chunk_records', 'content_count', 'signature', '_u_issuer', '_u_version', '_u_ca_crl_version',
                 '_u_signer_crl_version', '_u_reserved1', '_u_system_version', '_u_title_type', '_u_group_id',
                 '_u_reserved2', '_u_srl_flag', '_u_reserved3', '_u_access_rights', '_u_boot_count', '_u_padding')

    # arguments prefixed with _u_ are values unused by the 3DS and/or are only kept around to generate the final tmd
    def __init__(self, *, title_id: str, save_size: int, srl_save_size: int, title_version: TitleVersion,
                 info_records: 'Iterable[ContentInfoRecord]', chunk_records: 'Iterable[ContentChunkRecord]',
                 signature=BLANK_SIG_PAIR, _u_issuer='Root-CA00000003-CP0000000b', _u_version=1, _u_ca_crl_version=0,
                 _u_signer_crl_version=0, _u_reserved1=0, _u_system_version=b'\0' * 8, _u_title_type=b'\0\0\0@',
                 _u_group_id=b'\0\0', _u_reserved2=b'\0\0\0\0', _u_srl_flag=0, _u_reserved3=b'\0' * 0x31,
                 _u_access_rights=b'\0' * 4, _u_boot_count=b'\0\0', _u_padding=b'\0\0'):
        # TODO: add checks
        self.title_id = title_id.lower()
        self.save_size = save_size
        self.srl_save_size = srl_save_size
        self.title_version = title_version
        self.info_records = tuple(info_records)
        self.chunk_records = tuple(chunk_records)
        self.content_count = len(self.chunk_records)
        self.signature = signature  # TODO: store this differently

        # unused values
        self._u_issuer = _u_issuer
        self._u_version = _u_version
        self._u_ca_crl_version = _u_ca_crl_version
        self._u_signer_crl_version = _u_signer_crl_version
        self._u_reserved1 = _u_reserved1
        self._u_system_version = _u_system_version
        self._u_title_type = _u_title_type
        self._u_group_id = _u_group_id
        self._u_reserved2 = _u_reserved2
        self._u_srl_flag = _u_srl_flag
        self._u_reserved3 = _u_reserved3
        self._u_access_rights = _u_access_rights
        self._u_boot_count = _u_boot_count
        self._u_padding = _u_padding

    def __hash__(self) -> int:
        return hash((self.title_id, self.save_size, self.srl_save_size, self.title_version,
                     self.info_records, self.chunk_records))

    def __repr__(self) -> str:
        return (f'<TitleMetadataReader title_id={self.title_id!r} title_version={self.title_version!r} '
                f'content_count={self.content_count!r}>')

    def __bytes__(self) -> bytes:
        sig_data = pack(f'>I {signature_types[self.signature[0]][0]}s {signature_types[self.signature[0]][1]}x',
                        self.signature[0], self.signature[1])

        info_records = b''.join(bytes(x) for x in self.info_records).ljust(0x900, b'\0')

        header = pack('>64s b b b b 8s 8s 4s 2s I I 4s b 49s 4s H H 2s 2s 32s', self._u_issuer.encode('ascii'),
                      self._u_version, self._u_ca_crl_version, self._u_signer_crl_version, self._u_reserved1,
                      self._u_system_version, bytes.fromhex(self.title_id), self._u_title_type, self._u_group_id,
                      self.save_size, self.srl_save_size, self._u_reserved2, self._u_srl_flag, self._u_reserved3,
                      self._u_access_rights, self.title_version, self.content_count, self._u_boot_count,
                      self._u_padding, sha256(info_records).digest())

        chunk_records = b''.join(bytes(x) for x in self.chunk_records)

        return sig_data + header + info_records + chunk_records

    @classmethod
    def load(cls, fp: 'BinaryIO', verify_hashes: bool = True) -> 'TitleMetadataReader':
        """Load a tmd from a file-like object."""
        sig_type = readbe(fp.read(4))
        try:
            sig_size, sig_padding = signature_types[sig_type]
        except KeyError:
            raise InvalidSignatureTypeError(sig_type)

        signature = fp.read(sig_size)
        try:
            fp.seek(sig_padding, 1)
        except Exception:
            # most streams are probably seekable, but for some that aren't...
            fp.read(sig_padding)

        header = fp.read(0xC4)
        if len(header) != 0xC4:
            raise InvalidTMDError('Header length is not 0xC4')

        # only values that actually have a use are loaded here. (currently)
        # several fields in were left in from the Wii tmd and have no function on 3DS.
        title_id = header[0x4C:0x54].hex()
        save_size = readbe(header[0x5A:0x5E])
        srl_save_size = readbe(header[0x5E:0x62])
        title_version = TitleVersion.from_int(readbe(header[0x9C:0x9E]))
        content_count = readbe(header[0x9E:0xA0])

        content_info_records_hash = header[0xA4:0xC4]

        content_info_records_raw = fp.read(0x900)
        if len(content_info_records_raw) != 0x900:
            raise InvalidTMDError('Content info records length is not 0x900')

        if verify_hashes:
            real_hash = sha256(content_info_records_raw)
            if content_info_records_hash != real_hash.digest():
                raise InvalidHashError('Content Info Records hash is invalid')

        content_chunk_records_raw = fp.read(content_count * CHUNK_RECORD_SIZE)

        chunk_records = []
        for cr_raw in (content_chunk_records_raw[i:i + CHUNK_RECORD_SIZE] for i in
                       range(0, content_count * CHUNK_RECORD_SIZE, CHUNK_RECORD_SIZE)):
            chunk_records.append(ContentChunkRecord(id=cr_raw[0:4].hex(),
                                                    cindex=readbe(cr_raw[4:6]),
                                                    type=ContentTypeFlags.from_int(readbe(cr_raw[6:8])),
                                                    size=readbe(cr_raw[8:16]),
                                                    hash=cr_raw[16:48]))

        info_records = []
        for ir_raw in (content_info_records_raw[i:i + 0x24] for i in range(0, 0x900, 0x24)):
            if ir_raw != b'\0' * 0x24:
                info_records.append(ContentInfoRecord(index_offset=readbe(ir_raw[0:2]),
                                                      command_count=readbe(ir_raw[2:4]),
                                                      hash=ir_raw[4:36]))

        if verify_hashes:
            chunk_records_hashed = set()
            for ir in info_records:
                to_hash = []
                for cr in chunk_records[ir.index_offset:ir.index_offset + ir.command_count]:
                    if cr in chunk_records_hashed:
                        raise InvalidTMDError('attempting to hash chunk record twice')

                    chunk_records_hashed.add(cr)
                    to_hash.append(cr)

                hashed = sha256(b''.join(bytes(x) for x in to_hash))
                if hashed.digest() != ir.hash:
                    raise InvalidInfoRecordError(ir)

        # unused vales are loaded only for use when re-building the binary tmd
        u_issuer = header[0:0x40].decode('ascii').rstrip('\0')
        u_version = header[0x40]
        u_ca_crl_version = header[0x41]
        u_signer_crl_version = header[0x42]
        u_reserved1 = header[0x43]
        u_system_version = header[0x44:0x4C]
        u_title_type = header[0x54:0x58]
        u_group_id = header[0x58:0x5A]
        u_reserved2 = header[0x62:0x66]
        u_srl_flag = header[0x66]  # is this one used for anything?
        u_reserved3 = header[0x67:0x98]
        u_access_rights = header[0x98:0x9C]
        u_boot_count = header[0xA0:0xA2]
        u_padding = header[0xA2:0xA4]

        return cls(title_id=title_id, save_size=save_size, srl_save_size=srl_save_size, title_version=title_version,
                   info_records=info_records, chunk_records=chunk_records, signature=(sig_type, signature),
                   _u_issuer=u_issuer, _u_version=u_version, _u_ca_crl_version=u_ca_crl_version,
                   _u_signer_crl_version=u_signer_crl_version, _u_reserved1=u_reserved1,
                   _u_system_version=u_system_version, _u_title_type=u_title_type, _u_group_id=u_group_id,
                   _u_reserved2=u_reserved2, _u_srl_flag=u_srl_flag, _u_reserved3=u_reserved3,
                   _u_access_rights=u_access_rights, _u_boot_count=u_boot_count, _u_padding=u_padding)

    @classmethod
    def from_file(cls, fn: str, *, verify_hashes: bool = True) -> 'TitleMetadataReader':
        with open(fn, 'rb') as f:
            return cls.load(f, verify_hashes=verify_hashes)
