# Copyright 2019 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.

from __future__ import print_function, absolute_import
from builtins import object, range
from future.utils import iteritems, itervalues
from past.builtins import basestring

import ast
import inspect
import json
import logging
import os
import posixpath
import sys
import types

if sys.version_info.major < 3:
  from cStringIO import StringIO
else:
  from io import StringIO

import astunparse

from google.protobuf import json_format as jsonpb
from google.protobuf import text_format as textpb

from recipe_engine import engine_types
from recipe_engine import __path__ as RECIPE_ENGINE_PATH

from PB.recipe_engine import doc

from .... import config
from .... import recipe_api
from .... import util

from ...recipe_deps import Recipe, RecipeModule, RecipeRepo
from ...recipe_deps import parse_deps_spec

from . import doc_markdown


LOGGER = logging.getLogger(__name__)

RECIPE_ENGINE_BASE = os.path.dirname(os.path.abspath(RECIPE_ENGINE_PATH[0]))

join = posixpath.join
if sys.platform == 'win32':
  def _to_posix(native_path):
    return native_path.replace(os.path.sep, os.path.altsep)
else:
  def _to_posix(native_path):
    return native_path


def _grab_ast(repo, abspath):
  """Parses the Python file indicated by `abspath`.

  Args:
    * repo (RecipeRepo) - The repo which contains `abspath`. Used for error
      reporting.
    * abspath (str) - The absolute (native) path to the Python file to parse.

  Returns the Python AST object if the file exists and is parsable. Otherwise
  logs an error and returns None.
  """
  assert isinstance(repo, RecipeRepo), type(repo)
  relpath = os.path.relpath(abspath, repo.path)
  assert '..' not in relpath
  try:
    with open(abspath, 'rb') as f:
      return ast.parse(f.read(), relpath)
  except SyntaxError as ex:
    LOGGER.warn('skipping %s: bad syntax: %s', _to_posix(relpath), ex)
  except OSError as ex:
    LOGGER.warn('skipping %s: %s', _to_posix(relpath), ex)
  return None


def _unparse(node):
  """Prints Python code which could parse to `node`.

  Args:
    * node (ast.AST) - The AST to produce Python code for.

  Returns a str with the formatted code.
  """
  assert isinstance(node, ast.AST), type(node)
  buf = StringIO()
  astunparse.Unparser(node, buf)
  return buf.getvalue()


def _find_value_of(mod_ast, target):
  """Looks for an assignment to `target`, returning the assignment value AST
  node and the line number of the assignment.

  Example:

     some_var = 100
     other = 20 + 10

     _find_value_of(<code>, 'some_var')  ->  ast.Num(100)

  Args:
    * mod_ast (ast.Module) - The parsed Python module code.
    * target (str) - The variable name to look for an assignment to.

  Returns the Python AST object which is the right-hand-side of an assignment to
  `target`.
  """
  assert isinstance(mod_ast, ast.Module), type(mod_ast)
  for node in mod_ast.body:
    if isinstance(node, ast.Assign):
      if (len(node.targets) == 1 and
          isinstance(node.targets[0], ast.Name) and
          node.targets[0].id == target):
        return node.value, node.lineno
  return None, None


def _expand_mock_imports(*mock_imports):
  """Returns an expanded set of mock imports.

  mock_imports is expected to be a dict which looks like:
    {
      "absolute.module.name": SomeObject,
    }

  The objects in this dictionary will be the ones returned in the
  eval-compatible env dict. Think of it as a fake sys.modules. However, unlike
  sys.modules, mock_imports can be 'sparse'. For example, if mock_imports is:
    {
      'a.b': "foo",
      'a.c': "bar",
    }

  And the user does `import a`, this will make a fake object containing both .b
  and .c. However, this only works on leaf nodes. 'a.b' and 'a.b.d' would be an
  error (and this will raise ValueError if you attempt to do that).

  Returns a mock_imports, but with all the fake objects expanded.
  """
  combined_imports = {}
  for mi in mock_imports:
    combined_imports.update(mi)

  class expando(object):
    pass

  # expand combined_imports so it supports trivial lookups.
  expanded_imports = {}
  for dotted_name, obj in sorted(iteritems(combined_imports)):
    if dotted_name in expanded_imports:
      raise ValueError('nested mock imports! %r', dotted_name)
    toks = dotted_name.split('.')
    expanded_imports[dotted_name] = obj
    if isinstance(obj, types.ModuleType):
      for name in (n for n in dir(obj) if not n.startswith('_')):
        expanded_imports[dotted_name+'.'+name] = getattr(obj, name)
    for i in range(len(toks)-1, 0, -1):
      partial = '.'.join(toks[:i])
      cur_obj = expanded_imports.setdefault(partial, expando())
      if not isinstance(cur_obj, expando):
        raise ValueError('nested mock imports! %r', partial)
      setattr(cur_obj, toks[i], expanded_imports[partial+'.'+toks[i]])

  return expanded_imports

ALL_IMPORTS = {}  # used in doc_test to ensure everything is actually importable
KNOWN_OBJECTS = {}

_decorator_imports = {
  'recipe_engine.util.returns_placeholder': util.returns_placeholder,
  'recipe_engine.recipe_api.non_step': recipe_api.non_step,
  'recipe_engine.recipe_api.infer_composite_step': (
    recipe_api.infer_composite_step)
}
KNOWN_OBJECTS.update(_decorator_imports)

_config_imports = {
  'recipe_engine.config.ConfigGroup': config.ConfigGroup,
  'recipe_engine.config.ConfigList': config.ConfigList,
  'recipe_engine.config.Set': config.Set,
  'recipe_engine.config.Dict': config.Dict,
  'recipe_engine.config.List': config.List,
  'recipe_engine.config.Single': config.Single,
  'recipe_engine.config.Static': config.Static,
  'recipe_engine.config.Enum': config.Enum,
}
KNOWN_OBJECTS.update(_config_imports)

_placeholder_imports = {
  'recipe_engine.util.OutputPlaceholder': util.OutputPlaceholder,
  'recipe_engine.util.InputPlaceholder': util.InputPlaceholder,
  'recipe_engine.util.Placeholder': util.Placeholder,
}
KNOWN_OBJECTS.update(_placeholder_imports)

_property_imports = {
  'recipe_engine.recipe_api.Property': recipe_api.Property,
}
KNOWN_OBJECTS.update(_property_imports)

_util_imports = {
  'recipe_engine.engine_types.freeze': engine_types.freeze,
}
KNOWN_OBJECTS.update(_util_imports)

_recipe_api_class_imports = {
  'recipe_engine.recipe_api.RecipeApi': recipe_api.RecipeApi,
  'recipe_engine.recipe_api.RecipeApiPlain': recipe_api.RecipeApiPlain,
}
KNOWN_OBJECTS.update(_recipe_api_class_imports)


def _parse_mock_imports(mod_ast, expanded_imports):
  """Parses a module AST node for import statements and resolves them against
  expanded_imports (such as you might get from _expand_mock_imports).

  If an import is not recognized, it is omitted from the returned dictionary.

  Returns a dictionary suitable for eval'ing a statement in mod_ast, with
  symbols from mod_ast's imports resolved to real objects, as per
  expanded_imports.
  """
  ret = {}

  for node in mod_ast.body:
    if isinstance(node, ast.Import):
      for alias in node.names:
        if alias.name in expanded_imports:
          ret[alias.asname or alias.name] = expanded_imports[alias.name]
    elif isinstance(node, ast.ImportFrom):
      if node.level == 0:
        for alias in node.names:
          fullname ='%s.%s' % (node.module, alias.name)
          if fullname in expanded_imports:
            ret[alias.asname or alias.name] = expanded_imports[fullname]

  return ret


def _apply_imports_to_unparsed_expression(exp_ast, imports):
  """Attempts to evaluate the code equivalent of `exp_ast`, with `imports`
  as available symbols. If it's successful, it returns the evaluated object.
  Otherwise this returns the unparsed code for `exp_ast`.

  Args:
    * exp_ast (Union[ast.Name, ast.Attribute, ast.Call]) - The expression to
      evaluate.
    * imports (Dict[str, object]) - The symbols to include during the evaluation
      of `exp_ast`.

  Returns the evaluation of `exp_ast` if it can successfully evaluate with
  `imports`. Otherwise this returns the source-code representation of exp_ast as
  a string.
  """
  assert isinstance(exp_ast, (ast.Name, ast.Attribute, ast.Call)), type(exp_ast)
  unparsed = _unparse(exp_ast).strip()
  try:
    return eval(unparsed, {'__builtins__': None}, imports)
  except (NameError, AttributeError, TypeError):
    return unparsed


def _extract_classes_funcs(body_ast, relpath, imports, do_fixup=True):
  """Extracts the classes and functions from the AST suite.

  Args:
    * body_ast (Union[ast.ClassDef, ast.Module]) - The statement suite to
      evaluate.
    * relpath (str) - The posix-style relative path which should be associated
      with the code in body_ast.
    * imports (Dict[str, object]) - The objects which should be available while
      evaluating body_ast.
    * do_fixup (bool) - If True, fixes all scanned classes so that any base
      classes they have which are defined in body_ast will be patched up as
      such. So if you have a `class A(B)`, and also `class B(object)` in the
      same file, the returned `classes['A'].bases[0] is classes['B']` will be
      True.

  Returns (classes : Dict[str, Doc.Class], funcs : Dict[str, Doc.Func]) for all
  classes and functions found in body_ast.
  """
  assert isinstance(body_ast, (ast.ClassDef, ast.Module)), type(body_ast)

  classes = {}
  funcs = {}

  for node in body_ast.body:
    if isinstance(node, ast.ClassDef):
      if not node.name.startswith('_'):
        classes[node.name] = parse_class(node, relpath, imports)
    elif isinstance(node, ast.FunctionDef):
      ok = (
        not node.name.startswith('_') or
        (node.name.startswith('__') and node.name.endswith('__'))
      )
      if ok:
        funcs[node.name] = parse_func(node, relpath, imports)

  if do_fixup:
    # frequently classes in a file inherit from other classes in the same file.
    # Do a best effort scan to re-attribute class bases when possible.
    for v in itervalues(classes):
      for i, b in enumerate(v.bases):
        if isinstance(b, str):
          if b in classes:
            v.bases[i] = classes[b]

  return classes, funcs


def parse_class(class_ast, relpath, imports):
  """Parses a class AST object.

  Args:
    * class_ast (ast.ClassDef) - The class definition to parse.
    * relpath (str) - The posix-style relative path which should be associated
      with the code in class_ast.
    * imports (Dict[str, object]) - The objects which should be available while
      evaluating class_ast.

  Returns Doc.Class proto message.
  """
  assert isinstance(class_ast, ast.ClassDef), type(class_ast)

  classes, funcs = _extract_classes_funcs(class_ast, relpath, imports, False)

  ret = doc.Doc.Class(
    relpath=relpath,
    name=class_ast.name,
    docstring=ast.get_docstring(class_ast) or '',
    lineno=class_ast.lineno,
    classes=classes,
    funcs=funcs,
  )

  for b in class_ast.bases:
    item = _apply_imports_to_unparsed_expression(b, imports)
    if isinstance(item, str):
      ret.bases.add(generic=item)
    else:
      ret.bases.add(known=item.__module__+'.'+item.__name__)

  return ret


def parse_deps(repo_name, mod_ast, relpath):
  """Finds and parses the `DEPS` variable out of `mod_ast`.

  Args:
    * repo_name (str) - The implicit repo_name for DEPS entries which do not
      specify one.
    * mod_ast (ast.Module) - The Python module AST to parse from.
    * relpath (str) - The posix-style relative path which should be associated
      with the code in class_ast.

  Returns Doc.Deps proto message.
  """
  assert isinstance(mod_ast, ast.Module), type(mod_ast)
  ret = None

  DEPS, lineno = _find_value_of(mod_ast, 'DEPS')
  if DEPS:
    ret = doc.Doc.Deps(
      relpath=relpath,
      lineno=lineno,
    )
    spec = parse_deps_spec(repo_name, ast.literal_eval(_unparse(DEPS)))
    for dep_repo_name, mod_name in sorted(itervalues(spec)):
      ret.module_links.add(repo_name=dep_repo_name, name=mod_name)

  return ret


def extract_jsonish_assignments(mod_ast):
  """This extracts all single assignments where the target is a name, and the
  value is a simple 'jsonish' statement (aka Python literal).

  The result is returned as a dictionary of name to the decoded literal.

  Example:
    Foo = "hello"
    Bar = [1, 2, "something"]
    Other, Things = range(2)  # not single assignment
    Bogus = object()  # not a Python literal
    # returns: {"Foo": "hello", "Bar": [1, 2, "something"]}
  """
  ret = {}
  for node in mod_ast.body:
    if not isinstance(node, ast.Assign):
      continue
    if len(node.targets) != 1:
      continue
    if not isinstance(node.targets[0], ast.Name):
      continue
    try:
      ret[node.targets[0].id] = ast.literal_eval(node.value)
    except (KeyError, ValueError):
      pass
  return ret


def parse_parameter(param):
  """Parses a recipe parameter into a Doc.Parameter.

  Args:
    * param (recipe_api.Property) - The parameter to parse.

  Returns Doc.Parameter.
  """
  assert isinstance(param, recipe_api.Property), type(param)
  default = None
  if param._default is not recipe_api.PROPERTY_SENTINEL:
    default = json.dumps(param._default)

  return doc.Doc.Parameter(
    docstring=param.help,
    kind=param.kind.schema_proto() if param.kind else None,
    default_json=default)


MOCK_IMPORTS_PARAMETERS = _expand_mock_imports(
  _property_imports, _config_imports)
ALL_IMPORTS.update(MOCK_IMPORTS_PARAMETERS)


def parse_parameters(mod_ast, relpath):
  """Parses a set of recipe parameters from the PROPERTIES variable.

  Args:
    * mod_ast (ast.Module) - The parsed Python module code.
    * relpath (str) - The posix-style relative path which should be associated
      with the code in mod_ast.

  Returns Doc.Parameters.
  """
  assert isinstance(mod_ast, ast.Module), type(mod_ast)
  parameters, lineno = _find_value_of(mod_ast, 'PROPERTIES')
  if not parameters:
    return None

  if not isinstance(parameters, ast.Dict):
    # TODO(iannucci): Docs for new-style Protobuf PROPERTIES.
    return None

  imports = _parse_mock_imports(mod_ast, MOCK_IMPORTS_PARAMETERS)
  imports.update(extract_jsonish_assignments(mod_ast))
  data = eval(_unparse(parameters), imports, {'basestring': basestring})
  if not data:
    return None

  for k, v in sorted(iteritems(data)):
    data[k] = parse_parameter(v)

  return doc.Doc.Parameters(relpath=relpath, lineno=lineno, parameters=data)


def parse_func(func_ast, relpath, imports):
  """Parses a function into a Doc.Func.

  Args:
    * func_ast (ast.FunctionDef) - The function to parse.
    * relpath (str) - The posix-style relative path which should be associated
      with the code in func_ast.
    * imports (Dict[str, object]) - The symbols to include during the evaluation
      of `func_ast`.

  Returns Doc.Func.
  """
  assert isinstance(func_ast, ast.FunctionDef), type(func_ast)
  # In python3.8, the lineno was fixed to point to the line of func def while
  # it points to its first decorator in earlier versions (for details, see -
  # bugs.python.org/issue33211). Force it to point back to the first decorator
  # in order to generate the same README.recipes.md file.
  # TODO(crbug.com/1147793): remove this hack post migration.
  lineno = func_ast.lineno
  deco_list = getattr(func_ast, 'decorator_list', [])
  if len(deco_list) > 0:
    lineno = deco_list[0].lineno
  ret = doc.Doc.Func(
    name=func_ast.name,
    relpath=relpath,
    lineno=lineno,
    docstring=ast.get_docstring(func_ast) or '',
  )

  for exp in func_ast.decorator_list:
    item = _apply_imports_to_unparsed_expression(exp, imports)
    if isinstance(item, str):
      ret.decorators.add(generic=item)
    else:
      ret.decorators.add(known=item.__module__+'.'+item.__name__)

  ret.signature = _unparse(func_ast.args).strip()
  return ret



MOCK_IMPORTS_RECIPE = _expand_mock_imports(
 _util_imports, _decorator_imports, _placeholder_imports)
ALL_IMPORTS.update(MOCK_IMPORTS_RECIPE)


def parse_recipe(recipe):
  """Parses a recipe object into a Doc.Recipe.

  Args:
    * recipe (Recipe) - The recipe object to parse.

  Returns Doc.Recipe.
  """
  assert isinstance(recipe, Recipe), type(recipe)
  relpath = _to_posix(recipe.relpath)

  recipe_ast = _grab_ast(recipe.repo, recipe.path)
  if not recipe_ast:
    return None
  classes, funcs = _extract_classes_funcs(recipe_ast, relpath,
                                          MOCK_IMPORTS_RECIPE)
  funcs.pop('GenTests', None)

  return doc.Doc.Recipe(
    name=recipe.name,
    relpath=relpath,
    docstring=ast.get_docstring(recipe_ast) or '',
    deps=parse_deps(recipe.repo.name, recipe_ast, relpath),
    parameters=parse_parameters(recipe_ast, relpath),
    classes=classes,
    funcs=funcs,
    python_version_compatibility=recipe.python_version_compatibility,
  )


MOCK_IMPORTS_MODULE = _expand_mock_imports(
  _recipe_api_class_imports, _decorator_imports, _placeholder_imports)
ALL_IMPORTS.update(MOCK_IMPORTS_MODULE)


def parse_module(module):
  """Parses a recipe module object into a Doc.Module.

  Args:
    * recipe (RecipeModule) - The module object to parse.

  Returns Doc.Module.
  """
  assert isinstance(module, RecipeModule), type(module)
  relpath = _to_posix(module.relpath)

  api = _grab_ast(module.repo, os.path.join(module.path, 'api.py'))
  if not api:
    return None

  init = _grab_ast(module.repo, os.path.join(module.path, '__init__.py'))
  if not init:
    return None

  imports = _parse_mock_imports(api, MOCK_IMPORTS_MODULE)
  classes, funcs = _extract_classes_funcs(
    api, posixpath.join(relpath, 'api.py'), imports)

  api_class = None
  for name, val in sorted(iteritems(classes)):
    if any(b.known in _recipe_api_class_imports for b in val.bases):
      api_class = classes.pop(name)
      break
  if not api_class:
    LOGGER.error('could not determine main RecipeApi class: %r', relpath)
    return None

  init_relpath = posixpath.join(relpath, '__init__.py')

  return doc.Doc.Module(
    name=module.name,
    relpath=relpath,
    docstring=ast.get_docstring(api) or '',
    api_class=api_class,
    classes=classes,
    funcs=funcs,
    deps=parse_deps(module.repo.name, init, init_relpath),
    parameters=parse_parameters(init, init_relpath),
    python_version_compatibility=module.python_version_compatibility,
  )


def parse_repo(repo):
  """Parses a recipe repo object into a Doc.Repo.

  Args:
    * recipe (RecipeRepo) - The repo to parse.

  Returns Doc.Repo.
  """
  assert isinstance(repo, RecipeRepo), type(repo)
  ret = doc.Doc.Repo(repo_name=repo.name)
  ret.specs[repo.name].CopyFrom(repo.recipes_cfg_pb2)
  for dep_repo_name in repo.recipes_cfg_pb2.deps:
    ret.specs[dep_repo_name].CopyFrom(
        repo.recipe_deps.repos[dep_repo_name].recipes_cfg_pb2)

  readme = join(repo.recipes_root_path, 'README.recipes.intro.md')
  if os.path.isfile(readme):
    with open(readme, 'rb') as f:
      ret.docstring = f.read()

  for module in itervalues(repo.modules):
    mod = parse_module(module)
    if mod:
      ret.recipe_modules[module.name].CopyFrom(mod)

  for recipe in itervalues(repo.recipes):
    recipe = parse_recipe(recipe)
    if recipe:
      ret.recipes[recipe.name].CopyFrom(recipe)

  return ret


RECIPE_ENGINE_URL = 'https://chromium.googlesource.com/infra/luci/recipes-py'


def _set_known_objects(base):
  """Populates `base` with all registered known objects.

  Args:
    * base (Doc.Repo) - The repo doc message to populate.
  """
  assert isinstance(base, doc.Doc.Repo), type(base)

  source_cache = {}

  def _add_it(key, fname, target):
    relpath = os.path.relpath(fname, RECIPE_ENGINE_BASE)
    for node in source_cache[fname].body:
      if isinstance(node, ast.ClassDef) and node.name == target:
        # This is a class definition in the form of:
        #   def Target(...)
        base.known_objects[key].klass.CopyFrom(parse_class(node, relpath, {}))
        return
      elif isinstance(node, ast.FunctionDef) and node.name == target:
        # This is a function definition in the form of:
        #   def Target(...)
        base.known_objects[key].func.CopyFrom(parse_func(node, relpath, {}))
        return
      elif isinstance(node, ast.Assign) and node.targets[0].id == target:
        # This is an alias in the form of:
        #   Target = RealImplementation
        _add_it(key, fname, node.value.id)
        return

    raise ValueError('could not find %r in %r' % (key, relpath))

  for k, v in iteritems(KNOWN_OBJECTS):
    base.known_objects[k].url = RECIPE_ENGINE_URL
    _, target = k.rsplit('.', 1)
    fname = inspect.getsourcefile(v)
    if fname not in source_cache:
      # we load and cache the whole source file so that ast.parse gets the right
      # line numbers for all the definitions.
      source_lines, _ = inspect.findsource(v)
      source_cache[fname] = ast.parse(''.join(source_lines), fname)

    _add_it(k, fname, target)


def regenerate_doc(repo, output_file):
  """Rewrites `README.recipes.md` for the given recipe repo to the given output
  file.

  Args:
    * repo (RecipeRepo) - The repo to regenerate the markdown docs for.
    * output_file (I/O classes) - where the markdown docs will be printed.
  """
  assert isinstance(repo, RecipeRepo), type(repo)
  node = parse_repo(repo)
  _set_known_objects(node)
  doc_markdown.Emit(doc_markdown.Printer(output_file), node)


def is_doc_changed(repo):
  """Check if the `README.recipes.md` is changed.

  Args:
    * repo (RecipeRepo) - The repo for this readme file.

  Returns boolean.
  """
  buf = StringIO()
  regenerate_doc(repo, buf)
  with open(repo.readme_path, 'r') as f:
    current_file = f.read()
  return buf.getvalue() != current_file


def main(args):
  # defer to regenerate_doc for consistency between train and 'doc --kind gen'
  repo = args.recipe_deps.main_repo
  if repo.recipes_cfg_pb2.no_docs:
    LOGGER.warn('"no_docs" is set in recipes.cfg, generating docs anyway')

  if args.kind == 'gen':
    print('Generating README.recipes.md')
    with open(repo.readme_path, 'w') as f:
      regenerate_doc(repo, f)
    return 0

  if args.recipe:
    node = parse_recipe(args.recipe_deps.recipes[args.recipe])
  else:
    node = parse_repo(repo)

  _set_known_objects(node)

  if args.kind == 'jsonpb':
    sys.stdout.write(jsonpb.MessageToJson(
      node, including_default_value_fields=True,
      preserving_proto_field_name=True))
  elif args.kind == 'binarypb':
    sys.stdout.write(node.SerializeToString())
  elif args.kind == 'textpb':
    sys.stdout.write(textpb.MessageToString(node))
  elif args.kind == 'markdown':
    doc_markdown.Emit(doc_markdown.Printer(sys.stdout), node)
  else:
    raise NotImplementedError('--kind=%s' % args.kind)
