# bdflib, a library for working with BDF font files
# Copyright (C) 2009-2022, Timothy Allen
#
# This program 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.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.

from functools import partial
from typing import (
    Iterable,
    Iterator,
    Optional,
    List,
    NamedTuple,
    Callable,
    Union,
)
from bdflib import model


__all__ = ["read_bdf", "ParseError", "WarningCallback"]


class Token(NamedTuple):
    lineno: int
    key: bytes
    value: bytes


def _tokenise_bdf(
    source: Iterable[bytes], comments: List[bytes]
) -> Iterator[Token]:
    for lineno, line in enumerate(source, 1):
        line = line.strip()
        if line == b"":
            continue

        if b" " in line:
            key, value = line.split(None, 1)
        else:
            key, value = line, b""

        if key == b"COMMENT":
            comments.append(value.strip())
            continue

        yield Token(lineno, key, value)


class ParseError(Exception):
    """
    An error encountered by :func:`read_bdf`
    """

    #: The (1-based) line number where the error was encountered
    lineno: int

    #: A human-readable description of the problem
    message: str

    def __init__(self, lineno: int, message: str):
        self.lineno = lineno
        self.message = message

    def __str__(self) -> str:
        return f"Line {self.lineno}: {self.message}"


#: The signature of a function for reporting parser warnings
WarningCallback = Callable[[int, str], None]


ParseStep = Callable[[Token, WarningCallback], Union["ParseStep", model.Font]]


def _parse_key(token: Token, expected: bytes) -> None:
    if token.key != expected:
        raise ParseError(
            token.lineno, f"Expected {expected!r}, not {token.key!r}"
        )


def _parse_int(lineno: int, value: bytes, description: str) -> int:
    try:
        return int(value)
    except ValueError:
        raise ParseError(
            lineno, f"{description} must be an integer, not {value!r}"
        )


def _parse_startfont(
    token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"STARTFONT")

    if token.value not in (b"2.0", b"2.1"):
        raise ParseError(
            token.lineno,
            f"Only BDF versions 2.0 and 2.1 are supported, not {token.value!r}",
        )

    return _parse_font


def _parse_font(
    token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"FONT")

    if token.value == b"":
        raise ParseError(token.lineno, "Font name must not be empty")

    return partial(_parse_size, token.value)


def _parse_size(
    name: bytes, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"SIZE")

    parts = token.value.split()
    if len(parts) != 3:
        raise ParseError(token.lineno, "SIZE must have three values")

    point_size = _parse_int(token.lineno, parts[0], "Point size")
    xdpi = _parse_int(token.lineno, parts[1], "Horizontal DPI")
    ydpi = _parse_int(token.lineno, parts[2], "Vertical DPI")

    if point_size <= 0:
        warning(token.lineno, f"Point size should be > 0, not {point_size!r}")
    if xdpi <= 0:
        warning(token.lineno, f"Horizontal DPI should be > 0, not {xdpi!r}")
    if ydpi <= 0:
        warning(token.lineno, f"Vertical DPI should be > 0, not {ydpi!r}")

    return partial(
        _parse_fontboundingbox, model.Font(name, point_size, xdpi, ydpi)
    )


def _parse_fontboundingbox(
    font: model.Font, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"FONTBOUNDINGBOX")

    # We don't actually care about anything in the FONTBOUNDINGBOX,
    # since we'll recalculate it ourselves from the glyphs.

    return partial(_parse_startproperties_or_chars, font)


def _parse_startproperties_or_chars(
    font: model.Font, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    if token.key == b"STARTPROPERTIES":
        return _parse_startproperties(font, token, warning)
    elif token.key == b"CHARS":
        return _parse_chars(font, token, warning)

    raise ParseError(
        token.lineno,
        f"Metadata must be followed by properties or chars, not {token.key!r}",
    )


def _parse_startproperties(
    font: model.Font, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"STARTPROPERTIES")

    count = _parse_int(token.lineno, token.value, "Property count")

    if count > 0:
        return partial(_parse_property, font, count)
    else:
        return partial(_parse_endproperties, font)


def _parse_property(
    font: model.Font, remaining: int, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    value: Optional[model.PropertyValue] = None

    if token.value.startswith(b'"') and token.value.endswith(b'"'):
        # Must be a string. Remove the outer quotes and un-escape embedded
        # quotes.
        value = token.value[1:-1].replace(b'""', b'"')
    else:
        try:
            value = int(token.value)
        except ValueError:
            warning(
                token.lineno,
                (
                    f"Property {token.key!r} value must be "
                    + "int or quoted string, "
                    + f"not {token.value!r}"
                ),
            )

    if value is not None:
        if token.key in font.property_names():
            warning(
                token.lineno,
                (
                    f"Property {token.key!r} already set to "
                    + repr(font[token.key])
                    + ", "
                    + f"ignoring new value {value!r}"
                ),
            )
        else:
            font[token.key] = value

    if remaining == 1:
        return partial(_parse_endproperties, font)
    else:
        return partial(_parse_property, font, remaining - 1)


def _parse_endproperties(
    font: model.Font, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"ENDPROPERTIES")

    if token.value != b"":
        warning(
            token.lineno,
            f"ENDPROPERTIES expects no value, got {token.value!r}",
        )

    return partial(_parse_chars, font)


def _parse_chars(
    font: model.Font, token: Token, warning: WarningCallback
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"CHARS")

    count = _parse_int(token.lineno, token.value, "Glyph count")

    if count > 0:
        return partial(_parse_startchar, font, count)
    else:
        return partial(_parse_endfont, font, count)


def _parse_startchar_or_endfont(
    font: model.Font,
    glyphs_expected: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    if token.key == b"STARTCHAR":
        return _parse_startchar(font, glyphs_expected, token, warning)
    elif token.key == b"ENDFONT":
        return _parse_endfont(font, glyphs_expected, token, warning)

    raise ParseError(
        token.lineno,
        f"Expected STARTCHAR or ENDFONT, not {token.key!r}",
    )


def _parse_startchar(
    font: model.Font,
    glyphs_expected: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"STARTCHAR")

    if token.value == b"":
        warning(token.lineno, "Character name shouldn't be empty")

    if b" " in token.value:
        warning(
            token.lineno,
            f"Character name should not contain spaces: {token.value!r}",
        )

    # BDF 2.1 requires the glyph name to be 14 characters or less.
    # BDF 2.2 lifts that restriction, and a number of glyph names in the
    # official Adobe Glyph List are longer than that.
    # Even though we don't support BDF 2.2, it's not helpful to complain about
    # the name length, especially since we don't actually care.

    return partial(_parse_char_encoding, font, glyphs_expected, token.value)


def _parse_char_encoding(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"ENCODING")

    raw_codepoint = _parse_int(token.lineno, token.value, "Glyph encoding")
    if raw_codepoint < -1:
        warning(
            token.lineno,
            f"Glyph encoding must be >= -1, not {raw_codepoint!r}",
        )
        codepoint = None

    # Anything with a negative codepoint is "unencoded"
    # as far as we're concerned.
    elif raw_codepoint < 0:
        codepoint = None

    # Codepoints must be unique.
    elif raw_codepoint in font.codepoints():
        warning(
            token.lineno,
            f"Font already has a glyph with encoding {raw_codepoint}",
        )
        codepoint = None

    # Otherwise, the codepoint is probably fine.
    else:
        codepoint = raw_codepoint

    return partial(
        _parse_char_swidth,
        font,
        glyphs_expected,
        name,
        codepoint,
    )


def _parse_char_swidth(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"SWIDTH")

    # Since we are interested in bitmap fonts as bitmap fonts,
    # rather than pre-rendered scalable fonts,
    # we don't care about the SWIDTH,
    # and we just recalculate it based on the DWIDTH.

    return partial(_parse_char_dwidth, font, glyphs_expected, name, codepoint)


def _parse_char_dwidth(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"DWIDTH")

    parts = token.value.split()
    if len(parts) != 2:
        raise ParseError(
            token.lineno, f"DWIDTH must have two values, got {token.value!r}"
        )

    advance = _parse_int(token.lineno, parts[0], "Horizontal DWIDTH")
    y_dwidth = _parse_int(token.lineno, parts[1], "Vertical DWIDTH")

    if advance < 0:
        warning(
            token.lineno,
            f"Horizontal DWIDTH should be >= 0, not {advance!r}",
        )
    if y_dwidth != 0:
        # X11 only supports fonts with an vertial DWIDTH of 0
        warning(
            token.lineno,
            f"Non-zero vertical DWIDTH not supported, got {y_dwidth!r}",
        )

    return partial(
        _parse_char_bbx,
        font,
        glyphs_expected,
        name,
        codepoint,
        advance,
    )


def _parse_char_bbx(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"BBX")

    parts = token.value.split()
    if len(parts) != 4:
        raise ParseError(token.lineno, "BBX must have four values")

    bbW = _parse_int(token.lineno, parts[0], "Bounding box width")
    bbH = _parse_int(token.lineno, parts[1], "Bounding box height")
    bbX = _parse_int(token.lineno, parts[2], "Bounding box X")
    bbY = _parse_int(token.lineno, parts[3], "Bounding box Y")

    if bbW < 0:
        raise ParseError(
            token.lineno,
            f"Bounding box width should be >= 0, not {bbW!r}",
        )
    if bbH < 0:
        raise ParseError(
            token.lineno,
            f"Bounding box width should be >= 0, not {bbH!r}",
        )

    return partial(
        _parse_char_attributes_or_bitmap,
        font,
        glyphs_expected,
        name,
        codepoint,
        advance,
        bbX,
        bbY,
        bbW,
        bbH,
    )


def _parse_char_attributes_or_bitmap(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    bbX: int,
    bbY: int,
    bbW: int,
    bbH: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    if token.key == b"ATTRIBUTES":
        return _parse_char_attributes(
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            token,
            warning,
        )
    elif token.key == b"BITMAP":
        return _parse_char_bitmap(
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            token,
            warning,
        )

    raise ParseError(
        token.lineno,
        f"Expected ATTRIBUTES or BITMAP, not {token.key!r}",
    )


def _parse_char_attributes(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    bbX: int,
    bbY: int,
    bbW: int,
    bbH: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"ATTRIBUTES")

    # The BDF 2.1 specification says:
    # "The interpretation of these attributes is undefined in this document."
    # The ATTRIBUTES field is not mentioned at all in the 2.2 spec.
    # So we'll just ignore it.

    return partial(
        _parse_char_bitmap,
        font,
        glyphs_expected,
        name,
        codepoint,
        advance,
        bbX,
        bbY,
        bbW,
        bbH,
    )


def _parse_char_bitmap(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    bbX: int,
    bbY: int,
    bbW: int,
    bbH: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"BITMAP")

    if token.value != b"":
        warning(
            token.lineno,
            f"BITMAP expects no value, got {token.value!r}",
        )

    if bbH > 0:
        return partial(
            _parse_char_bitmap_row,
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            [],
        )
    else:
        return partial(
            _parse_endchar,
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            [],
        )


def _parse_char_bitmap_row(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    bbX: int,
    bbY: int,
    bbW: int,
    bbH: int,
    rows: List[int],
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    try:
        row_int = int(token.key, 16)
    except ValueError:
        raise ParseError(
            token.lineno, f"Expected hexadecimal digits, not {token.key!r}"
        )

    if token.value != b"":
        warning(
            token.lineno,
            (
                "Bitmap row should not contain spaces, got "
                + repr(token.key + b" " + token.value)
            ),
        )

    paddingbits = len(token.key) * 4 - bbW
    rows.append(row_int >> paddingbits)

    if len(rows) < bbH:
        return partial(
            _parse_char_bitmap_row,
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            rows,
        )
    else:
        return partial(
            _parse_endchar,
            font,
            glyphs_expected,
            name,
            codepoint,
            advance,
            bbX,
            bbY,
            bbW,
            bbH,
            rows,
        )


def _parse_endchar(
    font: model.Font,
    glyphs_expected: int,
    name: bytes,
    codepoint: Optional[int],
    advance: int,
    bbX: int,
    bbY: int,
    bbW: int,
    bbH: int,
    rows: List[int],
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"ENDCHAR")

    if token.value != b"":
        warning(
            token.lineno,
            f"ENDCHAR expects no value, got {token.value!r}",
        )

    # Make the list indices match the coordinate system
    rows.reverse()

    font.new_glyph_from_data(name, rows, bbX, bbY, bbW, bbH, advance, codepoint)

    return partial(_parse_startchar_or_endfont, font, glyphs_expected)


def _parse_endfont(
    font: model.Font,
    glyphs_expected: int,
    token: Token,
    warning: WarningCallback,
) -> Union[ParseStep, model.Font]:
    _parse_key(token, b"ENDFONT")

    if token.value != b"":
        warning(
            token.lineno,
            f"ENDFONT expects no value, got {token.value!r}",
        )

    glyphs_actual = len(font.glyphs)

    if len(font.glyphs) != glyphs_expected:
        warning(
            token.lineno,
            f"Font should have {glyphs_expected} glyphs, "
            + f"found {glyphs_actual}",
        )

    return font


def read_bdf(
    raw_iterable: Iterable[bytes],
    report_warning: Optional[WarningCallback] = None,
) -> model.Font:
    """
    Read a BDF-format font from the given source.

    :param raw_iterable: Each item should be a single line of the BDF file,
            ASCII encoded.
    :param report_warning: A callback that will be invoked to report
            problems encountered during parsing the file.
            These problems do not prevent the file being interpreted as BDF,
            but may indicate misunderstandings or corruption.
            If not provided, warnings will be discarded.
    :returns: the resulting font object
    :raises ParseError: if the font cannot be meaningfully interpreted
            as a BDF file.

    If you want to read an actual file, make sure you use the 'b' flag so
    you get bytes instead of text::

            def report_warning(lineno: int, message: str) -> None:
                print("Problem on line {}: {}".format(lineno, message))

            font = bdflib.reader.read_bdf(open(path, 'rb'), report_warning)
    """
    comments: List[bytes] = []
    tokens = _tokenise_bdf(raw_iterable, comments)
    if report_warning is None:

        def report_warning(lineno: int, message: str) -> None:
            pass

    step: Union[ParseStep, model.Font] = _parse_startfont
    while not isinstance(step, model.Font):

        try:
            step = step(next(tokens), report_warning)
        except StopIteration:
            raise ParseError(0, "Unexpected EOF")

    # Set font comments
    for c in comments:
        step.add_comment(c)

    return step
