#!/usr/bin/env python3
# Copyright (C) 2023 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.
"""
Enforce import rules for https://ui.perfetto.dev.
Directory structure encodes ideas about the expected dependency graph
of the code in those directories. Both in a fuzzy sense: we expect code
withing a directory to have high cohesion within the directory and low
coupling (aka fewer imports) outside of the directory - but also
concrete rules:
- "base should not depend on the fronted"
- "plugins should only directly depend on the public API"
- "we should not have circular dependencies"

Without enforcement exceptions to this rule quickly slip in. This
script allows such rules to be enforced at presubmit time.
"""

import sys
import os
import re
import collections
import argparse

ROOT_DIR = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
UI_SRC_DIR = os.path.join(ROOT_DIR, 'ui', 'src')

# Current plan for the dependency tree of the UI code (2023-09-21)
# black = current
# red = planning to remove
# green = planning to add
PLAN_DOT = """
digraph g {
    mithril [shape=rectangle, label="mithril"];
    protos [shape=rectangle, label="//protos/perfetto"];

    _gen [shape=ellipse, label="/gen"];
    _base [shape=ellipse, label="/base"];
    _core [shape=ellipse, label="/core"];
    _engine [shape=ellipse, label="/engine"];

    _frontend [shape=ellipse, label="/frontend" color=red];
    _common [shape=ellipse, label="/common" color=red];
    _controller [shape=ellipse, label="/controller" color=red];
    _tracks [shape=ellipse, label="/tracks" color=red];

    _widgets [shape=ellipse, label="/widgets"];

    _public [shape=ellipse, label="/public"];
    _plugins [shape=ellipse, label="/plugins"];
    _chrome_extension [shape=ellipse, label="/chrome_extension"];
    _trace_processor [shape=ellipse, label="/trace_processor" color="green"];
    _protos [shape=ellipse, label="/protos"];
    engine_worker_bundle [shape=cds, label="Engine worker bundle"];
    frontend_bundle [shape=cds, label="Frontend bundle"];

    engine_worker_bundle -> _engine;
    frontend_bundle -> _core [color=green];
    frontend_bundle -> _frontend [color=red];

    _core -> _public;
    _plugins -> _public;

    _widgets -> _base;
    _core -> _base;
    _core -> _widgets;


    _widgets -> mithril;
    _plugins -> mithril;
    _core -> mithril

    _plugins -> _widgets;

    _core -> _chrome_extension;

    _frontend -> _widgets [color=red];
    _common -> _core [color=red];
    _frontend -> _core [color=red];
    _controller -> _core [color=red];

    _frontend -> _controller [color=red];
    _frontend -> _common [color=red];
    _controller -> _frontend  [color=red];
    _controller -> _common [color=red];
    _common -> _controller [color=red];
    _common -> _frontend [color=red];
    _tracks -> _frontend  [color=red];
    _tracks -> _controller  [color=red];
    _common -> _chrome_extension [color=red];

    _core -> _trace_processor [color=green];

    _engine -> _trace_processor [color=green];
    _engine -> _common [color=red];
    _engine -> _base;

    _gen -> protos;
    _core -> _gen [color=red];

    _core -> _protos;
    _protos -> _gen;
    _trace_processor -> _protos [color=green];

    _trace_processor -> _public [color=green];

    npm_trace_processor [shape=cds, label="npm trace_processor" color="green"];
    npm_trace_processor -> engine_worker_bundle [color="green"];
    npm_trace_processor -> _trace_processor [color="green"];
}
"""


class Failure(object):

  def __init__(self, path, rule):
    self.path = path
    self.rule = rule

  def __str__(self):
    nice_path = ["ui/src" + name + ".ts" for name in self.path]
    return ''.join([
        'Forbidden dependency path:\n\n ',
        '\n    -> '.join(nice_path),
        '\n',
        '\n',
        str(self.rule),
        '\n',
    ])


class AllowList(object):

  def __init__(self, allowed, dst, reasoning):
    self.allowed = allowed
    self.dst = dst
    self.reasoning = reasoning

  def check(self, graph):
    for node, edges in graph.items():
      for edge in edges:
        if re.match(self.dst, edge):
          if not any(re.match(a, node) for a in self.allowed):
            yield Failure([node, edge], self)

  def __str__(self):
    return f'Only items in the allowlist ({self.allowed}) may directly depend on "{self.dst}" ' + self.reasoning


class NoDirectDep(object):

  def __init__(self, src, dst, reasoning):
    self.src = src
    self.dst = dst
    self.reasoning = reasoning

  def check(self, graph):
    for node, edges in graph.items():
      if re.match(self.src, node):
        for edge in edges:
          if re.match(self.dst, edge):
            yield Failure([node, edge], self)

  def __str__(self):
    return f'"{self.src}" may not directly depend on "{self.dst}" ' + self.reasoning


class NoDep(object):

  def __init__(self, src, dst, reasoning):
    self.src = src
    self.dst = dst
    self.reasoning = reasoning

  def check(self, graph):
    for node in graph:
      if re.match(self.src, node):
        for connected, path in bfs(graph, node):
          if re.match(self.dst, connected):
            yield Failure(path, self)

  def __str__(self):
    return f'"{self.src}" may not depend on "{self.dst}" ' + self.reasoning


class NoCircularDeps(object):

  def __init__(self):
    pass

  def check(self, graph):
    for node in graph:
      for child in graph[node]:
        for reached, path in dfs(graph, child):
          if reached == node:
            yield Failure([node] + path, self)

  def __str__(self):
    return f'circular dependencies can cause complex issues'


# We have three kinds of rules:
# NoDirectDep(a, b) = files matching regex 'a' cannot *directly* import
#   files matching regex 'b' - but they may indirectly depend on them.
# NoDep(a, b) = as above but 'a' may not even transitively import 'b'.
# NoCircularDeps = forbid introduction of circular dependencies
RULES = [
    AllowList(
        ['/protos/index'],
        r'/gen/protos',
        'protos should be re-exported from /protos/index without the nesting.',
    ),
    NoDirectDep(
        r'/plugins/.*',
        r'/core/.*',
        'instead plugins should depend on the API exposed at ui/src/public.',
    ),
    NoDirectDep(
        r"/frontend/.*",
        r"/core_plugins/.*",
        "core code should not depend on plugins.",
    ),
    NoDirectDep(
        r"/core/.*",
        r"/core_plugins/.*",
        "core code should not depend on plugins.",
    ),
    NoDirectDep(
        r"/base/.*",
        r"/core_plugins/.*",
        "core code should not depend on plugins.",
    ),
    #NoDirectDep(
    #    r'/tracks/.*',
    #    r'/core/.*',
    #    'instead tracks should depend on the API exposed at ui/src/public.',
    #),
    NoDep(
        r'/core/.*',
        r'/plugins/.*',
        'otherwise the plugins are no longer optional.',
    ),
    NoDep(
        r'/core/.*',
        r'/frontend/.*',
        'trying to reduce the dependency mess as we refactor into core',
    ),
    NoDep(
        r'/core/.*',
        r'/common/.*',
        'trying to reduce the dependency mess as we refactor into core',
    ),
    NoDep(
        r'/core/.*',
        r'/controller/.*',
        'trying to reduce the dependency mess as we refactor into core',
    ),
    NoDep(
        r'/base/.*',
        r'/core/.*',
        'core should depend on base not the other way round',
    ),
    NoDep(
        r'/base/.*',
        r'/common/.*',
        'common should depend on base not the other way round',
    ),
    NoDep(
        r'/common/.*',
        r'/chrome_extension/.*',
        'chrome_extension must be a leaf',
    ),

    # Widgets
    NoDep(
        r'/widgets/.*',
        r'/frontend/.*',
        'widgets should only depend on base',
    ),
    NoDep(
        r'/widgets/.*',
        r'/core/.*',
        'widgets should only depend on base',
    ),
    NoDep(
        r'/widgets/.*',
        r'/plugins/.*',
        'widgets should only depend on base',
    ),
    NoDep(
        r'/widgets/.*',
        r'/common/.*',
        'widgets should only depend on base',
    ),

    # Bigtrace
    NoDep(
        r'/bigtrace/.*',
        r'/frontend/.*',
        'bigtrace should not depend on frontend',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/common/.*',
        'bigtrace should not depend on common',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/engine/.*',
        'bigtrace should not depend on engine',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/trace_processor/.*',
        'bigtrace should not depend on trace_processor',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/traceconv/.*',
        'bigtrace should not depend on traceconv',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/tracks/.*',
        'bigtrace should not depend on tracks',
    ),
    NoDep(
        r'/bigtrace/.*',
        r'/controller/.*',
        'bigtrace should not depend on controller',
    ),

    # Fails at the moment as we have several circular dependencies. One
    # example:
    # ui/src/frontend/cookie_consent.ts
    #    -> ui/src/frontend/globals.ts
    #    -> ui/src/frontend/router.ts
    #    -> ui/src/frontend/pages.ts
    #    -> ui/src/frontend/cookie_consent.ts
    #NoCircularDeps(),
]


def all_source_files():
  for root, dirs, files in os.walk(UI_SRC_DIR, followlinks=False):
    for name in files:
      if name.endswith('.ts') and not name.endswith('.d.ts'):
        yield os.path.join(root, name)


def is_dir(path, cache={}):
  try:
    return cache[path]
  except KeyError:
    result = cache[path] = os.path.isdir(path)
    return result


def remove_prefix(s, prefix):
  return s[len(prefix):] if s.startswith(prefix) else s


def remove_suffix(s, suffix):
  return s[:-len(suffix)] if s.endswith(suffix) else s


def find_imports(path):
  src = path
  src = remove_prefix(src, UI_SRC_DIR)
  src = remove_suffix(src, '.ts')
  directory, _ = os.path.split(src)
  with open(path) as f:
    s = f.read()
    for m in re.finditer("^import[^']*'([^']*)';", s, flags=re.MULTILINE):
      raw_target = m[1]
      if raw_target.startswith('.'):
        target = os.path.normpath(os.path.join(directory, raw_target))
        if is_dir(UI_SRC_DIR + target):
          target = os.path.join(target, 'index')
      else:
        target = raw_target
      yield (src, target)


def path_to_id(path):
  path = path.replace('/', '_')
  path = path.replace('-', '_')
  path = path.replace('@', '_at_')
  path = path.replace('.', '_')
  return path


def is_external_dep(path):
  return not path.startswith('/')


def bfs(graph, src):
  seen = set()
  queue = [(src, [])]

  while queue:
    node, path = queue.pop(0)
    if node in seen:
      continue

    seen.add(node)

    path = path[:]
    path.append(node)

    yield node, path
    queue.extend([(child, path) for child in graph[node]])


def dfs(graph, src):
  seen = set()
  queue = [(src, [])]

  while queue:
    node, path = queue.pop()
    if node in seen:
      continue

    seen.add(node)

    path = path[:]
    path.append(node)

    yield node, path
    queue.extend([(child, path) for child in graph[node]])


def write_dot(graph, f):
  print('digraph g {', file=f)
  for node, edges in graph.items():
    node_id = path_to_id(node)
    shape = 'rectangle' if is_external_dep(node) else 'ellipse'
    print(f'{node_id} [shape={shape}, label="{node}"];', file=f)

    for edge in edges:
      edge_id = path_to_id(edge)
      print(f'{node_id} -> {edge_id};', file=f)
  print('}', file=f)


def do_check(options, graph):
  for rule in RULES:
    for failure in rule.check(graph):
      print(failure)
      return 1
  return 0


def do_desc(options, graph):
  print('Rules:')
  for rule in RULES:
    print("  - ", end='')
    print(rule)


def do_print(options, graph):
  for node, edges in graph.items():
    for edge in edges:
      print("{}\t{}".format(node, edge))


def do_dot(options, graph):

  def simplify(path):
    if is_external_dep(path):
      return path
    return os.path.dirname(path)

  if options.simplify:
    new_graph = collections.defaultdict(set)
    for node, edges in graph.items():
      for edge in edges:
        new_graph[simplify(edge)]
        new_graph[simplify(node)].add(simplify(edge))
    graph = new_graph

  if options.ignore_external:
    new_graph = collections.defaultdict(set)
    for node, edges in graph.items():
      if is_external_dep(node):
        continue
      for edge in edges:
        if is_external_dep(edge):
          continue
        new_graph[edge]
        new_graph[node].add(edge)
    graph = new_graph

  write_dot(graph, sys.stdout)
  return 0


def do_plan_dot(options, _):
  print(PLAN_DOT, file=sys.stdout)
  return 0


def main():
  parser = argparse.ArgumentParser(description=__doc__)
  parser.set_defaults(func=do_check)
  subparsers = parser.add_subparsers()

  check_command = subparsers.add_parser(
      'check', help='Check the rules (default)')
  check_command.set_defaults(func=do_check)

  desc_command = subparsers.add_parser('desc', help='Print the rules')
  desc_command.set_defaults(func=do_desc)

  print_command = subparsers.add_parser('print', help='Print all imports')
  print_command.set_defaults(func=do_print)

  dot_command = subparsers.add_parser(
      'dot',
      help='Output dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports dot | dot -Tpng -ograph.png)'
  )
  dot_command.set_defaults(func=do_dot)
  dot_command.add_argument(
      '--simplify',
      action='store_true',
      help='Show directories rather than files',
  )
  dot_command.add_argument(
      '--ignore-external',
      action='store_true',
      help='Don\'t show external dependencies',
  )

  plan_dot_command = subparsers.add_parser(
      'plan-dot',
      help='Output planned dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports plan-dot | dot -Tpng -ograph.png)'
  )
  plan_dot_command.set_defaults(func=do_plan_dot)

  graph = collections.defaultdict(set)
  for path in all_source_files():
    for src, target in find_imports(path):
      graph[src].add(target)
      graph[target]

  options = parser.parse_args()
  return options.func(options, graph)


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