"""
monobit.hbf - Hanzi Bitmap File Format

(c) 2022--2023 Rob Hagemans
licence: https://opensource.org/licenses/MIT
"""

import logging
from pathlib import Path

from ..properties import normalise_property
from ..storage import loaders, savers
from ..magic import FileFormatError
from ..font import Font, Coord
from ..glyph import Glyph
from ..binary import ceildiv

from .bdf import read_props, _parse_xlfd_properties, _create_xlfd_properties
from .bdf import _create_xlfd_name
from .yaff import _globalise_glyph_metrics
from .windows import _normalise_metrics


@loaders.register('hbf', magic=(b'HBF_START_FONT ',), name='hanzi-bf')
def load_hbf(instream):
    """
    Load font from Hanzi Bitmap Format (HBF) file.
    """
    where = instream.where
    instream = instream.text
    (
        comments, hbf_props, x_props,
        b2_ranges, b3_ranges, c_ranges
    ) = _read_hbf_global(instream)
    logging.info('hbf properties:')
    for name, value in hbf_props.items():
        logging.info('    %s: %s', name, value)
    logging.info('x properties:')
    for name, value in x_props.items():
        logging.info('    %s: %s', name, value)
    glyphs = _read_hbf_glyphs(
        instream, where, b2_ranges, b3_ranges, c_ranges, hbf_props
    )
    # check number of characters, but don't break if no match
    # if nchars != len(glyphs):
    #     logging.warning('Number of characters found does not match CHARS declaration.')
    properties = _parse_properties(hbf_props, x_props)
    font = Font(glyphs, comment=comments, **properties)
    # label glyphs with code scheme, if known and recoognised
    font = font.label()
    return font

@savers.register(linked=load_hbf)
def save_hbf(fonts, outstream):
    """
    Save font to Hanzi Bitmap Format (HBF) file.
    """
    if len(fonts) > 1:
        raise FileFormatError('Can only save one font to HBF file.')
    # ensure codepoint values are set
    font = fonts[0]
    _save_hbf(font, outstream.text, outstream.where)


##############################################################################
# HBF reader

# https://www.ibiblio.org/pub/packages/ccic/software/info/HBF-1.1/Format.html
# https://www.ibiblio.org/pub/packages/ccic/software/info/HBF-1.1/BitmapFile.html

# https://www.ibiblio.org/pub/packages/ccic/software/info/HBF-1.1/CodeSchemes.html
# these are matches to encodings for which we have unicode mappings
_HBF_CODE_SCHEMES_BASE = {
    # > GB2312-1980
    # > not simply "GB", nor "GB2312". there are many GuoBiao's for hanzi.
    # GBK is a superset of GB2312, often taken to mean windows-936
    'GB2312': 'gbk',
    # > Big5
    # > any so-called "Big5" bitmap file(s) can be used, but such bitmap file(s)
    # > must conform to the Big5 character code standard without vendor-added
    # > character code. The Big5 character code scheme has the following valid
    # > code ranges for hanzi:
    # >        0xA440-0xC67E for frequently-used hanzi      (5401 chars)
    # >        0xC940-0xF9D5 for less-frequently-used hanzi (7652 chars)
    'Big5': 'big5',
    # Big5 ETen 3.10
    # > not simply "Big5"; use specifically the bitmap files in the
    # > ETen system 3.10 which contains vendor-specific character codes.
    # > Similar specification of other vendors and software versions are
    # > acceptable, to provide a more accurate description.
    'Big5 ETen': 'big5-eten',
    # CNS11643-92p1 to CNS11643-92p7
    # Chinese National Standard of ROC, containing 7 planes.
    # complicatiion is that HBF defines 2-byte coding only
    # so the plane number would need to be extracted from the HBF_CODE_SCHEME
    'CNS11643': 'cns11643',
    # > Unicode 1.1
    # > version "1.1", equivalent to ISO/IEC 10646-1 UCS-2, level 3. Perhaps there
    # > will be vendor-specific versions at a later date.
    'Unicode': 'unicode',
    # > JISX0208-1990
    # > Japanese Industrial Standard
    'JISX0208': 'jisx0208',
    # > KSC5601-1987
    # > Korean Standard Code (formingly KIPS)
    'KSC5601': 'ksc5601'
}

def _normalise_code_scheme(hbf_cs):
    """Normalise the code scheme name for matching."""
    return hbf_cs.lower().replace(' ', '-').replace('.', '-')

# sort from longer to shorter keys to resolve collisions
_HBF_CODE_SCHEMES = {
    _normalise_code_scheme(_k): _v
    for _k, _v in sorted(
        _HBF_CODE_SCHEMES_BASE.items(),
        key=lambda _i: len(_i[0]),
        reverse=True,
    )
}

def _map_code_scheme(hbf_code_scheme):
    """
    Map HBF code scheme description to monobit encoding name.
    returns encoding_name, plane
    """
    hbf_code_scheme = _normalise_code_scheme(hbf_code_scheme)
    for cs, enc in _HBF_CODE_SCHEMES.items():
        if hbf_code_scheme.startswith(cs):
            if cs == 'cns11643':
                # assume the last nonempty char is the plane number
                plane_desc = hbf_code_scheme.strip()[-1:]
                try:
                    return enc, int(plane_desc)
                except ValueError:
                    pass
            return enc, None
    return hbf_code_scheme, None

def _read_hbf_global(instream):
    """Read global section of HBF file."""
    hbf_props, comments = _read_section(
        instream, end='ENDFONT',
        subsections=(
            'STARTPROPERTIES', 'HBF_START_BYTE_2_RANGES',
            'HBF_START_BYTE_3_RANGES', 'HBF_START_CODE_RANGES',
        )
    )
    hbf_props = dict(hbf_props)
    x_props = dict(hbf_props.pop('STARTPROPERTIES', {}))
    b2_ranges = hbf_props.pop('HBF_START_BYTE_2_RANGES')
    b3_ranges = hbf_props.pop('HBF_START_BYTE_3_RANGES', {})
    c_ranges = hbf_props.pop('HBF_START_CODE_RANGES')
    comments = '\n'.join(comments)
    return comments, hbf_props, x_props, b2_ranges, b3_ranges, c_ranges

def _read_section(instream, subsections, end):
    """Read a section of HBF file."""
    logging.debug('reading section %s', end)
    props = []
    comments = []
    while True:
        head_props, head_comments, keyword = read_props(
            instream, ends=subsections + (end,)
        )
        props.extend(head_props)
        comments.extend(head_comments)
        logging.debug((keyword, end, subsections))
        if not keyword or keyword == end:
            return props, comments
        sec_props, sec_comments = _read_section(
            instream, subsections=(), end=keyword.replace('START', 'END')
        )
        props.append((keyword, sec_props))
        # we're combining all comments in one block
        comments.append('')
        comments.extend(sec_comments)

def indexer(plane, code_range, b2_ranges, b3_ranges):
    """Gnenerator to run through code range keeping to allowed low-bytes."""
    if not code_range:
        return
    n_bytes = 3 if b3_ranges else 2
    planeshift = 8 * n_bytes
    hishift = 8 * (n_bytes-1)
    loshift = 8 * (n_bytes-2)
    himask = (1 << hishift) - 1
    lomask = (1 << loshift) - 1
    for codepoint in code_range:
        byte2 = (codepoint & himask) >> loshift
        if all(byte2 not in _range for _range in b2_ranges):
            continue
        if n_bytes == 3:
            byte3 = codepoint & lomask
            if all(byte3 not in _range for _range in b3_ranges):
                continue
        if plane is None:
            yield codepoint
        else:
            yield ((0x80 + plane) << planeshift) + codepoint

def _convert_ranges(b2_ranges):
    """Convert range descriptors to ranges."""
    b2_ranges = tuple(
        _split_hbf_ints(_range, sep='-')
        for _, _range in b2_ranges
    )
    b2_ranges = tuple(
        range(_range[0], _range[1]+1)
        for _range in b2_ranges
    )
    return b2_ranges

def _read_hbf_glyphs(instream, where, b2_ranges, b3_ranges, c_ranges, props):
    """Read glyphs from bitmap files and index according to ranges."""
    width, height, _, _ = _split_hbf_ints(props['HBF_BITMAP_BOUNDING_BOX'])
    bytesize = height * ceildiv(width, 8)
    # get 2nd- and 3rd-byte ranges
    b2_ranges = _convert_ranges(b2_ranges)
    b3_ranges = _convert_ranges(b3_ranges)
    # get encoding plane (0th byte)
    _, plane = _map_code_scheme(props['HBF_CODE_SCHEME'])
    code_ranges = []
    glyphs = []
    for _, c_desc in c_ranges:
        code_range, filename, offset = c_desc.split()
        code_range = _split_hbf_ints(code_range, sep='-')
        code_range = range(code_range[0], code_range[1]+1)
        offset = hbf_int(offset)
        path = Path(instream.name).parent
        with where.open(path / filename, 'r') as bitmapfile:
            # discard offset bytes
            bitmapfile.read(offset)
            for codepoint in indexer(plane, code_range, b2_ranges, b3_ranges):
                glyphbytes = bitmapfile.read(bytesize)
                glyphs.append(Glyph.from_bytes(
                    glyphbytes, width=width, codepoint=codepoint
                ))
    return glyphs


##############################################################################
# properties

def hbf_int(numstr):
    """Convert HBF int representation to int."""
    # HBF has c-style octals 0777
    if numstr.startswith('0') and numstr[1:2].isdigit():
        return int(numstr[1:], 8)
    return int(numstr, 0)


def _split_hbf_ints(value, sep=None):
    """Split a string and convert elements to int."""
    return tuple(hbf_int(_p) for _p in value.split(sep))


def _parse_properties(hbf_props, x_props):
    """Parse metrics and metadata."""
    # parse meaningful metadata
    properties, unparsed, plane = _parse_hbf_properties(hbf_props)
    # the FONT field *may* conform to xlfd but doesn't have to. don't parse it
    xlfd_props = _parse_xlfd_properties(x_props, xlfd_name='', to_int=hbf_int)
    for key, value in unparsed.items():
        logging.info(f'Unrecognised HBF property {key}={value}')
        # preserve as property
        properties[key] = value
    for key, value in xlfd_props.items():
        if key in properties and properties[key] != value:
            logging.debug(
                'Inconsistency between HBF and XLFD properties: '
                '%s=%s (from XLFD) but %s=%s (from HBF). Taking HBF property.',
                key, value, key, properties[key]
            )
        else:
            properties[key] = value
    # ensure default codepoint gets a plane value
    if plane is not None and 'default-char' in properties:
        properties['default-char'] += (0x80+plane) * 0x10000
    # prefer hbf code scheme to charset values from xlfd
    logging.info('yaff properties:')
    for name, value in properties.items():
        logging.info('    %s: %s', name, value)
    return properties

def _parse_hbf_properties(hbf_props):
    """Parse HBF properties."""
    size, xdpi, ydpi = _split_hbf_ints(hbf_props.pop('SIZE'))
    properties = {
        'source-format': 'HBF v{}'.format(hbf_props.pop('HBF_START_FONT')),
        'point-size': size,
        'dpi': (xdpi, ydpi),
    }
    width, height, offset_x, offset_y = _split_hbf_ints(
        hbf_props.pop('HBF_BITMAP_BOUNDING_BOX')
    )
    # https://www.ibiblio.org/pub/packages/ccic/software/info/HBF-1.1/BoundingBoxes.html
    # fontboundingbox is equal or larger than bitmap bounding box
    # may be used to specify inter-glyph and inter-line spacing
    # the documented examples show the effect of different bounding box heights
    # but the impact of the fondboundingbox offset is unclear to me
    full_width, full_height, full_offset_x, full_offset_y = _split_hbf_ints(
        hbf_props.pop('FONTBOUNDINGBOX')
    )
    properties.update({
        'line-height': full_height,
        # full_width :==: advance-width == left-bearing + width + right-bearing
        'left-bearing': offset_x,
        'right-bearing': full_width - width - offset_x,
        # I think the fontboundingbox offsets actually go unused
        'shift_up': offset_y,
    })
    # known but we don't use it
    properties['font-id'] = hbf_props.pop('FONT', None)
    # match encoding name
    code_scheme = hbf_props.pop('HBF_CODE_SCHEME')
    properties['encoding'], plane = _map_code_scheme(code_scheme)
    logging.debug(
        'Interpreting code scheme `%s` as encoding `%s` %s',
        code_scheme, properties['encoding'],
        f'plane {plane}' if plane is not None else ''
    )
    hbf_props.pop('HBF_END_FONT', None)
    # keep unparsed hbf props
    return properties, hbf_props, plane


##############################################################################
# hbf writer

def _save_hbf(font, outstream, container):
    """Write one font to HBF."""
    bitmap_name = outstream.name + '.bin'
    hbf_props, bitmaps = _convert_to_hbf(font, bitmap_name)
    for name, value in hbf_props:
        logging.info('    %s: %s', name, value)
    with container.open(bitmap_name, 'w') as binfile:
        for bitmap in bitmaps:
            binfile.write(bitmap)
    for name, value in hbf_props:
        outstream.write(f'{name} {value}\n')


def _convert_to_hbf(font, bitmap_name):
    """Convert to HBF properties."""
    # set codepoints
    font = font.label(codepoint_from=font.encoding)
    # get ranges
    cps, cranges, b2ranges, b3ranges = _get_code_ranges(font)
    font = font.subset(cps)
    # check if the remaining glyphs mae for a cell font
    if font.spacing != 'character-cell':
        raise FileFormatError(
            'Only character-cell fonts can be stored in HBF format.'
        )
    # bring font to normal form
    font, shift_up = _normalise_metrics(font)
    #TODO
    left_bearing = 0
    #glyphs, properties = _globalise_glyph_metrics(font)
    # convert properties
    xlfd_props = _create_xlfd_properties(font)
    if 'hbf.font' in font.properties:
        fontname = font.get_property('hbf.font')
        xlfd_props.pop('HBF.FONT')
    else:
        fontname = _create_xlfd_name(xlfd_props)
    bbx = (
        f'{font.cell_size.x} {font.cell_size.y} '
        f'{left_bearing} {shift_up}'
    )
    #TODO
    code_scheme = font.encoding
    props = [
        ('HBF_START_FONT', '1.1'),
        ('HBF_CODE_SCHEME', code_scheme),
    ] + [
        ('COMMENT', _comment) for _comment in font.get_comment().splitlines()
    ] + [
        ('FONT', fontname),
        ('SIZE', f'{font.point_size} {font.dpi.x} {font.dpi.y}'),
        ('HBF_BITMAP_BOUNDING_BOX', bbx),
        ('FONTBOUNDINGBOX', bbx),
    ]
    if xlfd_props:
        props.append(('STARTPROPERTIES', str(len(xlfd_props))))
        props.extend(xlfd_props.items())
        props.append(('ENDPROPERTIES', ''))
    props.append(('CHARS', f'{len(font.glyphs)}'))
    # byte-2 ranges
    props.append(('HBF_START_BYTE_2_RANGES', str(len(b2ranges))))
    for b2range in b2ranges:
        props.append(_format_byte_range(b2range, 2))
    props.append(('HBF_END_BYTE_2_RANGES', ''))
    # byte-3 ranges
    if b3ranges:
        props.append(('HBF_START_BYTE_3_RANGES', str(len(b3ranges))))
        for b3range in b3ranges:
            props.append(_format_byte_range(b3range, 3))
        props.append(('HBF_END_BYTE_3_RANGES', ''))
    # code ranges
    props.append(('HBF_START_CODE_RANGES', str(len(cranges))))
    # create glyph bitmaps, one code range at a time
    bitmaps = []
    offset = 0
    n_bytes = len(cps[-1])
    for crange in cranges:
        bitmap = b''.join(
            font.get_glyph(codepoint=_cp).as_bytes()
            for _cp in indexer(None, crange, b2ranges, b3ranges)
        )
        start = f'{crange.start:X}'.zfill(2*n_bytes)
        end = f'{crange.stop-1:X}'.zfill(2*n_bytes)
        props.append((
            'HBF_CODE_RANGE',
            f'0x{start}-0x{end} {bitmap_name} {offset}'
        ))
        bitmaps.append(bitmap)
        offset += len(bitmap)
    props.append(('HBF_END_CODE_RANGES', ''))
    return props, bitmaps


def _format_byte_range(brange, n_byte):
    return (
        f'HBF_BYTE_{n_byte}_RANGE',
        f'{brange.start:#02X}-{brange.stop-1:#02X}'
    )


def _get_code_ranges(font):
    """Determine contiguous ranges."""
    cps = font.get_codepoints()
    if not cps:
        raise FileFormatError('No storable glyphs in font.')
    n_bytes = len(max(cps))
    if n_bytes not in (2, 3):
        raise FileFormatError('HBF can only store 2- or 3-byte code ranges.')
    # only store full-length codepoints and store in order
    cps = sorted(_cp for _cp in cps if len(_cp) == n_bytes)
    # determine byte-2 ranges
    b2 = sorted(set(_cp[1] for _cp in cps))
    b2ranges = _find_ranges(b2)
    logging.debug('BYTE_2_RANGES %s', b2ranges)
    # determine byte-3 ranges
    if n_bytes == 3:
        b3 = sorted(set(_cp[2] for _cp in cps))
        b3ranges = _find_ranges(b3)
        logging.debug('BYTE_3_RANGES %s', b3ranges)
    else:
        b3ranges = ()
    # determine code ranges subject to byte-2, byte-3 ranges already found
    start_crange = range(int(cps[0]), int(cps[-1])+1)
    gen = indexer(None, start_crange, b2ranges, b3ranges)
    cranges = _find_ranges(cps, gen)
    logging.debug('CODE_RANGES %s', cranges)
    return cps, cranges, b2ranges, b3ranges

#
# def __old_find_ranges(cps):
#     """Convert a sorted list/tuple to a list of ranges."""
#     start = int(cps[0])
#     logging.debug(start)
#     cur_range = [start, start]
#     ranges = []
#     for cp in cps[1:]:
#         cp = int(cp)
#         if cp == cur_range[1] + 1:
#             cur_range[1] = cp
#         else:
#             ranges.append(range(cur_range[0], cur_range[1]+1))
#             cur_range = [cp, cp]
#     ranges.append(range(cur_range[0], cur_range[1]+1))
#     logging.debug(ranges)
#     return ranges


def _find_ranges(cps, indexgen=None):
    """Find code range subject to indexer."""
    cur_start = int(cps[0])
    cur_end = cur_start
    if not indexgen:
        indexgen = iter(range(cur_start, int(cps[-1])+1))
    index = next(indexgen)
    ranges = []
    try:
        for cp in cps[1:]:
            cp = int(cp)
            if cp <= index:
                continue
            # cp > index, get next index which is higher than previous.
            # so now cp can be less, equal or higher
            index = next(indexgen)
            if cp == index:
                cur_end = cp
            else:
                ranges.append(range(cur_start, cur_end + 1))
                cur_start, cur_end = cp, cp
                while cp > index:
                    index = next(indexgen)
    except StopIteration:
        logging.debug('Indexer was exhausted')
    ranges.append(range(cur_start, cur_end + 1))
    return ranges
