# Copyright 2015 Google Inc. All Rights Reserved.
#
# 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.

"""Cloud SDK markdown document text renderer."""

from googlecloudsdk.core.console import console_attr
from googlecloudsdk.core.document_renderers import renderer


class TextRenderer(renderer.Renderer):
  """Renders markdown to text.

  Attributes:
    _attr: console_attr.ConsoleAttr object.
    _bullet: List of bullet characters indexed by list level modulo #bullets.
    _blank: True if the output already contains a blank line. Used to avoid
      sequences of 2 or more blank lines in the output.
    _csi_char: The first control sequence indicator character or None if control
      sequences are not supported.
    _fill: The number of characters in the current output line.
    _ignore_width: True if the next output word should ignore _width.
    _indent: List of left indentations in characters indexed by _level.
    _level: The section or list level counting from 0.
    _table: True if currently rendering a table.
  """
  INDENT = 4
  SPLIT_INDENT = 2

  class Indent(object):
    """Hanging indent stack."""

    def __init__(self):
      self.indent = TextRenderer.INDENT
      self.hanging_indent = self.indent

  def __init__(self, *args, **kwargs):
    super(TextRenderer, self).__init__(*args, **kwargs)
    # We want the rendering to match the default console encoding. self._out
    # could be a file or pipe to a pager, either way we still want to see rich
    # encoding if the console supports it.
    encoding = console_attr.GetConsoleAttr().GetEncoding()
    self._attr = console_attr.GetConsoleAttr(out=self._out, encoding=encoding)
    self._blank = True
    self._bullet = self._attr.GetBullets()
    self._csi_char = self._attr.GetControlSequenceIndicator()
    if self._csi_char:
      self._csi_char = self._csi_char[0]
    self._fill = 0
    self._ignore_width = False
    self._indent = [self.Indent()]
    self._level = 0
    self._table = False

  def _Flush(self):
    """Flushes the current collection of Fill() lines."""
    self._ignore_width = False
    if self._fill:
      self._out.write('\n')
      self._blank = False
      self._fill = 0

  def _SetIndent(self, level, indent=None, hanging_indent=None):
    """Sets the markdown list level and indentations.

    Args:
      level: int, The desired markdown list level.
      indent: int, The new indentation.
      hanging_indent: int, The hanging indentation. This is subtracted from the
        prevailing indent to decrease the indentation of the next input line
        for this effect:
            HANGING INDENT ON THE NEXT LINE
               PREVAILING INDENT
               ON SUBSEQUENT LINES
    """
    if self._level < level:
      # The level can increase by 1 or more. Loop through each so that
      # intervening levels are handled the same.
      while self._level < level:
        prev_level = self._level
        self._level += 1
        if self._level >= len(self._indent):
          self._indent.append(self.Indent())
        self._indent[self._level].indent = (
            self._indent[prev_level].indent + indent)
        if (self._level > 1 and
            self._indent[prev_level].hanging_indent ==
            self._indent[prev_level].indent):
          # Bump the indent by 1 char for nested indentation. Top level looks
          # fine (aesthetically) without it.
          self._indent[self._level].indent += 1
        self._indent[self._level].hanging_indent = (
            self._indent[self._level].indent)
        if hanging_indent is not None:
          # Adjust the hanging indent if specified.
          self._indent[self._level].hanging_indent -= hanging_indent
    else:
      # Decreasing level just sets the indent stack level, no state to clean up.
      self._level = level
      if hanging_indent is not None:
        # Change hanging indent on existing level.
        self._indent[self._level].indent = (
            self._indent[self._level].hanging_indent + hanging_indent)

  def Example(self, line):
    """Displays line as an indented example.

    Args:
      line: The example line text.
    """
    self._fill = self._indent[self._level].indent + self.INDENT
    self._out.write(' ' * self._fill + line + '\n')
    self._blank = False
    self._fill = 0

  def Fill(self, line):
    """Adds a line to the output, splitting to stay within the output width.

    This is close to textwrap.wrap() except that control sequence characters
    don't count in the width computation.

    Args:
      line: The text line.
    """
    self._blank = True
    for word in line.split():
      if not self._fill:
        self._fill = self._indent[self._level].indent - 1
        self._out.write(' ' * self._fill)
      width = self._attr.DisplayWidth(word)
      if self._fill + width + 1 >= self._width and not self._ignore_width:
        self._out.write('\n')
        self._fill = self._indent[self._level].indent
        self._out.write(' ' * self._fill)
      else:
        self._ignore_width = False
        if self._fill:
          self._fill += 1
          self._out.write(' ')
      self._fill += width
      self._out.write(word)

  def Finish(self):
    """Finishes all output document rendering."""
    self._Flush()
    self.Font(out=self._out)

  def Font(self, attr=None, out=None):
    """Returns the font embellishment string for attr.

    Args:
      attr: None to reset to the default font, otherwise one of renderer.BOLD,
        renderer.ITALIC, or renderer.CODE.
      out: Writes tags to this stream if not None.

    Returns:
      The font embellishment string.
    """
    if attr is None:
      self._font = 0
    else:
      mask = 1 << attr
      self._font ^= mask
    code = self._attr.GetFontCode(self._font & (1 << renderer.BOLD),
                                  self._font & (1 << renderer.ITALIC))
    if out:
      out.write(code)
    return code

  def Heading(self, level, heading):
    """Renders a heading.

    Args:
      level: The heading level counting from 1.
      heading: The heading text.
    """
    if level == 1 and heading.endswith('(1)'):
      # Ignore man page TH.
      return
    self._Flush()
    self.Line()
    self.Font(out=self._out)
    if level > 2:
      self._out.write('  ' * (level - 2))
    self._out.write(self.Font(renderer.BOLD) + heading +
                    self.Font(renderer.BOLD) + '\n')
    if level == 1:
      self._out.write('\n')
    self._blank = True
    self._level = 0
    self._rows = []

  def Line(self):
    """Renders a paragraph separating line."""
    self._Flush()
    if not self._blank:
      self._blank = True
      self._out.write('\n')

  def List(self, level, definition=None, end=False):
    """Renders a bullet or definition list item.

    Args:
      level: The list nesting level, 0 if not currently in a list.
      definition: Bullet list if None, definition list item otherwise.
      end: End of list if True.
    """
    self._Flush()
    if not level:
      self._level = level
    elif end:
      # End of list.
      self._SetIndent(level)
    elif definition is not None:
      # Definition list item.
      if definition:
        self._SetIndent(level, indent=4, hanging_indent=3)
        self._out.write(
            ' ' * self._indent[level].hanging_indent + definition + '\n')
      else:
        self._SetIndent(level, indent=1, hanging_indent=0)
        self.Line()
    else:
      # Bullet list item.
      indent = 2 if level > 1 else 4
      self._SetIndent(level, indent=indent, hanging_indent=2)
      self._out.write(' ' * self._indent[level].hanging_indent +
                      self._bullet[(level - 1) % len(self._bullet)])
      self._fill = self._indent[level].indent + 1
      self._ignore_width = True

  def _SkipSpace(self, line, index):
    """Skip space characters starting at line[index].

    Args:
      line: The string.
      index: The starting index in string.

    Returns:
      The index in line after spaces or len(line) at end of string.
    """
    while index < len(line):
      c = line[index]
      if c != ' ':
        break
      index += 1
    return index

  def _SkipControlSequence(self, line, index):
    """Skip the control sequence at line[index].

    Args:
      line: The string.
      index: The starting index in string.

    Returns:
      The index in line after the control sequence or len(line) at end of
      string.
    """
    n = self._attr.GetControlSequenceLen(line[index:])
    if not n:
      n = 1
    return index + n

  def _SkipNest(self, line, index, open_chars='[(', close_chars=')]'):
    """Skip a [...] nested bracket group starting at line[index].

    Args:
      line: The string.
      index: The starting index in string.
      open_chars: The open nesting characters.
      close_chars: The close nesting characters.

    Returns:
      The index in line after the nesting group or len(line) at end of string.
    """
    nest = 0
    while index < len(line):
      c = line[index]
      index += 1
      if c in open_chars:
        nest += 1
      elif c in close_chars:
        nest -= 1
        if nest <= 0:
          break
      elif c == self._csi_char:
        index = self._SkipControlSequence(line, index)
    return index

  def _SplitWideSynopsisGroup(self, group, indent, running_width):
    """Splits a wide SYNOPSIS section group string to self._out.

    Args:
      group: The wide group string to split.
      indent: The prevailing left indent.
      running_width: The width of the self._out line in progress.

    Returns:
      The running_width after the group has been split and written to self._out.
    """
    prev_delimiter = ' '
    while group:
      # Check split delimiters in order for visual emphasis.
      for delimiter in (' | ', ' : ', ' ', ','):
        part, _, remainder = group.partition(delimiter)
        w = self._attr.DisplayWidth(part)
        if ((running_width + len(prev_delimiter) + w) >= self._width or
            prev_delimiter != ',' and delimiter == ','):
          if delimiter != ',' and (indent +
                                   self.SPLIT_INDENT +
                                   len(prev_delimiter) +
                                   w) >= self._width:
            # The next delimiter may produce a smaller first part.
            continue
          if prev_delimiter == ',':
            self._out.write(prev_delimiter)
            prev_delimiter = ' '
          if running_width != indent:
            running_width = indent + self.SPLIT_INDENT
            self._out.write('\n' + ' ' * running_width)
        self._out.write(prev_delimiter + part)
        running_width += len(prev_delimiter) + w
        prev_delimiter = delimiter
        group = remainder
        break
    return running_width

  def Synopsis(self, line):
    """Renders NAME and SYNOPSIS lines as a hanging indent.

    Collapses adjacent spaces to one space, deletes trailing space, and doesn't
    split top-level nested [...] or (...) groups. Also detects and does not
    count terminal control sequences.

    Args:
      line: The NAME or SYNOPSIS text.
    """
    # Split the line into token, token | token, and [...] groups.
    groups = []
    i = self._SkipSpace(line, 0)
    beg = i
    while i < len(line):
      c = line[i]
      if c == ' ':
        end = i
        i = self._SkipSpace(line, i)
        if i <= (len(line) - 1) and line[i] == '|' and line[i + 1] == ' ':
          i = self._SkipSpace(line, i + 1)
        else:
          groups.append(line[beg:end])
          beg = i
      elif c in '[(':
        i = self._SkipNest(line, i)
      elif c == self._csi_char:
        i = self._SkipControlSequence(line, i)
      else:
        i += 1
    if beg < len(line):
      groups.append(line[beg:])

    # Output the groups.
    indent = self._indent[0].indent - 1
    running_width = indent
    self._out.write(' ' * running_width)
    indent += self.INDENT
    for group in groups:
      w = self._attr.DisplayWidth(group) + 1
      if (running_width + w) >= self._width:
        running_width = indent
        self._out.write('\n' + ' ' * running_width)
        if (running_width + w) >= self._width:
          # The group is wider than the available width and must be split.
          running_width = self._SplitWideSynopsisGroup(
              group, indent, running_width)
          continue
      self._out.write(' ' + group)
      running_width += w
    self._out.write('\n\n')

  def Table(self, line):
    """Renders a table line.

    Nested tables are not supported. The first call on a new table is:
      Table(attributes)
    the intermediate calls add the heading and data lines and the last call is:
      Table(None)

    Args:
      line: A CSV table data line.
    """
    if line is None:
      # TODO(b/31628974): Use resource_printer.TablePrinter().
      if self._rows:
        cols = len(self._rows[0])
        width = [0 for _ in range(cols)]
        for row in self._rows:
          for i in range(cols - 1):
            w = len(row[i])
            if width[i] <= w:
              width[i] = w + 1
        for row in self._rows:
          self._out.write(' ' * (self._indent[self._level].indent + 2))
          for i in range(cols - 1):
            self._out.write(row[i].ljust(width[i]))
          self._out.write(row[-1] + '\n')
        self._rows = []
      self._table = False
      self._out.write('\n')
    elif not self._table:
      self._table = True
      self.Line()
    else:
      self._rows.append(line.split(','))
