#!/usr/bin/env python3
#
# Copyright (C) 2022 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License."""

"""Helpers pertaining to clang compile actions."""

import collections
import difflib
import pathlib
import subprocess
from typing import Callable
from commands import CommandInfo
from commands import flag_repr
from commands import is_flag_starts_with
from commands import parse_flag_groups


class ClangCompileInfo(CommandInfo):
  """Contains information about a clang compile action commandline."""

  def __init__(self, tool, args):
    CommandInfo.__init__(self, tool, args)

    flag_groups = parse_flag_groups(args, _custom_flag_group)

    misc = []
    i_includes = []
    iquote_includes = []
    isystem_includes = []
    defines = []
    warnings = []
    file_flags = []
    for g in flag_groups:
      if is_flag_starts_with("D", g) or is_flag_starts_with("U", g):
        defines += [g]
      elif is_flag_starts_with("I", g):
        i_includes += [g]
      elif is_flag_starts_with("isystem", g):
        isystem_includes += [g]
      elif is_flag_starts_with("iquote", g):
        iquote_includes += [g]
      elif is_flag_starts_with("W", g) or is_flag_starts_with("w", g):
        warnings += [g]
      elif (is_flag_starts_with("MF", g) or is_flag_starts_with("o", g) or
            _is_src_group(g)):
        file_flags += [g]
      else:
        misc += [g]
    self.misc_flags = sorted(misc, key=flag_repr)
    self.i_includes = _process_includes(i_includes)
    self.iquote_includes = _process_includes(iquote_includes)
    self.isystem_includes = _process_includes(isystem_includes)
    self.defines = _process_defines(defines)
    self.warnings = warnings
    self.file_flags = file_flags

  def _str_for_field(self, field_name, values):
    s = "  " + field_name + ":\n"
    for x in values:
      s += "    " + flag_repr(x) + "\n"
    return s

  def __str__(self):
    s = "ClangCompileInfo:\n"
    s += self._str_for_field("Includes (-I)", self.i_includes)
    s += self._str_for_field("Includes (-iquote)", self.iquote_includes)
    s += self._str_for_field("Includes (-isystem)", self.isystem_includes)
    s += self._str_for_field("Defines", self.defines)
    s += self._str_for_field("Warnings", self.warnings)
    s += self._str_for_field("Files", self.file_flags)
    s += self._str_for_field("Misc", self.misc_flags)
    return s


def _is_src_group(x):
  """Returns true if the given flag group describes a source file."""
  return isinstance(x, str) and x.endswith(".cpp")


def _custom_flag_group(x):
  """Identifies single-arg flag groups for clang compiles.

  Returns a flag group if the given argument corresponds to a single-argument
  flag group for clang compile. (For example, `-c` is a single-arg flag for
  clang compiles, but may not be for other tools.)

  See commands.parse_flag_groups documentation for signature details."""
  if x.startswith("-I") and len(x) > 2:
    return ("I", x[2:])
  if x.startswith("-W") and len(x) > 2:
    return (x)
  elif x == "-c":
    return x
  return None


def _process_defines(defs):
  """Processes and returns deduplicated define flags from all define args."""
  # TODO(cparsons): Determine and return effective defines (returning the last
  # set value).
  defines_by_var = collections.defaultdict(list)
  for x in defs:
    if isinstance(x, tuple):
      var_name = x[0][2:]
    else:
      var_name = x[2:]
    defines_by_var[var_name].append(x)
  result = []
  for k in sorted(defines_by_var):
    d = defines_by_var[k]
    for x in d:
      result += [x]
  return result


def _process_includes(includes):
  # Drop genfiles directories; makes diffing easier.
  result = []
  for x in includes:
    if isinstance(x, tuple):
      if not x[1].startswith("bazel-out"):
        result += [x]
    else:
      result += [x]
  return result


# given a file, give a list of "information" about it
ExtractInfo = Callable[[pathlib.Path], list[str]]


def _diff(left_path: pathlib.Path, right_path: pathlib.Path, tool_name: str,
    tool: ExtractInfo) -> list[str]:
  """Returns a list of strings describing differences in `.o` files.
  Returns the empty list if these files are deemed "similar enough".

  The given files must exist and must be object (.o) files."""
  errors = []

  left = tool(left_path)
  right = tool(right_path)
  comparator = difflib.context_diff(left, right)
  difflines = list(comparator)
  if difflines:
    err = "\n".join(difflines)
    errors.append(
      f"{left_path}\ndiffers from\n{right_path}\nper {tool_name}:\n{err}")
  return errors


def _external_tool(*args) -> ExtractInfo:
  return lambda file: subprocess.run([*args, str(file)],
                                     check=True, capture_output=True,
                                     encoding="utf-8").stdout.splitlines()


# TODO(usta) use nm as a data dependency
def nm_differences(left_path: pathlib.Path, right_path: pathlib.Path) -> list[
  str]:
  """Returns differences in symbol tables.
  Returns the empty list if these files are deemed "similar enough".

  The given files must exist and must be object (.o) files."""
  return _diff(left_path, right_path, "symbol tables", _external_tool("nm"))


# TODO(usta) use readelf as a data dependency
def elf_differences(left_path: pathlib.Path, right_path: pathlib.Path) -> list[
  str]:
  """Returns differences in elf headers.
  Returns the empty list if these files are deemed "similar enough".

  The given files must exist and must be object (.o) files."""
  return _diff(left_path, right_path, "elf headers",
               _external_tool("readelf", "-h"))