#!/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 shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
DAWN_ROOT = SCRIPT_DIR.parent


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


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.full_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 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 main():
    if Path.cwd().resolve() != DAWN_ROOT.resolve():
        fail(f'Error: must be run from the Dawn root directory.')

    parser = argparse.ArgumentParser(
        description='Run clang-tidy on Dawn source files and directories.',
        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.'
    )
    parser.add_argument('outdir', help='The build directory, e.g., out/debug')

    # 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(
        '--all',
        action='store_true',
        help='Run across all C/C++ files known to ninja (not recommended).')
    source_group.add_argument(
        'sources',
        nargs='*',
        help=
        'Specific source files and/or directories to run clang-tidy on. Any files which are not part of the build will be ignored.'
    )

    args = parser.parse_args()

    outdir = Path(args.outdir)

    # 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.')

    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('Wasm build must be disabled for clang-tidy. Set '
             'dawn_build_emdawnwebgpu = false in args.gn.')

    # Clean if requested.
    if args.clean:
        run_command(['gn', 'clean', outdir])

    # Make sure the build is up to date. Really we only need to regenerate
    # generated files, but building everything doesn't take that long.
    run_command(['autoninja', '-C', outdir])

    # Get the source paths from the command line, or from the defaults.
    source_paths = []
    if args.default:
        source_paths = [
            DAWN_ROOT / 'include',
            DAWN_ROOT / 'src',
            outdir / 'gen' / 'include',
            outdir / 'gen' / 'src',
        ]
    elif args.sources:
        source_paths = [Path(s) for s in args.sources]

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

    timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')
    findings_file = Path(f'clang-tidy-{timestamp}-findings.json')
    summary_file = Path(f'clang-tidy-{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
    ]

    print('Running on:')
    if args.all:
        script_args.append('--all')
        print('  --all')
        print(f'Clang-tidy will run on ALL files used in the build.')
    else:
        for d in source_paths:
            print(f'  {d}')
        print(f'Found {len(source_files)} source files to lint.')
        script_args.extend(source_files)

    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']
            line = (f'{file_path}:', line_number, f': {message} [{diag_name}]')
            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()

    print('\n------------------------------------------------------------\n')
    with (sys.stdout if args.stdout_only else open(summary_file, 'w')) as f:
        f.write('Command line:\n')
        f.write('  ' + shlex.join(sys.argv[:]) + '\n')
        f.write('\nFiles that failed with no associated diagnostics:\n')
        for line in summary_unknowns:
            f.write('  ' + ''.join(map(str, line)) + '\n')
        f.write('\nCompiler diagnostics (clang-diagnostic-*):\n')
        for line in summary_compiler:
            f.write('  ' + ''.join(map(str, line)) + '\n')
        f.write('\nClang-Tidy diagnostics:\n')
        for line in summary_tidy:
            f.write('  ' + ''.join(map(str, line)) + '\n')

    if args.stdout_only:
        findings_file.unlink()
    else:
        print(f'Full findings JSON saved to {findings_file}')
        print(f'Readable summary saved to {summary_file}')


if __name__ == '__main__':
    main()
