# Copyright 2019 RoadrunnerWMC
#
# This file is part of ndspy.
#
# ndspy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ndspy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ndspy.  If not, see <https://www.gnu.org/licenses/>.
"""
Support for saving ndspy.bmg.BMG objects to the WBMGT text format
"""

import collections

from ... import VERSION as ndspy_VERSION

from . import _common


def getInfSize(bmg):
    """
    Choose a reasonable @INF-SIZE value
    """
    if bmg.messages:
        infSize = 4 + len(bmg.messages[0].info)  # add 4 for string offset value

        # This is probably going to be a common mistake, so let's check
        # for it explicitly just to raise an exception
        for i, message in enumerate(bmg.messages):
            if len(message.info) != len(bmg.messages[0].info):
                raise ValueError(f'Message info values are presumed to'
                                 f' be {infSize - 4} bytes long,'
                                 f' but message {i} has a'
                                 f' {len(message.info)}-byte-long info'
                                 f' value!')

        return infSize

    else:
        return 8  # standard default value


def getDefaultAttribs(bmg, infSize):
    """
    Choose a reasonable default attribs value
    """
    if bmg.messages:
        # Find the most commonly used info value
        # (need the explicit cast to bytes because bytearray is unhashable)
        attr, count = collections.Counter(bytes(msg.info) for msg in bmg.messages).most_common(1)[0]

        # Only use this as the default if it's used by at least 10% of messages;
        # if it's too few, it's a bit weird/pointless
        if count >= len(bmg.messages) // 10:
            return attr

    # Fallback value (just zeros)
    return b'\0' * (infSize - 4)


def save(bmg):
    """
    Create a WBMGT file string from the provided BMG object
    """

    # Get the @INF-SIZE and @DEFAULT-ATTRIBS values
    infSize = getInfSize(bmg)
    defaultAttribs = getDefaultAttribs(bmg, infSize)

    # Create the lines list, starting with the header
    lines = [
        "#BMG  <<<  The first 4 characters '#BMG' are the magic for a BMG text file.",
        "#     <<<  Don't remove them!",
        '#',
        '# This text file was generated by ndspy:',
        '#     https://ndspy.readthedocs.io/',
        '#',
        '# ndspy tries to follow the official WBMGT text file specification',
        '# (https://szs.wiimm.de/doc/bmg/text) reasonably closely. Unfortunately,',
        '# though, perfect compatibility is impossible, both due to lack of clarity',
        '# about how to properly represent little-endian BMG files, and due to certain',
        '# file format features being highly specific to Mario Kart Wii.',
        '#',
        '#------------------------------------------------------------------------------',
        "# Parameters begin with '@'. Unknown parameters are ignored.",
        '',
        '# The program that produced this file, and its version number.',
        '# This can be used to improve compatibility between programs and versions.',
        f'@PRODUCED-BY = ndspy-{ndspy_VERSION[0]}.{ndspy_VERSION[1]}.{ndspy_VERSION[2]}',
        '',
        '# Create »MID1« section: 0=off, 1=on.',
        '# ndspy does not support MID1 sections, so this must be 0.',
        '@BMG-MID = 0',
        '',
        "# Size of each element of the 'INF0' section, including the 4-byte string offset",
        '# value. Set it first, because it defines defaults for other parameters.',
        f'@INF-SIZE = 0x{infSize:02X}',
        '',
        '# Default attribute values for this BMG.',
        f'@DEFAULT-ATTRIBS = {encodeAttributesString(defaultAttribs)}',
        '',
        '#------------------------------------------------------------------------------',
        '',
    ]

    # Add individual messages
    for i, message in enumerate(bmg.messages):
        line = [f'{i: 6x} ']

        # Attributes (only if it doesn't match the default)
        if message.info != defaultAttribs:
            attribs = encodeAttributesString(message.info)
            line.append(f'{attribs} ')

        if message.isNull:
            line.append('/')

        else:
            line.append('= ')

            # String parts
            addedAnything = False
            for part in message.stringParts:
                if isinstance(part, str):
                    line.append(encodeString(part))
                    addedAnything = addedAnything or bool(part)
                else:
                    line.append(encodeEscape(part, bmg.fullEncoding))
                    addedAnything = True

            # Delete the trailing space if the message was completely empty
            if not addedAnything:
                line[-1] = line[-1][:-1]

        # Add the line
        lines.append(''.join(line))

    return '\n'.join(lines)


def encodeAttributesString(data):
    """
    Encode an attributes string for the provided data.
    https://szs.wiimm.de/info/bmg-text.html#attrib

    NOTE: we never use the optional "ATTRIB32" format due to the spec
    being unclear about how to handle endianness.
    """
    line = ['[']

    i = 0
    needsSeparator = False
    while i < len(data):
        if data[i] == 0:

            # This is kind of dumb, but it works, so, whatever.

            if i % 4 == 0:
                if len(data) > i + 3 and data[i + 1] == data[i + 2] == data[i + 3] == 0:
                    line.append('/')
                    needsSeparator = False
                    i += 4
                else:
                    line.append(',')
                    i += 1

            elif i % 4 == 1:
                if len(data) > i + 2 and data[i + 1] == data[i + 2] == 0:
                    line.append('/')
                    needsSeparator = False
                    i += 3
                else:
                    line.append(',')
                    i += 1

            elif i % 4 == 2:
                if len(data) > i + 1 and data[i + 1] == 0:
                    line.append('/')
                    needsSeparator = False
                    i += 2
                else:
                    line.append(',')
                    i += 1

            elif i % 4 == 3:
                line.append('/')
                needsSeparator = False
                i += 1

        else:  # nonzero byte value
            if needsSeparator:
                line.append(',')
            line.append(f'{data[i]:X}')
            needsSeparator = True
            i += 1

    # Trailing zeros are implied, so remove trailing commas and slashes
    while line[-1] in ',/':
        line.pop()

    line.append(']')
    return ''.join(line)


def encodeString(s):
    """
    Encode the provided string using wszst string escaping rules
    """
    new = []

    for c in s:
        if c == '\n':
            # Add the "\\n", and then also split it onto a new line for
            # extra readability
            new.append('\\n\n       + ')
        elif c in _common.CONTROL_ESCAPES:
            new.append(_common.CONTROL_ESCAPES[c])
        elif '\\' in repr(c):
            # This codepoint doesn't have its own nice glyph, so we
            # should encode it as a raw hex value instead.
            # (Note that the "c == '\\'" case is already handled by the
            # previous "if" block.)
            new.append('\\x{' + hex(ord(c))[2:] + '}')
        else:
            new.append(c)

    return ''.join(new)


def encodeEscape(escape, encoding):
    """
    Encode the provided Message.Escape using wszst escaping rules
    """
    new = ['\\z{']

    # "xyy" portion
    # If "x" happens to be more than one nybble, encode as "xxyy". The
    # spec doesn't clarify that case, but wbmgt itself does that.
    totalLength = len('\x1A'.encode(encoding)) + 2 + len(escape.data)
    new.append(f'{totalLength:x}{escape.type:02x}')

    # OK, now, this part is weird.
    # The way it works is that you have 1-8 bytes, a comma, and then
    # 8-byte chunks separated by commas.
    # And if the length is odd, you have to add a pad byte at the end.
    # That is, for increasing lengths of escape.data, you encode as so:
    # \z{4__}
    # \z{5__,AA00}
    # \z{6__,AABB}
    # \z{7__,AABBCC00}
    # \z{8__,AABBCCDD}
    # \z{9__,AABBCCDDEE00}
    # \z{a__,AABBCCDDEEFF}
    # \z{b__,AABBCCDDEEFFGG00}
    # \z{c__,AABBCCDDEEFFGGHH}
    # \z{d__,AABB,CCDDEEFFGGHHII00}
    # \z{e__,AABB,CCDDEEFFGGHHIIJJ}
    # \z{f__,AABBCCDD,EEFFGGHHIIJJKK00}
    # ...
    # \z{1e__,AABB,CCDDEEFFGGHHIIJJ,KKLLMMNNOOPPQQRR,SSTTUUVVWWXXYYZZ}
    # etc.
    #
    # Each comma-separated value has any extraneous 0s (on the left
    # side) removed, and after that, we delete any trailing commas off
    # the right edge of the whole thing.
    # 
    # Now, to accomplish all that, we'll lop off 8-byte chunks from the
    # end until there are none left, add whatever's left, and then
    # reverse the order of those pieces.
    #
    # Here we go, then...

    dataParts = []
    remainingData = bytes(escape.data)

    # We need to add an extra pad byte if the length is odd, to match
    # the reference implementation
    if len(remainingData) % 2:
        remainingData += b'\0'

    # Chop off 8-byte-long sections from the right side
    while len(remainingData) > 8:
        dataParts.append(remainingData[-8:].hex().lstrip('0'))
        dataParts.append(',')
        remainingData = remainingData[:-8]

    # Add one final part with whatever's left
    dataParts.append(remainingData.hex().lstrip('0'))
    dataParts.append(',')

    # Since we started on the right and worked leftward, reverse the list
    dataParts.reverse()

    # Trim away any trailing empty parts and commas
    while dataParts and dataParts[-1] in [',', '']:
        dataParts.pop()

    # Add this to the overall escape parts list
    new.extend(dataParts)

    # Finish it off, and return
    new.append('}')
    return ''.join(new)
