""" Execute golang staticcheck linter and expose results to CodeSonar. """

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

from gtr.globmatch import iglob

import import_sarif


GO_PKG_PATTERN_WILDCARD = '...'
GO_MOD_FILENAME = 'go.mod'
STATICCHECK_ALL_CHECKS = 'all'
STATICCHECK_NO_CHECKS = 'none'


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 main(argv, stdio):
    """ High-level script to invoke Golang staticcheck linter 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'
    linesep = '\n'
    stderr = stdio[2]
    returncode = 0
    parser = RespFileArgumentParser(
        fromfile_prefix_chars='@',
        description='Scan Go source code for CodeSonar analysis',
    )
    parser.add_argument(
        'source_inputs',
        metavar='SOURCE',
        nargs='+',
        help='Go packages or 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.')
    parser.add_argument(
        '-include-sources',
        dest='PATTERN',
        action='append',
        type=(lambda s: (INCLUDE_FLAG, s)),
        help='File pattern for source files to include in analysis.')
    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 from analysis.')
    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(
        '-staticcheck',
        metavar='PATH',
        dest='staticcheck_path',
        help='Path to staticcheck executable')
    parser.add_argument(
        ANALYZER_ARG_FLAG,
        dest='analyzer_args',
        action='append',
        help='Arguments to pass to staticcheck.  Arguments should be prefixed and separated by "+".  E.g "-X+-checks+SA1019".')
    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)

    analyzer_inputs = arg_obj.source_inputs or ()
    source_args = arg_obj.source_args or ()
    source_max_size = arg_obj.source_max_size
    sarif_file = arg_obj.sarif_output
    base_dir = arg_obj.base_dir
    staticcheck_path = arg_obj.staticcheck_path
    fail_checks = STATICCHECK_NO_CHECKS

    staticcheck_cmd = 'staticcheck'
    if staticcheck_path:
        staticcheck_cmd = staticcheck_path
    else:
        staticcheck_path = which(staticcheck_cmd)
    
    if not staticcheck_path:
        stderr.write(
            'Error: Could not find staticcheck executable.'
            '  Please ensure Golang staticcheck tools are in the PATH.\n')
        return 1

    # If the user specified the staticcheck -fail argument via our -X,
    #  then we won't pass -fail ourselves, but we will let the user's
    #  option win.
    for arg in analyzer_args:
        if arg == '-fail':
            fail_checks = None
            break

    if base_dir:
        base_dir = os.path.normpath(base_dir)
    else:
        base_dir = None
    go_mod_file = GO_MOD_FILENAME
    if base_dir:
        go_mod_file = os.path.join(base_dir, go_mod_file)
    # If any of the analyzer arguments are directories or patterns,
    #  then we will need to know where the go.mod file is.
    #  Under some conditions, Staticcheck will return zero warnings
    #   without any complaints if it fails to find a go.mod file,
    #   so we have to make the complaint ourselves:
    if not os.path.exists(go_mod_file) and not all((os.path.isfile(a) for a in analyzer_inputs)):
        stderr.write(
            f'Error: Expected a Go module definition file at "{go_mod_file}".'
            + '  Use -C option specify a Go module directory for scanning.'
            + linesep)
        return 1
    analyzer_inputs2 = []
    curdir_prefix = os.path.curdir + os.path.sep
    source_args2 = []
    for analyzer_input in analyzer_inputs:
        # Analyzer arguments refer to directories or files,
        #  but they must be mapped into Go package names or source file paths for staticcheck,
        #  and glob patterns for cs-import (via import_sarif)
        # Use normpath to remove initial './' if it exists.
        #  This can help to ensure compatibility with exclusion patterns.
        analyzer_input = os.path.normpath(analyzer_input)
        # For importing source files,
        #  convert Go patterns '...' to cs-import patterns '**/*'.
        source_pattern = analyzer_input
        if base_dir and base_dir != os.path.curdir:
            source_pattern = os.path.join(base_dir, source_pattern)
        # Remove trailing '...' and replace with '**/*.go':
        if source_pattern.endswith(GO_PKG_PATTERN_WILDCARD):
            source_pattern = source_pattern[:-len(GO_PKG_PATTERN_WILDCARD)]
            source_pattern = source_pattern + os.path.join('**', '*.go')
        elif os.path.isdir(source_pattern):
            source_pattern = os.path.join(source_pattern, '**', '*.go')
        # Replace any other occurrences of '...' with '**':
        source_pattern = source_pattern.replace(GO_PKG_PATTERN_WILDCARD, '**')
        # Put the default pattern at the front of the include/exclude list:
        source_args2.append((INCLUDE_FLAG, source_pattern))
        #  Ensure all relative paths to Go package directories start with './'
        if not os.path.isabs(analyzer_input) and analyzer_input != os.path.curdir:
            analyzer_input = curdir_prefix + analyzer_input
        # If input is a directory, then  tell staticcheck to recurse the directory
        #  by transforming the directory path into a Go pattern ending with '...'.
        analyzer_input_path = analyzer_input
        if base_dir:
            analyzer_input_path = os.path.join(base_dir, analyzer_input)
        # On Windows os.path.isdir('dir/...') returns True when 'dir' exists.
        #  yet os.listdir('dir/...') fails with FileNotFoundError even if 'dir' exists,
        #  so we cannot assume that something ending with '...' will satisfy isdir().
        if os.path.isdir(analyzer_input_path) and not analyzer_input_path.endswith(GO_PKG_PATTERN_WILDCARD):
            analyzer_input = os.path.join(analyzer_input, GO_PKG_PATTERN_WILDCARD)
        analyzer_inputs2.append(analyzer_input)
    analyzer_inputs = analyzer_inputs2
    for flag, source_pattern in source_args:
        if base_dir and base_dir != os.path.curdir:
            source_pattern = os.path.join(base_dir, source_pattern)
        source_args2.append((flag, source_pattern))
    source_args = source_args2

    # Check if the source patterns actually match anything.
    #  If they don't match anything, then assume user made a commandline error.
    source_patterns = [
        pat if flag == INCLUDE_FLAG else EXCLUDE_SIGIL + pat
        for (flag, pat) in source_args]
    for _ in iglob(source_patterns or (), filesonly=True):
        break
    else:
        stderr.write(
            'Error: No source files matched the analysis source arguments.'
            ' If you are using the -C option to specify a module directory,'
            ' ensure input argument paths are relative to the module directory.')
        stderr.write(linesep)
        return 1

    temp_sarif_file = None
    if not sarif_file:
        fd, temp_sarif_file = mkstemp(prefix='codesonar.go_scan.', suffix='.sarif', dir='.')
        os.close(fd)
        sarif_file = temp_sarif_file

    staticcheck_argv = [
        staticcheck_path,
        '-f', 'sarif',
    ]
    # Default to using '-fail none' so that staticcheck doesn't return a nonzero exit code
    #  when there are rule violations.
    # It turns out that using `-f sarif` seems to imply `-fail none`,
    #  but this seems like it could change one day, so pass `-fail none` anyway.
    if fail_checks:
        staticcheck_argv.extend(('-fail', fail_checks))
    if analyzer_args:
        staticcheck_argv.extend(analyzer_args)
    staticcheck_argv.extend(analyzer_inputs)

    sarif_argv = [
        'import_sarif.py',
        sarif_file,
        '-path-baseid', "%SRCROOT%", base_dir or os.path.curdir,
        ]
    if base_dir:
        # Staticcheck SARIF doesn't include relative URIs without a uriBaseId,
        #  but to be safe, set -path-base anyway:
        sarif_argv.extend(('-path-base', base_dir))
    if source_max_size:
        sarif_argv.extend(('-source-max-bytes', str(source_max_size)))
    if staticcheck_path:
        sarif_argv.extend(('-staticcheck', staticcheck_path))
    for flag, pattern in source_args:
        sarif_argv.append(flag)
        sarif_argv.append(pattern)
    staticcheck = None
    try:
        if base_dir:
            stderr.write(f'cd "{base_dir}" && ')
        stderr.write(list2cmdline(staticcheck_argv))
        stderr.write(linesep)
        stderr.flush()
        with open(sarif_file, 'wb') as sarif_io:
            staticcheck = Popen(staticcheck_argv, stdin=DEVNULL, stdout=sarif_io, cwd=base_dir)
            staticcheck_returncode = staticcheck.wait()
        staticcheck = None
        
        if staticcheck_returncode:
            returncode = staticcheck_returncode
        if not returncode:
            stderr.write(list2cmdline(sarif_argv))
            stderr.write(linesep)
            stderr.flush()
            returncode = import_sarif.main(sarif_argv)
    finally:
        if staticcheck:
            staticcheck.kill()
            staticcheck.wait()
            returncode = 1
        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)))
