# -*- coding: utf8 -*-
# Copyright (c) 2019 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

import dataclasses
import html
import io
import sys
import typing as t
from pathlib import Path

import docspec
from docspec_python import format_arglist
from yapf.yapflib.yapf_api import FormatCode  # type: ignore[import]

from pydoc_markdown.interfaces import Context, Renderer, Resolver, ResolverV2, SingleObjectRenderer, SinglePageRenderer, SourceLinker
from pydoc_markdown.util.docspec import format_function_signature, is_method, ApiSuite


def dotted_name(obj: docspec.ApiObject) -> str:
  return '.'.join(x.name for x in obj.path)


@dataclasses.dataclass
class MarkdownRenderer(Renderer, SinglePageRenderer, SingleObjectRenderer):
  """
  Produces Markdown files. This renderer is often used by other renderers, such as
  #MkdocsRenderer and #HugoRenderer. It provides a wide variety of options to customize
  the generated Markdown files.

  ### Options
  """

  #: The name of the file to render to. If no file is specified, it will
  #: render to stdout.
  filename: t.Optional[str] = None

  #: The encoding of the output file. This is ignored when rendering to
  #: stdout.
  encoding: str = 'utf-8'

  #: If enabled, inserts anchors before Markdown headers to ensure that
  #: links to the header work. This is enabled by default.
  insert_header_anchors: bool = True

  #: Generate HTML headers instead of Mearkdown headers. This is disabled
  #: by default.
  html_headers: bool = False

  #: Render names in headers as code (using backticks or `<code>` tags,
  #: depending on #html_headers). This is enabled by default.
  code_headers: bool = False

  #: Generate descriptive class titles by adding the word "Objects" if set to `True`. Otherwise,
  #: it can be a string that is appended or prepended (appended if the string begins with `$`).
  #: the class name. This is enabled by default.
  descriptive_class_title: t.Union[bool, str] = True

  #: Generate descriptivie module titles by adding the word "Module" before
  #: the module name. This is enabled by default.
  descriptive_module_title: bool = False

  #: Add the module name as a prefix to class & method names. This module name is
  #: also rendered as code if #code_headers is enabled. This is enabled
  #: by default.
  add_module_prefix: bool = True

  #: Add the class name as a prefix to method names. This class name is
  #: also rendered as code if #code_headers is enabled. This is enabled
  #: by default.
  add_method_class_prefix: bool = False

  #: Add the class name as a prefix to member names. This is enabled by
  #: default.
  add_member_class_prefix: bool = False

  #: Add the full module name as a prefix to the title of the header.
  #: This is disabled by default.
  add_full_prefix: bool = False

  #: If #add_full_prefix is enabled, this will result in the prefix to
  #: be wrapped in a `<sub>` tag.
  sub_prefix: bool = False

  #: Render the definition of data members as a code block. This is disabled
  #: by default.
  data_code_block: bool = False

  #: Max length of expressions. If this limit is exceeded, the remaining
  #: characters will be replaced with three dots. This is set to 100 by
  #: default.
  data_expression_maxlength: int = 100

  #: Render the class signature as a code block. This includes the "class"
  #: keyword, the class name and its bases. This is enabled by default.
  classdef_code_block: bool = True

  #: Render decorators before class definitions.
  classdef_with_decorators: bool = True

  #: Render classdef and function signature blocks in the Python help()
  #: style.
  signature_python_help_style: bool = False

  #: Render the function signature as a code block. This includes the "def"
  #: keyword, the function name and its arguments. This is enabled by
  #: default.
  signature_code_block: bool = True

  #: Render the function signature in the header. This is disabled by default.
  signature_in_header: bool = False

  #: Render the vertical bar '|' before function signature. This is enabled by default.
  signature_with_vertical_bar: bool = False

  #: Include the "def" keyword in the function signature. This is enabled
  #: by default.
  signature_with_def: bool = True

  #: Render the class name in the code block for function signature. Note
  #: that this results in invalid Python syntax to be rendered. This is
  #: disabled by default.
  signature_class_prefix: bool = False

  #: Render decorators before function definitions.
  signature_with_decorators: bool = True

  #: Render type hints for data elements in the header.
  render_typehint_in_data_header: bool = False

  #: Add the string "python" after the backticks for code blocks. This is
  #: enabled by default.
  code_lang: bool = True

  #: Render title of page at the beginning of the file.
  render_page_title: bool = False

  #: Render a table of contents at the beginning of the file.
  render_toc: bool = False

  #: The title of the "Table of Contents" header.
  render_toc_title: str = 'Table of Contents'

  #: The maximum depth of the table of contents. Defaults to 2.
  toc_maxdepth: int = 2

  #: Render module headers. This is enabled by default.
  render_module_header: bool = True

  #: Custom template for module header.
  render_module_header_template: str = ''

  #: Render docstrings as blockquotes. This is disabled by default.
  docstrings_as_blockquote: bool = False

  #: Use a fixed header level for every kind of API object. The individual
  #: levels can be defined with #header_level_by_type.
  use_fixed_header_levels: bool = True

  #: Fixed header levels by API object type.
  header_level_by_type: t.Dict[str, int] = dataclasses.field(default_factory=lambda: {
    'Module': 1,
    'Class': 2,
    'Method': 4,
    'Function': 4,
    'Variable': 4,
  })

  #: A plugin that implements the #SourceLinker interface to provide links to the
  #: source code of API objects. If this field is specified, the renderer will
  #: place links to the source code in the generated Markdown files.
  source_linker: t.Optional[SourceLinker] = None

  #: Allows you to define the position of the "view source" link in the Markdown
  #: file if a #source_linker is configured.
  # TODO: Validator.choices(["after signature", "before signature"])
  source_position: str = 'after signature'

  #: Allows you to override how the "view source" link is rendered into the Markdown
  #: file if a #source_linker is configured. The default is `[[view_source]]({url})`.
  source_format: str = '[[view_source]]({url})'

  #: Escape html in docstring. Default to False.
  escape_html_in_docstring: bool = False

  #: Render Novella `@anchor` tags before headings.
  render_novella_anchors: bool = False

  #: Format code rendered into Markdown code blocks with YAPF.
  format_code: bool = True

  #: The style to format code as. This can be a YAPF builtin style name or point to
  #: a file relative to the context directory (usually the working directory).
  format_code_style: str = 'pep8'

  def __post_init__(self) -> None:
    self._resolver = MarkdownReferenceResolver()

  def _is_method(self, obj: docspec.ApiObject) -> bool:
    return is_method(obj)

  def _format_arglist(self, func: docspec.Function) -> str:
    args = func.args[:]
    if self._is_method(func) and args and args[0].name == 'self':
      args.pop(0)
    return format_arglist(args)

  def _render_toc(self, fp: t.TextIO, level: int, obj: docspec.ApiObject):
    if level > self.toc_maxdepth:
      return
    object_id = self._resolver.generate_object_id(obj)
    title = self._escape(obj.name)
    if not self.add_module_prefix and isinstance(obj, docspec.Module):
      title = title.split('.')[-1]
    fp.write('  ' * level + '* [{}](#{})\n'.format(title, object_id))
    level += 1
    for child in getattr(obj, 'members', []):
      self._render_toc(fp, level, child)

  def _render_header(self, fp: t.TextIO, level: int, obj: docspec.ApiObject):
    if self.render_module_header_template and isinstance(obj, docspec.Module):
      fp.write(
        self.render_module_header_template.format(
          module_name=obj.name,
          relative_module_name=obj.name.rsplit(".", 1)[-1]
        )
      )
      return

    object_id = self._resolver.generate_object_id(obj)
    if self.use_fixed_header_levels:
      # Read the header level based on the API object type. The default levels defined
      # in the field will act as a first fallback, the level of the object inside it's
      # hierarchy is the final fallback.
      header_levels = {
        **type(self).__dataclass_fields__['header_level_by_type'].default_factory(),
        **self.header_level_by_type,
      }
      # Backwards compat for when we used "Data" instead of "Variable" which mirrors the docspec API
      header_levels['Variable'] = header_levels.get('Data', header_levels['Variable'])

      type_name = 'Method' if self._is_method(obj) else type(obj).__name__
      level = header_levels.get(type_name, level)
    if self.insert_header_anchors and not self.html_headers:
      fp.write('<a id="{}"></a>\n\n'.format(object_id))
    if self.html_headers:
      header_template = '<h{0} id="{1}">{{title}}</h{0}>'.format(level, object_id)
    else:
      header_template = level * '#' + ' {title}'
    if self.render_novella_anchors:
      fp.write(f'@anchor pydoc:' + '.'.join(x.name for x in obj.path) + '\n')
    fp.write(header_template.format(title=self._get_title(obj)))
    fp.write('\n\n')

  def _format_decorations(self, decorations: t.List[docspec.Decoration]) -> t.Iterable[str]:
    for dec in decorations:
      yield '@{}{}\n'.format(dec.name, dec.args or '')

  def _yapf_code(self, code: str) -> str:
    if not self.format_code:
      return code
    style_file = Path(self._context.directory) / self.format_code_style
    style = str(style_file) if style_file.is_file() else self.format_code_style
    return FormatCode(code, style_config=style)[0]

  def _format_function_signature(self, func: docspec.Function, override_name: str = None, add_method_bar: bool = True) -> str:
    parts: t.List[str] = []
    if self.signature_with_decorators:
      parts += self._format_decorations(func.decorations or [])
    if self.signature_python_help_style and not self._is_method(func):
      parts.append('{} = '.format(dotted_name(func)))
    parts += [x + ' ' for x in func.modifiers or []]
    if self.signature_with_def:
      parts.append('def ')
    if self.signature_class_prefix and self._is_method(func):
      parent = func.parent
      assert parent, func
      parts.append(parent.name + '.')
    parts.append((override_name or func.name))
    parts.append(format_function_signature(func, self._is_method(func)))
    result = ''.join(parts)
    result = self._yapf_code(result + ': pass').rpartition(':')[0].strip()

    if add_method_bar and self._is_method(func):
      result = '\n'.join(' | ' + l for l in result.split('\n'))
    return result

  def _format_classdef_signature(self, cls: docspec.Class) -> str:
    bases = ', '.join(map(str, cls.bases or []))
    if cls.metaclass:
      bases += ', metaclass=' + str(cls.metaclass)
    code = 'class {}({})'.format(cls.name, bases)
    if self.signature_python_help_style:
      code = dotted_name(cls) + ' = ' + code
    code = self._yapf_code(code + ': pass').rpartition(':')[0].strip()

    if cls.decorations and self.classdef_with_decorators:
      code = '\n'.join(self._format_decorations(cls.decorations)) + code
    return code

  def _format_data_signature(self, data: docspec.Variable) -> str:
    expr = str(data.value)
    code = self._yapf_code(data.name + ' = ' + expr).strip()
    if len(code) > self.data_expression_maxlength:
      code = code[:self.data_expression_maxlength] + ' ...'
    return code

  def _render_signature_block(self, fp: t.TextIO, obj: docspec.ApiObject):
    if self.classdef_code_block and isinstance(obj, docspec.Class):
      code = self._format_classdef_signature(obj)
    elif self.signature_code_block and isinstance(obj, docspec.Function):
      code = self._format_function_signature(obj, add_method_bar=self.signature_with_vertical_bar)
    elif self.data_code_block and isinstance(obj, docspec.Variable):
      code = self._format_data_signature(obj)
    else:
      return
    fp.write('```{}\n'.format('python' if self.code_lang else ''))
    fp.write(code)
    fp.write('\n```\n\n')

  def _render_object(self, fp: t.TextIO, level: int, obj: docspec.ApiObject):
    if not isinstance(obj, docspec.Module) or self.render_module_header:
      self._render_header(fp, level, obj)

    render_view_source = not isinstance(obj, (docspec.Module, docspec.Variable))

    if render_view_source:
      url = self.source_linker.get_source_url(obj) if self.source_linker else None
      source_string = self.source_format.replace('{url}', str(url)) if url else None
      if source_string and self.source_position == 'before signature':
        fp.write(source_string + '\n\n')

    self._render_signature_block(fp, obj)

    if render_view_source:
      if source_string and self.source_position == 'after signature':
        fp.write(source_string + '\n\n')

    if obj.docstring:
      docstring = html.escape(obj.docstring.content) if self.escape_html_in_docstring else obj.docstring.content
      lines = docstring.split('\n')
      if self.docstrings_as_blockquote:
        lines = ['> ' + x for x in lines]
      fp.write('\n'.join(lines))
      fp.write('\n\n')

  def _render_recursive(self, fp: t.TextIO, level: int, obj: docspec.ApiObject):
    self._render_object(fp, level, obj)
    level += 1
    for member in getattr(obj, 'members', []):
      self._render_recursive(fp, level, member)

  def _get_title(self, obj: docspec.ApiObject) -> str:
    title = obj.name
    if (self.add_method_class_prefix and self._is_method(obj)) or \
       (self.add_member_class_prefix and isinstance(obj, docspec.Variable)):
      title = (obj.parent.name + '.' + title) if obj.parent else title
    elif self.add_full_prefix and not self._is_method(obj):
      title = dotted_name(obj)
    if (not self.add_module_prefix and isinstance(obj, docspec.Module)):
      title = title.split('.')[-1]
    if isinstance(obj, docspec.Function):
      if self.signature_in_header:
        title += '(' + self._format_arglist(obj) + ')'

    if isinstance(obj, docspec.Variable) and obj.datatype and self.render_typehint_in_data_header:
      if self.code_headers:
        title += f': {obj.datatype}'
      elif self.html_headers:
        title += f': <code>{obj.datatype}</code>'
      else:
        title += f': `{obj.datatype}`'

    if self.code_headers:
      if self.html_headers or self.sub_prefix:
        if self.sub_prefix and '.' in title:
          prefix, title = title.rpartition('.')[::2]
          title = '<sub>{}.</sub>{}'.format(prefix, title)
        title = '<code>{}</code>'.format(title)
      else:
        title = '`{}`'.format(title)
    elif not self.html_headers:
      title = self._escape(title)
    if isinstance(obj, docspec.Module) and self.descriptive_module_title:
      title = 'Module ' + title
    if isinstance(obj, docspec.Class) and self.descriptive_class_title:
      if self.descriptive_class_title is True:
        title += ' Objects'
      elif self.descriptive_class_title is False:
        pass
      elif self.descriptive_class_title.startswith('$'):
        title += self.descriptive_class_title[1:]
      else:
        title = self.descriptive_class_title + title
    return title

  def _escape(self, s):
    return s.replace('_', '\\_').replace('*', '\\*')

  def render_to_string(self, modules: t.List[docspec.Module]) -> str:
    fp = io.StringIO()
    self.render_single_page(fp, modules)
    return fp.getvalue()

  def _render_to_stream(self, modules: t.List[docspec.Module], stream: t.TextIO):
    return self.render_single_page(stream, modules)

  # SinglePageRenderer

  def render_single_page(self, fp: t.TextIO, modules: t.List[docspec.Module], page_title: t.Optional[str] = None) -> None:
    if self.render_page_title:
      fp.write('# {}\n\n'.format(page_title))

    if self.render_toc:
      if self.render_toc_title:
        if self.render_page_title:
          # set to level2 since level1 is page title
          fp.write('## {}\n\n'.format(self.render_toc_title))
        else:
          fp.write('# {}\n\n'.format(self.render_toc_title))

      for m in modules:
        self._render_toc(fp, 0, m)
      fp.write('\n')
    for m in modules:
      self._render_recursive(fp, 1, m)

  # SingleObjectRenderer

  def render_object(self, fp: t.TextIO, obj: docspec.ApiObject, options: t.Dict[str, t.Any]) -> None:
    self._render_recursive(fp, 0, obj)

  # Renderer

  def get_resolver(self, modules: t.List[docspec.Module]) -> t.Optional[Resolver]:
    """
    Returns a simple #Resolver implementation. Finds cross-references in the same file.
    """

    return self._resolver

  def render(self, modules: t.List[docspec.Module]) -> None:
    if self.filename is None:
      self._render_to_stream(modules, sys.stdout)
    else:
      with io.open(self.filename, 'w', encoding=self.encoding) as fp:
        self._render_to_stream(modules, t.cast(t.TextIO, fp))

  # PluginBase

  def init(self, context: Context) -> None:
    if self.source_linker:
      self.source_linker.init(context)
    self._context = context


@dataclasses.dataclass
class MarkdownReferenceResolver(Resolver, ResolverV2):

  local: bool = True
  global_: bool = False

  def generate_object_id(self, obj: docspec.ApiObject) -> str:
    return '.'.join(o.name for o in obj.path)

  def _resolve_reference_in_members(self, obj: t.Optional[docspec.ApiObject], ref: t.List[str]) -> t.Optional[docspec.ApiObject]:
    if not obj:
      return None
    for part_name in ref:
      obj = docspec.get_member(obj, part_name)
      if not obj:
        return None
    return obj

  def _resolve_local_reference(self, scope: docspec.ApiObject, ref_split: t.List[str]) -> t.Optional[docspec.ApiObject]:
    obj: t.Optional[docspec.ApiObject] = scope
    while obj:
      resolved = self._resolve_reference_in_members(obj, ref_split)
      if resolved:
        return resolved
      obj = obj.parent
    return None

  # Resolver

  def resolve_ref(self, scope: docspec.ApiObject, ref: str) -> t.Optional[str]:
    target = self._resolve_local_reference(scope, ref.split('.'))
    if target:
      return '#' + self.generate_object_id(target)
    return None

  # ResolverV2

  def resolve_reference(self, suite: ApiSuite, scope: docspec.ApiObject, ref: str) -> t.Optional[docspec.ApiObject]:
    """ Resolves the reference by searching in the members of *scope* or any of its parents. """

    # TODO (@NiklasRosenstein): Support resolving indirections

    ref_split = ref.split('.')

    resolved = self._resolve_local_reference(scope, ref_split)
    if resolved:
      return resolved

    if self.global_:
      def _recurse(obj: docspec.ApiObject) -> t.Optional[docspec.ApiObject]:
        resolved = self._resolve_reference_in_members(obj, ref_split)
        if resolved:
          return resolved
        if isinstance(obj, docspec.HasMembers):
          for member in obj.members:
            resolved = _recurse(member)
            if resolved:
              return resolved
        return None

      for module in suite:
        resolved = _recurse(module)
        if resolved:
          return resolved

    return None

