#!/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)

