#!/usr/bin/env python3
# Copyright 2026 The Dawn & Tint Authors
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

# For info about this script, see docs/clang-tidy.md.

import argparse
import datetime
import json
import re
import shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path


def fail(message: str) -> None:
    print('ERROR: ' + message, file=sys.stderr)
    sys.exit(1)


try:
    import yaml
except (ImportError, ModuleNotFoundError):
    fail("'yaml' module not found. Try running via 'vpython3'.")

DAWN_ROOT = Path(__file__).parent.parent.resolve()
if Path.cwd().resolve() != DAWN_ROOT:
    fail("Must be run from the Dawn root directory.")
RUN_TIMESTAMP = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')

# Locate required binaries/scripts
clang_tidy = Path('third_party/llvm-build/Release+Asserts/bin/clang-tidy')
if sys.platform == 'win32':
    clang_tidy = clang_tidy.with_suffix('.exe')
if not clang_tidy.is_file():
    fail(f'{clang_tidy} missing. See docs/clang-tidy.md.')

recipe_script = Path(
    'third_party/chromium-tools-build/src/recipes/recipe_modules/tricium_clang_tidy/resources/tricium_clang_tidy_script.py'
)
if not recipe_script.is_file():
    fail(f'{recipe_script} missing. See docs/clang-tidy.md.')


def run_command(args: list, capture_stdout: bool = False) -> str:
    str_args = [str(arg) for arg in args]
    try:
        result = subprocess.run(
            str_args,
            stdout=subprocess.PIPE if capture_stdout else None,
            text=True,
            check=True,
        )
        return result.stdout if capture_stdout else ''
    except subprocess.CalledProcessError as e:
        fail(f'Command failed: {" ".join(str_args)}\nError: {e}')


def has_source_file_extension(path: Path) -> bool:
    extensions = {
        '.c',
        '.cc',
        '.cpp',
        '.cxx',
        '.h',
        '.hh',
        '.hpp',
        '.hxx',
        '.m',
        '.mm',
    }
    ignore_patterns = {
        # Ignore files generated by protobuf, we have no control over them.
        '*.pb.cc',
        '*.pb.h',
    }
    return path.suffix.lower() in extensions and \
        not any(path.match(pattern) for pattern in ignore_patterns)


def find_source_files(paths: list[Path]) -> list[str]:
    files = []
    for path in paths:
        p = Path(path).resolve()
        if p.is_file():
            if has_source_file_extension(p):
                files.append(str(p.relative_to(DAWN_ROOT)))
            else:
                print(
                    f"Warning: Skipped explicitly-specified file because its "
                    f"filename doesn't look like a C/C++ source file: {path}",
                    file=sys.stderr)
        elif p.is_dir():
            for item in p.rglob('*'):
                if item.is_file() and has_source_file_extension(item):
                    files.append(str(item.relative_to(DAWN_ROOT)))
        else:
            fail(f'Not found: {path}')
    return files


def parse_args():
    parser = argparse.ArgumentParser(
        description=f'''
  Run clang-tidy on Dawn source files and directories. Reads .clangd to determine which out/* build directory should be used for which files. Outputs findings and a summary into each build directory.

  Basic usage: {sys.argv[0]} --no-clean --default

  The findings can be processed by tools/apply-clang-tidy-fixes.py. For more info on clang-tidy, see docs/clang-tidy.md.''',
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    # Options
    parser.add_argument(
        '--clean',
        action=argparse.BooleanOptionalAction,
        required=True,
        help=
        'Pass --clean to clean the build before building and running clang-tidy. Pass --no-clean to skip that, and promise there are no leftover no-longer-used generated files from old Dawn revisions.'
    )
    parser.add_argument(
        '--stdout-only',
        action='store_true',
        help=
        'Print the summary to stdout instead of saving the JSON and summary files to disk. Useful when iterating on a specific small directory of files.'
    )
    # Source files
    source_group = parser.add_mutually_exclusive_group(required=True)
    source_group.add_argument(
        '--default',
        action='store_true',
        help='Use default sources (all of our source files).')
    source_group.add_argument(
        'sources',
        nargs='*',
        default=[],
        help=
        'Specific source files and/or directories to run clang-tidy on. Any files which are not part of the build will be ignored.'
    )

    return parser.parse_args()


def load_outdir_patterns():
    # Use the user's .clangd settings to determine which files should be built
    # using which outdir.
    clangd_config_path = DAWN_ROOT / '.clangd'
    if not clangd_config_path.is_file():
        fail('Please set up your .clangd (see development-tips.md).')

    try:
        with open(clangd_config_path, 'r') as f:
            clangd_config_docs = list(yaml.safe_load_all(f))
    except yaml.YAMLError as e:
        fail(f'Error parsing .clangd: {e}')

    default_outdir = None
    outdir_to_patterns = {}

    for doc in clangd_config_docs:
        compile_flags = doc.get('CompileFlags', {})
        compilation_database = compile_flags.get('CompilationDatabase')
        if not compilation_database:
            continue

        outdir = Path(compilation_database)

        # Determine if there's a PathMatch condition
        path_matches = doc.get('If', {}).get('PathMatch', [])

        if not path_matches:
            # Default outdir
            if default_outdir is not None:
                fail(f'Found multiple default outdirs in .clangd: '
                     f'{default_outdir} and {outdir}')
            default_outdir = outdir
        else:
            if outdir in outdir_to_patterns:
                fail(f'Duplicate outdir found in .clangd: {outdir}')
            outdir_to_patterns[outdir] = path_matches

    if default_outdir is None:
        fail('No default outdir (no If.PathMatch) found in .clangd.')
    if default_outdir in outdir_to_patterns:
        fail(f'Duplicate outdir found in .clangd: {default_outdir}')

    return [default_outdir, outdir_to_patterns]


def set_up_builds(args, default_outdir, outdirs):
    # Set up build so that clang-tidy can use compile_commands.json.
    for outdir in outdirs:
        if not (outdir / 'args.gn').is_file():
            fail(f'{outdir} does not have an args.gn.')

        run_command(['gn', 'gen', outdir])

        # Check that Wasm is disabled, because clang-tidy doesn't understand em++
        # commands (it doesn't know where the sysroot is because it's implicit).
        #
        # TODO(crbug.com/501491694): Fix this by setting the sysroot via additional
        # flag in .clang-tidy, but is there a way to do this only for Emscripten
        # commands? Also add third_party/emdawnwebgpu and outdir/wasm/gen
        # (or outdir/*/gen) to --default if this is fixed.
        gn_has_wasm = run_command(
            [
                'gn', 'args', outdir, '--short',
                '--list=dawn_build_emdawnwebgpu'
            ],
            capture_stdout=True,
        ).strip() != 'dawn_build_emdawnwebgpu = false'
        if gn_has_wasm:
            fail(f'Wasm build must be disabled for clang-tidy. Set '
                 f'dawn_build_emdawnwebgpu = false in {outdir}/args.gn.')

    # Clean if requested. Note this will aloso remove old findings files.
    if args.clean:
        run_command(['gn', 'clean', default_outdir])
    # Make sure the default_outdir build is up to date. Really we only need to
    # regenerate generated files, but we don't have a way to just regenerate
    # files, and building shouldn't take that long anyway. We don't need the
    # non-default outdirs because we won't look at their generated files.
    run_command(['autoninja', '-C', default_outdir])


def expand_source_paths(args, default_outdir):
    source_paths = []
    if args.default:
        source_paths = [
            DAWN_ROOT / 'include',
            DAWN_ROOT / 'src',
            default_outdir / 'gen' / 'include',
            default_outdir / 'gen' / 'src',
        ]
    elif args.sources:
        source_paths = [Path(s) for s in args.sources]

    # Find source files
    source_files = find_source_files(source_paths)
    if not source_files:
        fail('No matching source files found to run clang-tidy on.')
    return source_files


def make_outdir_mapping(default_outdir, outdir_to_patterns, source_files):
    # Initialize mapping from outdir to its assigned files
    outdir_to_files = {default_outdir: set()}
    for outdir in outdir_to_patterns:
        outdir_to_files[outdir] = set()

    # Map each file using PathMatch regex patterns
    for file_path_str in source_files:
        assigned = False
        for outdir, patterns in outdir_to_patterns.items():
            for pattern in patterns:
                try:
                    if re.match(pattern, file_path_str):
                        outdir_to_files[outdir].add(file_path_str)
                        assigned = True
                        break
                except re.error as e:
                    fail(f"Invalid regex pattern in .clangd: {pattern}")
            if assigned:
                break
        if not assigned:
            outdir_to_files[default_outdir].add(file_path_str)

    return outdir_to_files


def run_tidy(args, outdir: Path, files_to_lint):
    if not files_to_lint:
        return ''

    print(f'\nRunning clang-tidy for build directory: {outdir}')

    findings_file = outdir / f'clang-tidy-{RUN_TIMESTAMP}-findings.json'
    summary_file = outdir / f'clang-tidy-{RUN_TIMESTAMP}-summary.txt'

    script_args = [
        f'--base_path={DAWN_ROOT}',
        f'--out_dir={outdir}',
        f'--clang_tidy_binary={clang_tidy}',
        f'--findings_file={findings_file}',
        '--no_clean',  # We handled cleaning/building already, if requested
        '--windows',  # Also recognize windows-style compile commands if there are any
    ]
    script_args.extend(files_to_lint)

    run_command([recipe_script] + script_args)

    # Lists of diagnostics. Each item is a comparable tuple of strings+numbers,
    # allowing the array to be sorted. Then the tuple is concatenated to print.
    summary_unknowns = []
    summary_compiler = []
    summary_tidy = []

    with open(findings_file) as f:
        findings = json.load(f)

        seen_file_paths = set()
        for diagnostic in findings['diagnostics']:
            file_path = diagnostic['file_path']
            line_number = diagnostic['line_number']
            diag_name = diagnostic['diag_name']
            message = diagnostic['message']
            replacement_count = len(diagnostic['replacements'])

            explanation = f': {message} [{diag_name}]'
            if replacement_count:
                explanation += f' ({replacement_count} fixits)'
            line = (f'{file_path}:', line_number, explanation)

            if diag_name.startswith('clang-diagnostic-'):
                summary_compiler.append(line)
            else:
                summary_tidy.append(line)

            seen_file_paths.add(file_path)
            for expansion_loc in diagnostic['expansion_locs']:
                seen_file_paths.add(expansion_loc['file_path'])

        for file_path in findings['failed_tidy_files']:
            if file_path not in seen_file_paths:
                summary_unknowns.append(
                    (file_path, ': found in failed_tidy_files'))
        for file_path in findings['failed_src_files']:
            if file_path not in seen_file_paths:
                summary_unknowns.append(
                    (file_path, ': found in failed_src_files'))
        for file_path in findings['timed_out_src_files']:
            if file_path not in seen_file_paths:
                summary_unknowns.append(
                    (file_path, ': found in timed_out_src_files'))

    summary_unknowns.sort()
    summary_compiler.sort()
    summary_tidy.sort()

    summary = ''
    summary = f'[{outdir}] Command line:\n'
    summary += '  ' + shlex.join(sys.argv[:]) + '\n'
    summary += f'\n[{outdir}] Files that failed without any diagnostics:\n'

    for line in summary_unknowns:
        summary += '  ' + ''.join(map(str, line)) + '\n'
    summary += f'\n[{outdir}] Compiler diagnostics clang-diagnostic-*):\n'
    for line in summary_compiler:
        summary += '  ' + ''.join(map(str, line)) + '\n'
    summary += f'\n[{outdir}] Clang-Tidy diagnostics:\n'
    for line in summary_tidy:
        summary += '  ' + ''.join(map(str, line)) + '\n'

    if args.stdout_only:
        findings_file.unlink()
        return summary
    else:
        with open(summary_file, 'w') as f:
            f.write(summary)
        return (f'- Full findings JSON saved to {findings_file}\n'
                f'  Readable summary saved to {summary_file}\n')


def main():
    args = parse_args()

    [default_outdir, outdir_patterns] = load_outdir_patterns()
    set_up_builds(args, default_outdir, outdir_patterns.keys())
    source_files = expand_source_paths(args, default_outdir)
    outdir_to_files = make_outdir_mapping(default_outdir, outdir_patterns,
                                          source_files)

    exit_message = ''
    for outdir, files_to_lint in outdir_to_files.items():
        print(f'Found {len(files_to_lint)} source files to lint for {outdir}.')
        exit_message += run_tidy(args, outdir, files_to_lint)

    print('\n------------------------------------------------------------\n')
    print(exit_message)


if __name__ == '__main__':
    main()
