#!/usr/bin/env python3
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""update_api.py - Update committed Cronet API."""

import argparse
import filecmp
import hashlib
import os
import re
import shutil
import sys
import tempfile

REPOSITORY_ROOT = os.path.abspath(
    os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))

sys.path.insert(0, REPOSITORY_ROOT)
from components.cronet.tools import utils  # pylint: disable=wrong-import-position

# Filename of dump of current API.
API_FILENAME = os.path.abspath(
    os.path.join(os.path.dirname(__file__), '..', 'android', 'api.txt'))
# Filename of file containing API version number.
API_VERSION_FILENAME = os.path.abspath(
    os.path.join(os.path.dirname(__file__), '..', 'android',
                 'api_version.txt'))

# Regular expression that catches the beginning of lines that declare classes.
# The first group returned by a match is the class name.
CLASS_RE = re.compile(r'.*(class|interface) ([^ ]*) .*\{')

# Regular expression that matches a string containing an unnamed class name,
# for example 'Foo$1'.
UNNAMED_CLASS_RE = re.compile(r'.*\$[0-9]')

# javap still prints internal (package private, nested...) classes even though
# -protected is passed so they need to be filtered out.
INTERNAL_CLASS_RE = re.compile(
    r'^(?!public ((final|abstract) )?(class|interface)).*')


def _split_by_class(javap_output):
    """Splits the combined javap output to separate classes.

   * Removes unneeded comments like "Compiled from ...".
   * Sorts the declarations inside the class.

  Returns an array where each element represents a class.
  """
    current_class_lines = []
    all_classes = []
    for line in javap_output:
        # Lines starting with Compiled from are just comments and not part of the
        # api.
        if line.startswith('Compiled from'):
            continue
        current_class_lines.append(line)
        if line == '}':
            # sort only the lines between the {}.
            current_class_lines = ([current_class_lines[0]] +
                                   sorted(current_class_lines[1:-1]) +
                                   [current_class_lines[-1]])
            all_classes.append(current_class_lines)
            current_class_lines = []
    return all_classes


def _generate_api(api_jar, output_filename, verbose=False):
    """Dumps the API in |api_jar| into |outpuf_filename|."""
    # Extract API class files from api_jar.
    with tempfile.TemporaryDirectory() as temp_dir:
        api_jar_path = os.path.abspath(api_jar)
        jar_cmd = [utils.JAR_PATH, 'xf', api_jar_path]
        utils.run(jar_cmd, verbose=verbose, cwd=temp_dir)

        shutil.rmtree(os.path.join(temp_dir, 'META-INF'), ignore_errors=True)

        # Collect paths of all API class files
        api_class_files = []
        for root, _, filenames in os.walk(temp_dir):
            api_class_files += [os.path.join(root, f) for f in filenames]
        api_class_files.sort()

        output_lines = [
            'DO NOT EDIT THIS FILE, USE update_api.py TO UPDATE IT\n'
        ]
        javap_cmd = [utils.JAVAP_PATH, '-protected'] + api_class_files
        javap_output = utils.run_and_get_stdout(javap_cmd, verbose=verbose)

    all_classes = _split_by_class(javap_output.splitlines())
    for class_lines in all_classes:
        first_line = class_lines[0]
        # Skip classes we do not care about.
        if UNNAMED_CLASS_RE.match(first_line) or INTERNAL_CLASS_RE.match(
                first_line):
            continue
        output_lines.extend(class_lines)

    output_string = '\n'.join(output_lines) + '\n'
    md5_hash = hashlib.md5()
    md5_hash.update(output_string.encode('utf-8'))
    output_string += 'Stamp: %s\n' % md5_hash.hexdigest()

    with open(output_filename, 'w') as output_file:
        output_file.write(output_string)


def check_up_to_date(api_jar, verbose=False):
    """Returns True if API_FILENAME matches the API exposed by |api_jar|."""
    with tempfile.NamedTemporaryFile() as temp:
        _generate_api(api_jar, temp.name, verbose=verbose)
        return filecmp.cmp(API_FILENAME, temp.name)


def _check_api_update(old_api, new_api):
    """Enforce that lines are only added when updating API."""
    new_hash = hashlib.md5()
    old_hash = hashlib.md5()
    seen_stamp = False
    with open(old_api, 'r') as old_api_file, open(new_api,
                                                  'r') as new_api_file:
        for old_line in old_api_file:
            while True:
                new_line = new_api_file.readline()
                if seen_stamp:
                    print('ERROR: Stamp is not the last line.')
                    return False
                if new_line.startswith('Stamp: ') and old_line.startswith(
                        'Stamp: '):
                    if old_line != 'Stamp: %s\n' % old_hash.hexdigest():
                        print(
                            'ERROR: Prior api.txt not stamped by update_api.py'
                        )
                        return False
                    if new_line != 'Stamp: %s\n' % new_hash.hexdigest():
                        print(
                            'ERROR: New api.txt not stamped by update_api.py')
                        return False
                    seen_stamp = True
                    break
                new_hash.update(new_line.encode('utf8'))
                if new_line == old_line:
                    break
                if not new_line:
                    if old_line.startswith('Stamp: '):
                        print(
                            'ERROR: New api.txt not stamped by update_api.py')
                    else:
                        print('ERROR: This API was modified or removed:')
                        print('           ' + old_line)
                        print(
                            '       Cronet API methods and classes cannot be modified.'
                        )
                    return False
            old_hash.update(old_line.encode('utf8'))
    if not seen_stamp:
        print('ERROR: api.txt not stamped by update_api.py.')
        return False
    return True


def main(args):
    parser = argparse.ArgumentParser(description='Update Cronet api.txt.')
    parser.add_argument('--api_jar',
                        help='Path to API jar (i.e. cronet_api.jar)',
                        required=True,
                        metavar='path/to/cronet_api.jar')
    parser.add_argument(
        '--allow_breaking_api_changes',
        help=
        'If true, allows changing the API surface in a non-backward compatible manner (e.g., deleting existing APIs)',
        required=False,
        default=False,
        action='store_true')
    parser.add_argument('--verbose',
                        help='Print verbose output.',
                        action='store_true')
    opts = parser.parse_args(args)

    if check_up_to_date(opts.api_jar, verbose=opts.verbose):
        return True

    with tempfile.NamedTemporaryFile() as temp:
        _generate_api(opts.api_jar, temp.name, verbose=opts.verbose)
        if opts.allow_breaking_api_changes or _check_api_update(
                API_FILENAME, temp.name):
            # Update API version number to new version number
            with open(API_VERSION_FILENAME, 'r+') as f:
                version = int(f.read())
                f.seek(0)
                f.write(str(version + 1))
            # Update API file to new API
            shutil.copyfile(temp.name, API_FILENAME)
            return True
    return False


if __name__ == '__main__':
    sys.exit(0 if main(sys.argv[1:]) else -1)
