#!/usr/bin/env python3

import argparse
import glob
import os
import re
import signal
import subprocess
import sys
import shlex

from binascii import hexlify
from ctypes.util import find_library
from functools import reduce
from itertools import chain
try:
    from shutil import which
except ImportError:
    from backports.shutil_which import which

from elftools.elf.dynamic import DynamicSegment
from elftools.elf.elffile import ELFFile

from revng_dump_model import load_model

search_prefixes = []
real_argv0 = os.environ.get("REAL_ARGV0", sys.argv[0])

def shlex_join(split_command):
  return ' '.join(shlex.quote(arg) for arg in split_command)

def log_error(msg):
  sys.stderr.write(msg + "\n")

def relative(path):
  return os.path.relpath(path ,".")

def run(command, override={}):
  if is_executable(command[0]):
    command = wrap(command)

  global log_commands
  if log_commands:
    cwd = os.getcwd().rstrip("/")
    if command[0].startswith(cwd):
      program_path = "." + command[0][len(cwd):]
    else:
      program_path = command[0]
    sys.stderr.write("{}\n\n".format(" \\\n  ".join([program_path] + command[1:])))

  signal.signal(signal.SIGINT, signal.SIG_IGN)
  environment = dict(os.environ)
  environment.update(override)
  p = subprocess.Popen(command,
                       preexec_fn=lambda: signal.signal(signal.SIGINT,
                                                        signal.SIG_DFL),
                       env=environment)
  if p.wait() != 0:
      log_error("The following command exited with {}:\n{}".format(p.returncode, shlex_join(command)))
      sys.exit(p.returncode)

def is_executable(path):
  with open(path, "rb") as program:
    return program.read(4) == b"\x7fELF"

def is_dynamic(path):
  with open(path, "rb") as input_file:
    return len([segment
                for segment
                in ELFFile(input_file).iter_segments()
                if segment.header.p_type == "PT_DYNAMIC"]) != 0


def interleave(base, repeat):
  return list(sum(zip([repeat] * len(base), base), ()))

# Use in case different version of pyelftools might give str or bytes
def to_string(obj):
  if type(obj) is str:
    return obj
  elif type(obj) is bytes:
    return obj.decode("utf-8")

def get_elf_needed(path):
  with open(path, "rb") as elf_file:
    segments = [segment
                for segment
                in ELFFile(elf_file).iter_segments()
                if type(segment) is DynamicSegment]

    if len(segments) == 1:
      needed = [to_string(tag.needed)
                for tag
                in segments[0].iter_tags()
                if tag.entry.d_tag == "DT_NEEDED"]

      runpath = [tag.runpath
                 for tag
                 in segments[0].iter_tags()
                 if tag.entry.d_tag == "DT_RUNPATH"]

      assert len(runpath) < 2

      if not runpath:
        return needed
      else:
        runpaths = [path.replace("$ORIGIN",
                                 os.path.dirname(path))
                    for path
                    in runpath[0].split(":")]
        absolute_needed = []
        for lib in needed:
          found = False
          for runpath in runpaths:
            full_path = os.path.join(runpath, lib)
            if os.path.isfile(full_path):
              absolute_needed.append(full_path)
              found = True
              break

          if not found:
            absolute_needed.append(lib)

        return absolute_needed

    else:
      return []

def get_command(command):
  global search_path
  path = which(command, path=search_path)
  if not path:
    log_error("""Couldn't find "{}" in "{}".""".format(command, search_path))
    assert False
  return os.path.abspath(path)

def build_opt_args(args):
  analysis_libraries = []
  all = set()
  for prefix in search_prefixes:
    analyses_path = os.path.join(prefix, "lib", "revng", "analyses")
    if not os.path.isdir(analyses_path):
      continue

    # Enumerate all the libraries containing analyses
    for library in glob.glob(os.path.join(analyses_path, "*.so")):
        basename = os.path.basename(library)
        if basename not in all:
            analysis_libraries.append(library)
            all.add(basename)

  # Identify all the libraries that are dependencies of other libraries, i.e.,
  # non-roots in the dependencies tree. Note that circular dependencies are not
  # allowed.
  provided = set(chain.from_iterable([get_elf_needed(path)
                                      for path
                                      in analysis_libraries]))

  # Path to libraries representing the roots in the dependency tree
  roots = [relative(os.path.join(analyses_path, name))
           for name
           in all - provided]

  prefix = []
  libasan = [name
             for name
             in provided
             if ("libasan." in name
                 or "libclang_rt.asan" in name)]
  if libasan:
    original_asan_options = os.environ.get("ASAN_OPTIONS", "")
    if original_asan_options:
        asan_options = dict([option.split("=")
                             for option
                             in original_asan_options.split(":")])
    else:
        asan_options = dict()
    asan_options["abort_on_error"] = "1"
    new_asan_options = ":".join(["=".join(option)
                                 for option
                                 in asan_options.items()])

    # Use `sh` instead of `env` since `env` sometimes is not a real executable
    # but a shebang script spawning /usr/bin/coreutils, which makes gdb unhappy
    prefix = [get_command("sh"),
              "-c",
              'LD_PRELOAD={} ASAN_OPTIONS={} '
              'exec "$0" "$@"'.format(libasan[0], new_asan_options)]

  return (prefix + [relative(get_command("opt"))]
          + interleave(roots, "-load")
          + args
          + ["-serialize-model"])

def split_dash_dash(args):
  if not args:
    return [], []

  extra_args = []
  while (len(args) != 0) and (args[0] != "--"):
    extra_args.append(args[0])
    del args[0]

  # Drop the delimiter
  if len(args) != 0:
    del args[0]

  return list(extra_args), args

def get_architecture(path):
  with open(path, "rb") as file:
    file.seek(5)
    arch_id = hexlify(file.read(1))
    file.seek(18)
    arch_id += hexlify(file.read(2))
  arch_id = arch_id.decode("utf-8")

  if arch_id == "012800":
    return "arm"
  elif arch_id == "01b700":
    return "aarch64"
  elif arch_id == "010800":
    return "mipsel"
  elif arch_id == "020008":
    return "mips"
  elif arch_id == "013e00":
    return "x86_64"
  elif arch_id == "010300":
    return "i386"
  elif arch_id == "020016":
    return "s390x"
  elif arch_id == "01b700":
    return "aarch64"
  else:
    log_error("Unknown architecture ID: {}".format(arch_id))
    assert False

def find_file(name, paths):
  for path in paths:
    path = os.path.join(path, name)
    if os.path.isfile(path):
      return path

  log_error("Can't find the following file: {}".format(name))
  assert False

def get_stderr(args):
  with subprocess.Popen(args, stderr=subprocess.PIPE) as process:
    return process.stderr.read()

def build_linking_options(model):
  result = []

  # Force maximum page size
  page_size = 4096
  result.append("-Wl,-z,max-page-size={}".format(page_size))

  # Produce semgment loading options
  min = 0
  max = 0
  parse_ma = lambda string: int(string.split(":")[0], 16)
  for segment in model["Segments"]:
    start, end = parse_ma(segment["Start"]), parse_ma(segment["End"])

    # Record max and min
    if end > max:
      max = end

    if (min == 0) or start < min:
      min = start

    # Force section address
    result.append("-Wl,--section-start=.{}={}".format(segment["Name"],
                                                      hex(start)))

  # Force ld.bfd
  result.append("-fuse-ld=bfd")

  # Force a page before the lowest original address for the ELF header
  result.append("-Wl,--section-start=.elfheaderhelper={}".format(hex(min - 1)))

  # Force text to start on the page after all the original program segments
  text_address = page_size * int((max + page_size - 1) / page_size)
  result.append("-Wl,-Ttext-segment={}".format(hex(text_address)))

  # Link required dynamic libraries
  result.append("-Wl,--no-as-needed")

  for library in model["ImportedLibraries"]:
    library = library.strip()
    if "/" in library:
      library = library[library.rindex("/") + 1:]

    match = re.match("^lib(.*).so(\.[0-9]+)*$", library)
    if match:
      library = "-l" + match.group(1)
    else:
      library = "-l:" + library

    result.append(library)

  result.append("-Wl,--as-needed")

  return result

def wrap(args):
  global command_prefix
  return command_prefix + args

def register_translate(subparsers):
  parser = subparsers.add_parser("translate",
                                 help="the rev.ng translator",
                                 description="The rev.ng translator.",
                                 prog=real_argv0 + " translate")
  parser.add_argument("-O0", action="store_true", help="Do no optimize.")
  parser.add_argument("-O1", action="store_true", help="Use llc -O2.")
  parser.add_argument("-O2",
                      action="store_true",
                      help="Use llc -O2 and opt -O2.")
  parser.add_argument("--trace",
                      action="store_true",
                      help="Use the tracing version of support.ll.")
  parser.add_argument("-s",
                      "--skip",
                      action="store_true",
                      help="Do not invoke revng-lift.")
  parser.add_argument("-i",
                      "--isolate",
                      action="store_true",
                      help="Enable function isolation.")
  parser.add_argument("--base", help="Load address to employ in lifting.")
  parser.add_argument("-o", "--output", metavar="OUTPUT", help="Output path.")
  parser.add_argument("input", metavar="INPUT", help="The input binary.")

def run_translate(args, post_dash_dash):
  # Ensure there's an input file
  if not args.input:
    log_error("Input file not provided")
    return -1

  # Register optimization level
  optimization_level = 0
  if args.O1:
    optimization_level = 1
  elif args.O2:
    optimization_level = 2

  input = args.input
  executable = args.output if args.output else "{}.translated".format(input)
  output = "{}.ll".format(executable)

  # Find a compiler (used for linking)
  compiler = get_command(os.environ.get("CXX", "c++"))

  # Extracted the architecture name
  architecture = get_architecture(input)

  if is_dynamic(input) and architecture != "x86_64":
    log_error("""
Currently, rev.ng can translate dynamically linked binaries only for x86-64.
The provided program is dynamically linked but targets {}.
Try with an input program that is compiled for x86-64, or statically linked.
If you're just interested in lifting to LLVM IR, simply use `revng lift`.
    """.format(architecture).strip())
    return -1

  # Check if tracing is enabled
  if args.trace:
    config = "trace"
  else:
    config = "normal"

  # Build the name of the support.ll file
  support_name = "support-{}-{}.ll".format(architecture, config)

  # Find support.ll
  global script_path
  support_path = find_file(support_name,
                           [os.path.join(script_path,
                                         "..",
                                         "share",
                                         "revng"),
                            script_path])

  # Perform lifting
  if not args.skip:

    lift_options = post_dash_dash or []

    if args.base:
      lift_options += ["--base", args.base]

    run([get_command("revng-lift"),
         "-g", "ll"]
        + lift_options
        + [relative(input), relative(output)])

  # Perform function isolation
  if args.isolate:
    isolated = "{}.isolated".format(executable)
    opt_invocation = build_opt_args(["-S",
                                     "-detect-abi",
                                     "-isolate",
                                     "-invoke-isolated-functions",
                                     relative(output),
                                     "-o", relative(isolated)])
    run(opt_invocation)
    output = isolated

  # Link with support
  linked = "{}.linked.ll".format(output)
  run([get_command("llvm-link"),
       "-S",
       relative(output),
       relative(support_path),
       "-o", relative(linked)])
  output = linked

  # Compile
  object_file = "{}.o".format(output)

  common_llc_options = ["-disable-machine-licm", "-filetype=obj"]
  llc = get_command("llc")
  if optimization_level == 0:
    run([llc,
         "-O0",
         relative(output),
         "-o", relative(object_file)]
        + common_llc_options)
  elif optimization_level == 1:
    run([llc,
         "-O2",
         relative(output),
         "-o", relative(object_file)]
        + common_llc_options)
  elif optimization_level == 2:
    optimized = "{}.opt.ll".format(output)
    run([get_command("opt"),
         "-O2",
         "-S",
         "-enable-pre=false",
         "-enable-load-pre=false",
         relative(output),
         "-o", relative(optimized)])
    run([llc,
         "-O2",
         relative(optimized),
         "-o", relative(object_file)]
        + common_llc_options)

  with open(output, "r") as output_file:
    model = load_model(output_file)

  assert model is not None

  # Parse .li.csv and .need.csv files
  linking_options = build_linking_options(model)

  no_pie = ["-fno-pie"]

  # Detect -no-pie support
  if b"unrecognized command line" not in get_stderr([compiler, "-no-pie"]):
    no_pie.append("-no-pie")

  run([compiler,
       object_file,
       "-lz", "-lm", "-lrt", "-lpthread",
       "-L", "./",
       "-o", executable]
      + no_pie
      + linking_options,
      {"HARD_FLAGS_IGNORE": "1"})

  # Invoke revng-merge-dynamic
  unpatched_output = "{}.tmp".format(executable)
  os.rename(executable, unpatched_output)
  base_args = ["--base", args.base] if args.base else []
  run([get_command("revng"),
       "merge-dynamic",
       unpatched_output,
       input,
       executable]
      + base_args)
  os.unlink(unpatched_output)

  return 0

def run_cc(args):
  # Collect translate options
  translated_args = []
  if "--" in args:
    translate_args, args = split_dash_dash(args)

  if (not args) or ("--help" in args):
    log_error(f"Usage: {real_argv0} cc [[translate-options] --] compiler "
              + "[compiler-options]")
    return -1

  res = subprocess.call(args)
  if res != 0:
    return res

  # Are we linking?
  if not ("-c" in args):
    assert "-o" in args

    # Identify the path of the final program
    output = os.path.abspath(args[args.index("-o") + 1])

    original = output + ".original"
    translated = original + ".translated"
    os.rename(output, original)

    result = run_translate(translate_args + [original])
    if result != 0:
      return result

    os.rename(translated, output)

  return res

def main():
  parser = argparse.ArgumentParser(description="The rev.ng driver.")
  parser.add_argument("--version",
                      action="store_true",
                      help="Display version information.")
  parser.add_argument("--verbose",
                      action="store_true",
                      help="Log all executed commands.")
  parser.add_argument("--perf",
                      action="store_true",
                      help="Run programs under perf (for use with hotspot).")
  parser.add_argument("--heaptrack",
                      action="store_true",
                      help="Run programs under heaptrack.")
  parser.add_argument("--gdb",
                      action="store_true",
                      help="Run programs under gdb.")
  parser.add_argument("--lldb",
                      action="store_true",
                      help="Run programs under lldb.")
  parser.add_argument("--valgrind",
                      action="store_true",
                      help="Run programs under valgrind.")
  parser.add_argument("--callgrind",
                      action="store_true",
                      help="Run programs under callgrind.")
  parser.add_argument("--prefix",
                      metavar="PREFIX",
                      help="Additional search prefix.")

  # Get script_path
  global script_path
  script_path = os.path.dirname(os.path.realpath(__file__))

  # Create a custom PATH variable to find programs
  global search_path
  search_path = os.environ["PATH"]
  if search_path:
    search_path = "{}:{}".format(script_path, search_path)
  else:
    search_path = script_path

  subparsers = parser.add_subparsers(dest="command_name",
                                     help='sub-commands help')

  register_translate(subparsers)

  subparsers.add_parser("cc",
                        help="compile, link and translate transparently",
                        add_help=False)
  subparsers.add_parser("opt",
                        help="LLVM's opt with rev.ng passes",
                        add_help=False)

  programs = set()
  prefix = "revng-"
  for path in search_path.split(":"):
    if os.path.isdir(path):
      for program in os.listdir(path):
        if program.startswith(prefix) and which(program, path=search_path):
          name = program[len(prefix):]
          subparsers.add_parser(name,
                                help=f"see {name} --help",
                                add_help=False)

  global revng_parser
  revng_parser = parser

  # Strip away arguments -- so we can forward them to revng-lift
  base_args, post_dash_dash = split_dash_dash(sys.argv[1:])

  args, unknown_args = parser.parse_known_args(base_args)

  global search_prefixes
  search_prefixes = (args.prefix or []
                     + [os.path.join(script_path, "..")])

  global log_commands
  log_commands = args.verbose

  global command_prefix
  command_prefix = []

  assert (args.gdb + args.lldb + args.valgrind + args.callgrind) <= 1

  if args.gdb:
    command_prefix += ["gdb", "--args"]

  if args.lldb:
    command_prefix += ["lldb", "--"]

  if args.valgrind:
    command_prefix += ["valgrind"]

  if args.callgrind:
    command_prefix += ["valgrind", "--tool=callgrind"]

  if args.perf:
    command_prefix += ["perf",
                       "record",
                       "--call-graph", "dwarf",
                       "--output=perf.data"]

  if args.heaptrack:
    command_prefix += ["heaptrack"]

  if args.version:
    sys.stdout.write("rev.ng version @VERSION@\n")
    return 0

  return run_command(args, unknown_args, post_dash_dash)

def run_command(args, unknown_args, post_dash_dash):
  command = args.command_name
  if not command:
      log_error("No command specified")
      return 1

  all_args = list(unknown_args)
  if post_dash_dash:
      all_args += ["--"] + post_dash_dash

  # First consider the hardcoded commands
  if command == "opt":
    run(build_opt_args(all_args))
  elif command == "cc":
    return run_cc(all_args)
  elif command == "translate":
    assert not unknown_args
    return run_translate(args, post_dash_dash)
  else:
    executable = "revng-" + command
    if not which(executable, path=search_path):
      log_error("Can't find the following command: {}".format(executable))
      return 1

    in_path = get_command(executable)
    if in_path:
      arguments = [os.path.abspath(in_path)] + all_args
      run(arguments)
    else:
      log_error("\"revng-{}\" is not a valid command".format(command))
      return 1

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