""" Execute ESLint and show results to CodeSonar. """

import argparse
import os
from shlex import split as cmdline2list
from subprocess import Popen, list2cmdline
import sys
from tempfile import mkstemp

from gtr.globmatch import glob, globescape

import import_sarif

# ESLint seems to return 2 for problems unrelated to the lint warnings.
#  This seems not to be limited to commandline syntax issues,
#  for example ESLint will return 2 if it cannot write to the output file location.
ESLINT_WARNINGS_RETURNCODE = 1

ESLINT_SARIF_FORMATTER = '@microsoft/eslint-formatter-sarif'

NPM_MODULES_DIRNAME = 'node_modules'

class RespFileArgumentParser(argparse.ArgumentParser):
    """ Extend basic ArgumentParser to parse arguments from response files. """
    def convert_arg_line_to_args(self, arg_line):
        return cmdline2list(arg_line)


def split_csvs(sep, *csv_strs):
    """ Combine several separated-value strings and split them. """
    return sep.join(csv_strs).split(sep)


def main(argv, stdio):
    """ High-level script to invoke ESLint subprocess and CodeSonar analysis import commands. """
    # flags for import_sarif.py:
    INCLUDE_FLAG = '-include-sources'
    EXCLUDE_FLAG = '-exclude-sources'
    EXCLUDE_SIGIL = '!'
    ANALYZER_ARG_FLAG = '-X'
    EXTS_SEP = ','
    DEFAULT_ESLINT_EXTS = [
        '.js', '.mjs', '.cjs', '.jsx',
        '.ts', '.mts', '.cts', '.tsx',
    ]
    linesep = '\n'
    stderr = stdio[2]
    returncode = 0
    parser = RespFileArgumentParser(
        fromfile_prefix_chars='@',
        description='Scan JavaScript and TypeScript source files for CodeSonar analysis.',
        )
    parser.add_argument(
        'source_inputs',
        metavar='SOURCE',
        nargs='*',
        help='Base directory containing source files to analyze.')
    parser.add_argument(
        '-C', '--directory',
        metavar='DIR',
        dest='base_dir',
        help='Change to directory DIR before analyzing.')
    parser.add_argument(
        '-sarif-output',
        dest='sarif_output',
        help='Name of file to save analysis results in SARIF format.  Can be used for diagnostics.')
    parser.add_argument(
        '-include-sources',
        metavar='PATTERN',
        dest='source_args',
        action='append',
        type=(lambda s: (INCLUDE_FLAG, s)),
        help="File pattern for source files to include when importing.")
    parser.add_argument(
        '-exclude-sources',
        metavar='PATTERN',
        dest='source_args',
        action='append',
        type=(lambda s: (EXCLUDE_FLAG, s)),
        help="File pattern for source files to exclude when importing.")
    parser.add_argument(
        '-source-max-bytes',
        dest='source_max_size',
        type=int,
        help="The maximum size in bytes for any included source file.")
    parser.add_argument(
        '-ext',
        dest='source_exts',
        action='append',
        default=None,
        help='Source file extensions to use when searching for source files in the analysis.'
             ' This option will override the default list of extensions.'
             ' Multiple extensions may be separated by a comma "," character.')
    parser.add_argument(
        '-ext+',
        dest='add_source_exts',
        action='append',
        default=None,
        help='Additional source file extensions to use when searching for source files in the analysis.'
             ' This option will add more extensions to the default list of extensions.'
             ' Multiple extensions may be separated by a comma "," character.')
    parser.add_argument(
        '-eslint-cmd',
        dest='eslint_cmd',
        help='Alternate ESLint command.')
    parser.add_argument(
        ANALYZER_ARG_FLAG,
        dest='analyzer_args',
        action='append',
        help='Arguments to pass to ESLint.  Arguments should be prefixed and separated by ",".  E.g. "-X,--cache,--debug"')
    arg_obj, unknown_args = parser.parse_known_args(argv[1:])

    for arg in unknown_args:
        if arg.startswith(ANALYZER_ARG_FLAG):
            arg_obj.analyzer_args.append(arg[len(ANALYZER_ARG_FLAG):])
        else:
            stderr.write(f'Error: unrecognized argument: {arg}\n')
            return 2
    analyzer_args = []
    for arg in arg_obj.analyzer_args or ():
        if not arg:
            continue
        sep = arg[0]
        arg = arg[len(sep):]
        args = arg.split(sep)
        analyzer_args.extend(args)

    source_args = arg_obj.source_args or []
    analyzer_inputs = arg_obj.source_inputs
    source_max_size = arg_obj.source_max_size
    sarif_file = arg_obj.sarif_output
    base_dir = arg_obj.base_dir
    eslint_cmd_str = arg_obj.eslint_cmd
    source_exts = arg_obj.source_exts
    add_source_exts = arg_obj.add_source_exts
    if not source_exts:
        source_exts = list(DEFAULT_ESLINT_EXTS)
    else:
        source_exts = split_csvs(EXTS_SEP, *source_exts)
    if add_source_exts:
        source_exts.extend(split_csvs(EXTS_SEP, *add_source_exts))

    if not source_args and not analyzer_inputs:
        parser.print_usage(stderr)
        stderr.write("Error: An analysis target is required.  Please provide a SOURCE argument or use -include-sources to specify a pattern.\n")
        return 2

    # Try to normalize out extra slashes and '.' references,
    #  this helps ensure source file patterns will match successfully.
    if base_dir:
        base_dir = os.path.normpath(base_dir)
    else:
        base_dir = None
    if base_dir and base_dir == os.path.curdir:
        base_dir = None
    source_args2 = []
    if not analyzer_inputs:
        # If no input items, use -include-sources, etc.
        #  to decide what to pass to the analyzer.
        # User will need to exclude node_modules with their source_args in this case.
        # analyzer_inputs must be relative to base_dir.
        analyzer_source_patterns = [
            pat if flag == INCLUDE_FLAG else EXCLUDE_SIGIL + pat
            for (flag, pat) in source_args]
        analyzer_inputs = glob(analyzer_source_patterns or (), cwd=base_dir or None, filesonly=True)
    else:
        # If there are analyzer_inputs then use inputs to construct
        #  initial include patterns for import_sarif.py.
        for path in analyzer_inputs:
            # source_args must be relative to cwd (i.e. joined with base_dir)
            path2 = os.path.join(base_dir or '', path)
            if os.path.isdir(path2):
                # Use normpath to remove initial './' if it exists.
                #  This can help to ensure compatibility with exclusion patterns.
                source_args2.extend((
                    (
                        INCLUDE_FLAG,
                        os.path.normpath(os.path.join(
                            globescape(path2), '**', '*' + ext)),
                    )
                    for ext in source_exts))
            else:
                source_args2.append(
                    (
                        INCLUDE_FLAG,
                        globescape(path2),
                    ))
        # Automatically exclude node_modules by default.
        #  ESLint ignores node_modules.
        #  If user really wants this, they can include it with a source_arg
        source_args2.append((
                EXCLUDE_FLAG,
                os.path.join(base_dir or '', NPM_MODULES_DIRNAME, '**', '*')))
    # source_args must be relative to cwd (i.e. joined with base_dir)
    if not base_dir:
        source_args2.extend(source_args)
    else:
        source_args2.extend(((f, os.path.join(base_dir, a)) for (f, a) in source_args))
    source_args = source_args2

    shl_ext = ''
    if sys.platform == 'win32':
        # On Windows, we need to execute 'npx.cmd',
        #  there is also an 'npx' with no extension,
        #  so we must provide the extension to avoid confusion in Popen.
        shl_ext = '.cmd'
    npm_cmd = 'npm' + shl_ext
    npx_cmd = 'npx' + shl_ext

    eslint_cmd = None
    if eslint_cmd_str:
        eslint_cmd = cmdline2list(eslint_cmd_str)
    else:
        # The following are equivalent commands:
        #eslint_cmd = [npm_cmd, 'exec', '--', 'eslint']
        eslint_cmd = [npx_cmd, 'eslint']

    temp_sarif_file = None
    if not sarif_file:
        fd, temp_sarif_file = mkstemp(prefix='codesonar.es_scan.', suffix='.sarif', dir='.')
        os.close(fd)
        sarif_file = temp_sarif_file
    try:
        sarif_argv = ['import_sarif.py', sarif_file]
        if base_dir:
            # eslint-formatter-sarif writes absolute URIs to the SARIF,
            #  so providing a base for relative paths should be unnecessary.
            #  None-the-less, include -path-base anyway just to be safe:
            sarif_argv.extend(('-path-base', base_dir))
        if source_max_size:
            sarif_argv.extend(('-source-max-bytes', str(source_max_size)))
        for flag, pattern in source_args:
            sarif_argv.append(flag)
            sarif_argv.append(pattern)
        assert eslint_cmd
        # If the eslint command is npm, then use npm to check for existince of SARIF formatter.
        # If the eslint command is not npm, then don't make any assumptions about it.
        if eslint_cmd[0] in (npm_cmd, npx_cmd):
            npm_argv = [npm_cmd, 'ls', ESLINT_SARIF_FORMATTER]
            npm = Popen(npm_argv, cwd=base_dir or None)
            npm.wait()
            if npm.returncode:
                stderr.write(
                    'Warning: it appears that the ESLint SARIF formatter is not installed.\n'
                    'The ESLint SARIF formatter is required for importing results into CodeSonar.\n'
                    'To install the ESLnt SARIF formatter using npm, execute: \n'
                    f'    {ESLINT_SARIF_FORMATTER}\n')
                stderr.flush()
            npm = None
        eslint_argv = list(eslint_cmd)
        eslint_argv.extend([
            '-f', '@microsoft/eslint-formatter-sarif',
            '-o', sarif_file]) 
        eslint_argv.extend(analyzer_args)
        eslint_argv.extend(analyzer_inputs)
        stderr.write(list2cmdline(eslint_argv))
        stderr.write(linesep)
        stderr.flush()
        eslint = Popen(eslint_argv, cwd=base_dir or None)
        eslint_returncode = eslint.wait()
        if eslint_returncode and eslint_returncode != ESLINT_WARNINGS_RETURNCODE:
            returncode = eslint_returncode
        if not returncode:
            stderr.write(list2cmdline(sarif_argv))
            stderr.write(linesep)
            stderr.flush()
            returncode = import_sarif.main(sarif_argv)
    finally:
        if temp_sarif_file:
            os.remove(temp_sarif_file)
    return returncode


if __name__ == '__main__':
    sys.exit(main(sys.argv, (sys.stdin, sys.stdout, sys.stderr)))
