#!/usr/bin/env python3

"""
(C) 2023 Jack Lloyd

Botan is released under the Simplified BSD License (see license.txt)
"""

import subprocess
import sys
import optparse # pylint: disable=deprecated-module
import multiprocessing
import difflib
import time
import os
import re
import shutil
from multiprocessing.pool import ThreadPool

def clang_format_style_path():
    script_location = os.path.dirname(os.path.abspath(__file__))
    relative_style_path = "../../configs/clang-format"
    return os.path.realpath(os.path.join(script_location, relative_style_path))

def run_command(cmdline):
    proc = subprocess.Popen(cmdline,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE)

    (stdout, stderr) = proc.communicate()

    stdout = stdout.decode('utf8')
    stderr = stderr.decode('utf8')

    return (stdout, stderr)

def apply_clang_format(clang_format, style_file, source_file):
    cmdline = [clang_format, f'--style=file:{style_file}', '-i', source_file]
    (stdout, stderr) = run_command(cmdline)

    if stdout != '' or stderr != '':
        print("Running '%s' stdout: '%s' stderr: '%s''" % (' '.join(cmdline), stdout, stderr))
        return False
    return True

def run_diff(source_file, formatted_contents):
    original_contents = open(source_file, encoding='utf8').read()
    if original_contents == formatted_contents:
        return ''

    return '\n'.join(difflib.unified_diff(
        original_contents.splitlines(),
        formatted_contents.splitlines(),
        fromfile="%s (original)" % (source_file),
        tofile="%s" % (source_file),
        lineterm="",
    ))

def check_clang_format(clang_format, style_file, source_file):
    cmdline = [clang_format, f'--style=file:{style_file}', source_file]
    (stdout, stderr) = run_command(cmdline)

    if stderr != '':
        print("Running '%s' stderr: '%s''" % (' '.join(cmdline), stderr))
        return False

    diff = run_diff(source_file, stdout)
    if diff != '':
        print(diff)
        return False

    return True

def list_source_files_in(directory):
    excluded = ['pkcs11.h']

    for (dirpath, _, filenames) in os.walk(directory):
        for filename in filenames:
            if filename.endswith('.cpp') or filename.endswith('.h'):

                if filename not in excluded:
                    yield os.path.join(dirpath, filename)

def filter_files(files, filters):
    if len(filters) == 0:
        return files

    files_to_fmt = []

    for file in files:
        for filt in filters:
            if file.find(filt) >= 0:
                files_to_fmt.append(file)
                break

    return files_to_fmt

def find_clang_format_binary(req_version):
    for binary_name in ['clang-format', f'clang-format-{req_version}']:
        binary = shutil.which(binary_name)
        if binary is not None:
            return binary
    return None

# Run clang-version -version and return the major version
def clang_format_version(clang_format):
    clang_format_version_re = re.compile(r'^(.* )?clang-format version ([0-9]+)\.([0-9]+)\.([0-9]+)')

    (stdout, stderr) = run_command([clang_format, '-version'])

    if stderr != '':
        print("Error trying to get clang-format version number: '%s'" % (stderr))
        return None

    version = clang_format_version_re.match(stdout)

    if version is None:
        print("Cannot interpret clang-format output (%s) as version" % (stdout.strip()))
        return None

    return int(version.group(2))

def main(args = None):
    if args is None:
        args = sys.argv

    parser = optparse.OptionParser()

    parser.add_option('-j', '--jobs', action='store', type='int', default=0)
    parser.add_option('--src-dir', metavar='DIR', default='src')
    parser.add_option('--check', action='store_true', default=False)
    parser.add_option('--clang-format-binary', metavar='PATH')
    parser.add_option('--style-file', metavar='FILE', default=clang_format_style_path())
    parser.add_option('--skip-version-check', action='store_true', default=False)

    (options, args) = parser.parse_args(args)

    # This check is probably stricter than we really need, and should
    # be revised as we gain more experience with clang-format
    req_version = 17

    clang_format = options.clang_format_binary or find_clang_format_binary(req_version)
    if clang_format is None:
        print("Failed to find clang-format binary, exiting")
        return 1

    if not options.skip_version_check:
        version = clang_format_version(clang_format)

        if version is None:
            print("Failed to get clang-format version, exiting")
            return 1

        if version != req_version:
            print("This script requires clang-format %d but current version is %d" % (req_version, version))
            print("Use --skip-version-check to carry on, however formatting may be incorrect")
            return 1

    if not os.path.isfile(options.style_file):
        print("Failed to find file containing clang-format configuration")
        return 1

    jobs = options.jobs
    if jobs == 0:
        jobs = multiprocessing.cpu_count() + 1

    pool = ThreadPool(jobs)

    start_time = time.time()

    all_files = sorted(list_source_files_in(options.src_dir))

    if len(all_files) == 0:
        print("Error: unable to find any source files in %s'" % (options.src_dir))
        return 1

    files_to_fmt = filter_files(all_files, args[1:])

    if len(files_to_fmt) == 0:
        print("Error: filter does not match any files")
        return 1

    # this file is incredibly slow to format so start it early
    slow_to_format = ['os_utils.cpp']

    first = [x for x in files_to_fmt if os.path.basename(x) in slow_to_format]
    rest = [x for x in files_to_fmt if x not in first]

    files_to_fmt = first + rest

    results = []
    for file in files_to_fmt:
        if options.check:
            results.append(pool.apply_async(check_clang_format, (clang_format, options.style_file, file,)))
        else:
            results.append(pool.apply_async(apply_clang_format, (clang_format, options.style_file, file,)))

    fail_execution = False

    for result in results:
        ok = result.get()
        if not ok:
            fail_execution = True

    time_consumed = time.time() - start_time

    print("Formatted %d files in %d seconds" % (len(files_to_fmt), time_consumed))

    return -1 if fail_execution else 0

if __name__ == '__main__':
    sys.exit(main())
