#!/usr/bin/env python3
#
# Copyright 2017 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS-IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
r"""Tool to identify problems with fonts.

"""
from __future__ import print_function
import collections
import contextlib
import os
import re
import sys

from fontTools import ttLib
from absl import flags, app
from gftools.util import google_fonts as fonts

FLAGS = flags.FLAGS

flags.DEFINE_boolean("suppress_pass", True, "Whether to print pass: results")
flags.DEFINE_boolean("check_metadata", True, "Whether to check METADATA values")
flags.DEFINE_boolean("check_font", True, "Whether to check font values")
flags.DEFINE_string("repair_script", None, "Where to write a repair script")
_FIX_TYPE_OPTS = [
    "all",
    "name",
    "filename",
    "postScriptName",
    "fullName",
    "fsSelection",
    "fsType",
    "usWeightClass",
    "emptyGlyphLSB",
]
flags.DEFINE_multi_string(
    "fix_type",
    "all",
    "What types of problems should be fixed by "
    "repair_script. "
    "Choices: " + ", ".join(_FIX_TYPE_OPTS),
)

ResultMessageTuple = collections.namedtuple(
    "ResultMessageTuple", ["happy", "message", "path", "repair_script"]
)


def _HappyResult(message, path):
    return ResultMessageTuple(True, message, path, None)


def _SadResult(message, path, repair_script=None):
    return ResultMessageTuple(False, message, path, repair_script)


def _DropEmptyPathSegments(path):
    """Removes empty segments from the end of path.

    Args:
      path: A filesystem path.
    Returns:
      path with trailing empty segments removed. Eg /duck/// => /duck.
    """
    while True:
        (head, tail) = os.path.split(path)
        if tail:
            break
        path = head
    return path


def _SanityCheck(path):
    """Runs various sanity checks on the font family under path.

    Args:
      path: A directory containing a METADATA.pb file.
    Returns:
      A list of ResultMessageTuple's.
    """
    try:
        fonts.Metadata(path)
    except ValueError as e:
        return [_SadResult("Bad METADATA.pb: " + e.message, path)]

    results = []
    if FLAGS.check_metadata:
        results.extend(_CheckLicense(path))
        results.extend(_CheckNameMatching(path))

    if FLAGS.check_font:
        results.extend(_CheckFontInternalValues(path))

    return results


def _CheckLicense(path):
    """Verifies that METADATA.pb license is correct under path.

    Assumes path is of the form .../<license>/<whatever>/METADATA.pb.

    Args:
      path: A directory containing a METADATA.pb file.
    Returns:
      A list with one ResultMessageTuple. If happy, license is good.
    """
    metadata = fonts.Metadata(path)
    lic = metadata.license
    lic_dir = os.path.basename(os.path.dirname(path))

    # We use /apache for the license Apache2
    if lic_dir == "apache":
        lic_dir += "2"

    result = _HappyResult("License consistantly %s" % lic, path)
    # if we were Python 3 we'd use casefold(); this will suffice
    if lic_dir.lower() != lic.lower():
        result = _SadResult(
            "Dir license != METADATA license: %s != %s" % (lic_dir, lic), path
        )

    return [result]


def _CheckNameMatching(path):
    """Verifies the various name fields in the METADATA.pb file are sane.

    Args:
      path: A directory containing a METADATA.pb file.
    Returns:
      A list of ResultMessageTuple, one per validation performed.
    """
    results = []
    metadata = fonts.Metadata(path)
    name = metadata.name

    for font in metadata.fonts:
        # We assume style/weight is correct in METADATA
        style = font.style
        weight = font.weight
        values = [
            ("name", name, font.name),
            ("filename", fonts.FilenameFor(name, style, weight, ".ttf"), font.filename),
            (
                "postScriptName",
                fonts.FilenameFor(name, style, weight),
                font.post_script_name,
            ),
            ("fullName", fonts.FullnameFor(name, style, weight), font.full_name),
        ]

        for key, expected, actual in values:
            if expected != actual:
                results.append(
                    _SadResult(
                        "%s METADATA %s/%d %s expected %s, got %s"
                        % (name, style, weight, key, expected, actual),
                        path,
                        _FixMetadata(style, weight, key, expected),
                    )
                )

    if not results:
        results.append(
            _HappyResult('METADATA name consistently derived from "%s"' % name, path)
        )

    return results


def _IsItalic(style):
    return style.lower() == "italic"


def _IsBold(weight):
    """Is this weight considered bold?

    Per Dave C, only 700 will be considered bold.

    Args:
      weight: Font weight.
    Returns:
      True if weight is considered bold, otherwise False.
    """
    return weight == 700


def _ShouldFix(key):
    return FLAGS.fix_type and (key in FLAGS.fix_type or "all" in FLAGS.fix_type)


def _FixMetadata(style, weight, key, expected):
    if not _ShouldFix(key):
        return None

    if not isinstance(expected, int):
        expected = "'%s'" % expected

    return (
        "[f for f in metadata.fonts if f.style == '%s' "
        "and f.weight == %d][0].%s = %s"
    ) % (style, weight, re.sub("([a-z])([A-Z])", r"\1_\2", key).lower(), expected)


def _FixFsSelectionBit(key, expected):
    """Write a repair script to fix a bad fsSelection bit.

    Args:
      key: The name of an fsSelection flag, eg 'ITALIC' or 'BOLD'.
      expected: Expected value, true/false, of the flag.
    Returns:
      A python script to fix the problem.
    """
    if not _ShouldFix("fsSelection"):
        return None

    op = "|="
    verb = "set"
    mask = bin(fonts.FsSelectionMask(key))
    if not expected:
        op = "&="
        verb = "unset"
        mask = "~" + mask

    return "ttf['OS/2'].fsSelection %s %s  # %s %s" % (op, mask, verb, key)


def _FixFsType(expected):
    if not _ShouldFix("fsType"):
        return None
    return "ttf['OS/2'].fsType = %d" % expected


def _FixWeightClass(expected):
    if not _ShouldFix("usWeightClass"):
        return None
    return "ttf['OS/2'].usWeightClass = %d" % expected


def _FixBadNameRecord(friendly_name, name_id, expected):
    if not _ShouldFix(friendly_name):
        return None

    return (
        "for nr in [n for n in ttf['name'].names if n.nameID == %d]:\n"
        "  nr.string = '%s'.encode(nr.getEncoding())  # Fix %s"
        % (name_id, expected, friendly_name)
    )


def _FixMissingNameRecord(friendly_name, name_id, expected):
    if not _ShouldFix(friendly_name):
        return None

    return (
        "nr = ttLib.tables._n_a_m_e.NameRecord()\n"
        "nr.nameID = %d  # %s"
        "nr.langID = 0x409\n"
        "nr.platEncID = 1\n"
        "nr.platformID = 3\n"
        "nr.string = '%s'.encode(nr.getEncoding())\n"
        "ttf['name'].names.append(nr)\n" % (name_id, friendly_name, expected)
    )


def _FixEmptyGlyphLsb(glyph_name):
    if not _ShouldFix("emptyGlyphLSB"):
        return None

    return "ttf['hmtx']['%s'] = [ttf['hmtx']['%s'][0], 0]\n" % (glyph_name, glyph_name)


def _CheckFontOS2Values(path, font, ttf):
    """Check sanity of values hidden in the 'OS/2' table.

    Notably usWeightClass, fsType, fsSelection.

    Args:
      path: Path to directory containing font.
      font: A font record from a METADATA.pb.
      ttf: A fontTools.ttLib.TTFont for the font.
    Returns:
      A list of ResultMessageTuple for tests performed.
    """
    results = []

    font_file = font.filename
    full_font_file = os.path.join(path, font_file)
    expected_style = font.style
    expected_weight = font.weight

    os2 = ttf["OS/2"]
    fs_selection_flags = fonts.FsSelectionFlags(os2.fsSelection)
    actual_weight = os2.usWeightClass
    fs_type = os2.fsType

    marked_oblique = "OBLIQUE" in fs_selection_flags
    marked_italic = "ITALIC" in fs_selection_flags
    marked_bold = "BOLD" in fs_selection_flags

    expect_italic = _IsItalic(expected_style)
    expect_bold = _IsBold(expected_weight)
    # Per Dave C, we should NEVER set oblique, use 0 for italic
    expect_oblique = False

    results.append(
        ResultMessageTuple(
            marked_italic == expect_italic,
            "%s %s/%d fsSelection marked_italic %d"
            % (font_file, expected_style, expected_weight, marked_italic),
            full_font_file,
            _FixFsSelectionBit("ITALIC", expect_italic),
        )
    )
    results.append(
        ResultMessageTuple(
            marked_bold == expect_bold,
            "%s %s/%d fsSelection marked_bold %d"
            % (font_file, expected_style, expected_weight, marked_bold),
            full_font_file,
            _FixFsSelectionBit("BOLD", expect_bold),
        )
    )

    results.append(
        ResultMessageTuple(
            marked_oblique == expect_oblique,
            "%s %s/%d fsSelection marked_oblique %d"
            % (font_file, expected_style, expected_weight, marked_oblique),
            full_font_file,
            _FixFsSelectionBit("OBLIQUE", expect_oblique),
        )
    )

    # For weight < 300, just confirm weight [250, 300)
    # TODO(user): we should also verify ordering is correct
    weight_ok = expected_weight == actual_weight
    weight_msg = str(expected_weight)
    if expected_weight < 300:
        weight_ok = actual_weight >= 250 and actual_weight < 300
        weight_msg = "[250, 300)"

    results.append(
        ResultMessageTuple(
            weight_ok,
            "%s %s/%d weight expected: %s usWeightClass: %d"
            % (font_file, expected_style, expected_weight, weight_msg, actual_weight),
            full_font_file,
            _FixWeightClass(expected_weight),
        )
    )

    expected_fs_type = 0
    results.append(
        ResultMessageTuple(
            expected_fs_type == fs_type,
            "%s %s/%d fsType expected: %d fsType: %d"
            % (font_file, expected_style, expected_weight, expected_fs_type, fs_type),
            full_font_file,
            _FixFsType(expected_fs_type),
        )
    )

    return results


def _CheckFontNameValues(path, name, font, ttf):
    """Check sanity of values in the 'name' table.

    Specifically the fullname and postScriptName.

    Args:
      path: Path to directory containing font.
      name: The name of the family.
      font: A font record from a METADATA.pb.
      ttf: A fontTools.ttLib.TTFont for the font.
    Returns:
      A list of ResultMessageTuple for tests performed.
    """
    results = []

    style = font.style
    weight = font.weight
    full_font_file = os.path.join(path, font.filename)

    expectations = [
        ("family", fonts.NAME_FAMILY, name),
        ("postScriptName", fonts.NAME_PSNAME, fonts.FilenameFor(name, style, weight)),
        ("fullName", fonts.NAME_FULLNAME, fonts.FullnameFor(name, style, weight)),
    ]

    for friendly_name, name_id, expected in expectations:
        # If you have lots of name records they should ALL have the right value
        actuals = fonts.ExtractNames(ttf, name_id)
        for idx, actual in enumerate(actuals):
            results.append(
                ResultMessageTuple(
                    expected == actual,
                    "%s %s/%d 'name' %s[%d] expected %s, got %s"
                    % (name, style, weight, friendly_name, idx, expected, actual),
                    full_font_file,
                    _FixBadNameRecord(friendly_name, name_id, expected),
                )
            )

        # should have at least one actual
        if not actuals:
            results.append(
                _SadResult(
                    "%s %s/%d 'name' %s has NO values"
                    % (name, style, weight, friendly_name),
                    full_font_file,
                    _FixMissingNameRecord(friendly_name, name_id, expected),
                )
            )

    return results


def _CheckLSB0ForEmptyGlyphs(path, font, ttf):
    """Checks if font has empty (loca[n] == loca[n+1]) glyphs that have non-0 lsb.

    There is no reason to set such lsb's.

    Args:
      path: Path to directory containing font.
      font: A font record from a METADATA.pb.
      ttf: A fontTools.ttLib.TTFont for the font.
    Returns:
      A list of ResultMessageTuple for tests performed.
    """
    results = []
    if "loca" not in ttf:
        return results
    for glyph_index, glyph_name in enumerate(ttf.getGlyphOrder()):
        is_empty = ttf["loca"][glyph_index] == ttf["loca"][glyph_index + 1]
        lsb = ttf["hmtx"][glyph_name][1]
        if is_empty and lsb != 0:
            results.append(
                _SadResult(
                    "%s %s/%d ['hmtx']['%s'][1] (lsb) should be 0 but is %d"
                    % (font.name, font.style, font.weight, glyph_name, lsb),
                    os.path.join(path, font.filename),
                    _FixEmptyGlyphLsb(glyph_name),
                )
            )
    return results


def _CheckFontInternalValues(path):
    """Validates fonts internal metadata matches METADATA.pb values.

    In particular, checks 'OS/2' {usWeightClass, fsSelection, fsType} and 'name'
    {fullName, postScriptName} values.

    Args:
      path: A directory containing a METADATA.pb file.
    Returns:
      A list of ResultMessageTuple, one per validation performed.
    """
    results = []
    metadata = fonts.Metadata(path)
    name = metadata.name

    for font in metadata.fonts:
        font_file = font.filename
        with contextlib.closing(ttLib.TTFont(os.path.join(path, font_file))) as ttf:
            results.extend(_CheckFontOS2Values(path, font, ttf))
            results.extend(_CheckFontNameValues(path, name, font, ttf))
            results.extend(_CheckLSB0ForEmptyGlyphs(path, font, ttf))

    return results


def _WriteRepairScript(dest_file, results):
    with open(dest_file, "w") as out:
        out.write("import collections\n")
        out.write("import contextlib\n")
        out.write("from fontTools import ttLib\n")
        out.write("from google.protobuf.text_format " "import text_format\n")
        out.write("from gftools.fonts_public_pb2 import fonts_pb2\n")
        out.write("from gftools.fonts_public_pb2 " "import fonts_metadata_pb2\n")
        out.write("\n")

        # group by path
        by_path = collections.defaultdict(list)
        for result in results:
            if result.happy or not result.repair_script:
                continue
            if result.repair_script not in by_path[result.path]:
                by_path[result.path].append(result.repair_script)

        for path in sorted(by_path.keys()):
            out.write("# repair %s\n" % os.path.basename(path))
            _, ext = os.path.splitext(path)

            prefix = ""
            if ext == ".ttf":
                prefix = "  "
                out.write(
                    "with contextlib.closing(ttLib.TTFont('%s')) as ttf:\n" % path
                )
            elif os.path.isdir(path):
                metadata_file = os.path.join(path, "METADATA.pb")
                out.write("metadata = fonts_pb2.FamilyProto()\n")
                out.write("with open('%s', 'r') as f:\n" % metadata_file)
                out.write("  text_format.Merge(f.read(), metadata)\n")
            else:
                raise ValueError("Not sure how to script %s" % path)

            for repair in by_path[path]:
                out.write(prefix)
                out.write(re.sub("\n", "\n" + prefix, repair))
                out.write("\n")

            if ext == ".ttf":
                out.write("  ttf.save('%s')\n" % path)

            if os.path.isdir(path):
                out.write("with open('%s', 'w') as f:\n" % metadata_file)
                out.write("  f.write(text_format.MessageToString(metadata))\n")

            out.write("\n")


def main(argv):
    result_code = 0
    all_results = []
    paths = [_DropEmptyPathSegments(os.path.expanduser(p)) for p in argv[1:]]
    for path in paths:
        if not os.path.isdir(path):
            raise ValueError("Not a directory: %s" % path)

    for path in paths:
        for font_dir in fonts.FontDirs(path):
            results = _SanityCheck(font_dir)
            all_results.extend(results)
            for result in results:
                result_msg = "pass"
                if not result.happy:
                    result_code = 1
                    result_msg = "FAIL"
                if not result.happy or not FLAGS.suppress_pass:
                    print("%s: %s (%s)" % (result_msg, result.message, font_dir))

    if FLAGS.repair_script:
        _WriteRepairScript(FLAGS.repair_script, all_results)

    sys.exit(result_code)


if __name__ == "__main__":
    app.run(main)
