text: # -*- coding: utf-8 -*- #
  # Copyright 2017 Google LLC. 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.
  
  """utils for search-help command resources."""
  
  from __future__ import absolute_import
  from __future__ import division
  from __future__ import unicode_literals
  
  import copy
  import io
  import re
  
  from googlecloudsdk.command_lib.help_search import lookup
  from googlecloudsdk.core.document_renderers import render_document
  
  import six
  from six.moves import filter
  
  DEFAULT_SNIPPET_LENGTH = 200
  DOT = '.'
  
  SUMMARY_PRIORITIES = {
      lookup.NAME: 0,
      lookup.CAPSULE: 1,
      lookup.SECTIONS: 2,
      lookup.POSITIONALS: 3,
      lookup.FLAGS: 4,
      lookup.COMMANDS: 5,
      lookup.PATH: 6}
  
  
  class TextSlice(object):
    """Small class for working with pieces of text."""
  
    def __init__(self, start, end):
      self.start = start
      self.end = end
  
    def Overlaps(self, other):
      if other.start < self.start:
        return other.overlaps(self)
      return self.end >= other.start
  
    def Merge(self, other):
      if not self.Overlaps(other):
        msg = ('Cannot merge text slices [{}:{}] and [{}:{}]: '
               'Do not overlap.'.format(
                   self.start, self.end, other.start, other.end))
        raise ValueError(msg)
      self.start = min(self.start, other.start)
      self.end = max(self.end, other.end)
  
    def AsSlice(self):
      return slice(self.start, self.end, 1)
  
  
  def _GetStartAndEnd(match, cut_points, length_per_snippet):
    """Helper function to get start and end of single snippet that matches text.
  
    Gets a snippet of length length_per_snippet with the match object
    in the middle.
    Cuts at the first cut point (if available, else cuts at any char)
    within 1/2 the length of the start of the match object.
    Then cuts at the last cut point within
    the desired length (if available, else cuts at any point).
    Then moves start back if there is extra room at the beginning.
  
    Args:
      match: re.match object.
      cut_points: [int], indices of each cut char, plus start and
          end index of full string. Must be sorted.
          (The characters at cut_points are skipped.)
      length_per_snippet: int, max length of snippet to be returned
  
    Returns:
      (int, int) 2-tuple with start and end index of the snippet
    """
    max_length = cut_points[-1] if cut_points else 0
    match_start = match.start() if match else 0
    match_end = match.end() if match else 0
  
    # Get start cut point.
    start = 0
    if match_start > .5 * length_per_snippet:
      # Get first point within 1/2 * length_per_snippet chars of term.
      for c in cut_points:
        if c >= match_start - (.5 * length_per_snippet) and c < match_start:
          start = c + 1
          break  # The cut points are already sorted, so first = min.
      # If no cut points, just start 1/2 the desired length back or at 0.
      start = int(max(match_start - (.5 * length_per_snippet), start))
  
    # Get end cut point.
    # Must be after term but within desired distance of start.
    end = match_end
    # Look for last cut point in this interval
    for c in cut_points:
      if end < c <= start + length_per_snippet:
        end = c
      elif c > start + length_per_snippet:
        break  # the list was sorted, so last = max.
    # If no cut points, just cut at the exact desired length or at the end,
    # whichever comes first.
    if end == match_end:
      end = max(min(max_length, start + length_per_snippet), end)
  
    # If cutting at the end, update start so we get the maximum length snippet.
    # Look for the first cut point within length_of_snippet of the end.
    if end == max_length:
      for c in cut_points:
        if end - c <= (length_per_snippet + 1) and c < start:
          start = c + 1
          break
    return TextSlice(start, end)
  
  
  def _BuildExcerpt(text, snips):
    """Helper function to build excerpt using (start, end) tuples.
  
    Returns a string that combines substrings of the text (text[start:end]),
    joins them with ellipses
  
    Args:
      text: the text to excerpt from.
      snips: [(int, int)] list of 2-tuples representing start and end places
          to cut text.
  
    Returns:
      str, the excerpt.
    """
    snippet = '...'.join([text[snip.AsSlice()] for snip in snips])
    if snips:
      if snips[0].start != 0:
        snippet = '...' + snippet
      if snips[-1].end != len(text):
        snippet += '...'
    return snippet
  
  
  def _Snip(text, length_per_snippet, terms):
    """Create snippet of text, containing given terms if present.
  
    The max length of the snippet is the number of terms times the given length.
    This is to prevent a long list of terms from resulting in nonsensically
    short sub-strings. Each substring is up to length given, joined by '...'
  
    Args:
      text: str, the part of help text to cut. Should be only ASCII characters.
      length_per_snippet: int, the length of the substrings to create containing
          each term.
      terms: [str], the terms to include.
  
    Returns:
      str, a summary excerpt including the terms, with all consecutive whitespace
          including newlines reduced to a single ' '.
    """
    text = re.sub(r'\s+', ' ', text)
    if len(text) <= length_per_snippet:
      return text
    cut_points = ([0] + [r.start() for r in re.finditer(r'\s', text)] +
                  [len(text)])
  
    if not terms:
      return _BuildExcerpt(
          text,
          [_GetStartAndEnd(None, cut_points, length_per_snippet)])
  
    unsorted_matches = [re.search(term, text, re.IGNORECASE) for term in terms]
    matches = sorted(filter(bool, unsorted_matches),
                     key=lambda x: x.start())
    snips = []  # list of TextSlice objects.
    for match in matches:
      # Don't get a new excerpt if the word is already in the excerpted part.
      if not (snips and
              snips[-1].start < match.start() and snips[-1].end > match.end()):
        next_slice = _GetStartAndEnd(match, cut_points, length_per_snippet)
        # Combine if overlaps with previous snippet.
        if snips:
          latest = snips[-1]
          if latest.Overlaps(next_slice):
            latest.Merge(next_slice)
          else:
            snips.append(next_slice)
        else:
          snips.append(next_slice)
    # If no terms were found, just cut from beginning.
    if not snips:
      snips = [_GetStartAndEnd(None, cut_points, length_per_snippet)]
    return _BuildExcerpt(text, snips)
  
  
  def _FormatHeader(header):
    """Helper function to reformat header string in markdown."""
    if header == lookup.CAPSULE:
      return None
    return '# {}'.format(header.upper())
  
  
  def _FormatItem(item):
    """Helper function to reformat string as markdown list item: {STRING}::."""
    return '{}::'.format(item)
  
  
  def _SummaryPriority(x):
    # Ensure the summary is built in the right order.
    return SUMMARY_PRIORITIES.get(x[0], len(SUMMARY_PRIORITIES))
  
  
  class SummaryBuilder(object):
    """Class that builds a summary of certain attributes of a command.
  
    This will summarize a json representation of a command using
    cloud SDK-style markdown (but with no text wrapping) by taking snippets
    of the given locations in a command.
  
    If a lookup is given from terms to where they appear, then the snippets will
    include the relevant terms. Occurrences of search terms will be stylized.
  
    Uses a small amount of simple Cloud SDK markdown.
  
    1) To get a summary with just the brief help:
    SummaryBuilder(command, {'alligator': 'capsule'}).GetSummary()
  
    [no heading]
    {excerpt of command['capsule'] with first appearance of 'alligator'}
  
    2) To get a summary with a section (can be first-level or inside 'sections',
    which is the same as detailed_help):
    SummaryBuilder(command, {'': 'sections.SECTION_NAME'}).GetSummary()
  
    # SECTION_NAME
    {excerpt of 'SECTION_NAME' section of detailed help. If it is a list
     it will be joined by ', '.}
  
    3) To get a summary with a specific positional arg:
    SummaryBuilder(command, {'crocodile': 'positionals.myarg.name'}).GetSummary()
  
    # POSITIONALS
    myarg::
    {excerpt of 'myarg' positional help containing 'crocodile'}
  
    4) To get a summary with specific flags, possibly including choices/defaults:
    SummaryBuilder.GetSummary(command,
                              {'a': 'flags.--my-flag.choices',
                               'b': 'flags.--my-other-flag.default'})
  
    # FLAGS
    myflag::
    {excerpt of help} Choices: {comma-separated list of flag choices}
    myotherflag::
    {excerpt of help} Default: {flag default}
  
    Attributes:
      command: dict, a json representation of a command.
      found_terms_map: dict, mapping of terms to the locations where they are
        found, equivalent to the return value of
        CommandSearchResults.FoundTermsMap(). This map is found under "results"
        in the command resource returned by help-search. Locations have segments
        separated by dots, such as sections.DESCRIPTION. If the first segment is
        "flags" or "positionals", there must be three segments.
      length_per_snippet: int, length of desired substrings to get from text.
    """
  
    _INVALID_LOCATION_MESSAGE = (
        'Attempted to look up a location [{}] that was not found or invalid.')
    _IMPRECISE_LOCATION_MESSAGE = (
        'Expected location with three segments, received [{}]')
  
    def __init__(self, command, found_terms_map, length_per_snippet=200):
      """Create the class."""
      self.command = command
      self.found_terms_map = found_terms_map
      self.length_per_snippet = length_per_snippet
      self._lines = []
  
    def _AddFlagToSummary(self, location, terms):
      """Adds flag summary, given location such as ['flags']['--myflag']."""
      flags = self.command.get(location[0], {})
      line = ''
  
      assert len(location) > 2, self._IMPRECISE_LOCATION_MESSAGE.format(
          DOT.join(location))
  
      # Add flag name and description of flag if not added yet.
      flag = flags.get(location[1])
      assert flag and not flag[lookup.IS_HIDDEN], (
          self._INVALID_LOCATION_MESSAGE.format(DOT.join(location)))
      if _FormatHeader(lookup.FLAGS) not in self._lines:
        self._lines.append(_FormatHeader(lookup.FLAGS))
      if _FormatItem(location[1]) not in self._lines:
        self._lines.append(_FormatItem(location[1]))
        desc_line = flag.get(lookup.DESCRIPTION, '')
        desc_line = _Snip(desc_line, self.length_per_snippet, terms)
        assert desc_line, self._INVALID_LOCATION_MESSAGE.format(
            DOT.join(location))
        line = desc_line
  
      # Add default if needed.
      if location[2] == lookup.DEFAULT:
        default = flags.get(location[1]).get(lookup.DEFAULT)
        if default:
          if line not in self._lines:
            self._lines.append(line)
          if isinstance(default, dict):
            default = ', '.join([x for x in sorted(default.keys())])
          elif isinstance(default, list):
            default = ', '.join([x for x in default])
          line = 'Default: {}.'.format(default)
      else:
      # The other three sub-locations for flags are covered by adding the
      # snippet of the description.
        valid_subattributes = [lookup.NAME, lookup.DESCRIPTION, lookup.CHOICES]
        assert location[2] in valid_subattributes, (
            self._INVALID_LOCATION_MESSAGE.format(DOT.join(location)))
  
      if line:
        self._lines.append(line)
  
    def _AddPositionalToSummary(self, location, terms):
      """Adds summary of arg, given location such as ['positionals']['myarg']."""
      positionals = self.command.get(lookup.POSITIONALS)
      line = ''
      assert len(location) > 2, self._IMPRECISE_LOCATION_MESSAGE.format(DOT.join(
          location))
      positionals = [p for p in positionals if p[lookup.NAME] == location[1]]
      assert positionals, self._INVALID_LOCATION_MESSAGE.format(
          DOT.join(location))
      if _FormatHeader(lookup.POSITIONALS) not in self._lines:
        self._lines.append(_FormatHeader(lookup.POSITIONALS))
      self._lines.append(_FormatItem(location[1]))
      positional = positionals[0]
      line = positional.get(lookup.DESCRIPTION, '')
      line = _Snip(line, self.length_per_snippet, terms)
  
      if line:
        self._lines.append(line)
  
    def _AddGenericSectionToSummary(self, location, terms):
      """Helper function for adding sections in the form ['loc1','loc2',...]."""
      section = self.command
      for loc in location:
        section = section.get(loc, {})
        if isinstance(section, str):
          line = section
        # if dict or list, use commas to join keys or items, respectively.
        elif isinstance(section, list):
          line = ', '.join(sorted(section))
        elif isinstance(section, dict):
          line = ', '.join(sorted(section.keys()))
        else:
          line = six.text_type(section)
      assert line, self._INVALID_LOCATION_MESSAGE.format(DOT.join(location))
      header = _FormatHeader(location[-1])
      if header:
        self._lines.append(header)
      loc = '.'.join(location)
      self._lines.append(
          _Snip(line, self.length_per_snippet, terms))
  
    def GetSummary(self):
      """Builds a summary.
  
      Returns:
        str, a markdown summary
      """
      all_locations = set(self.found_terms_map.values())
      if lookup.CAPSULE not in all_locations:
        all_locations.add(lookup.CAPSULE)
  
      def _Equivalent(location, other_location):
        """Returns True if both locations correspond to same summary section."""
        if location == other_location:
          return True
        if len(location) != len(other_location):
          return False
        if location[:-1] != other_location[:-1]:
          return False
        equivalent = [lookup.NAME, lookup.CHOICES, lookup.DESCRIPTION]
        if location[-1] in equivalent and other_location[-1] in equivalent:
          return True
        return False
  
      # Sort alphabetically first to make sure everything is alphabetical within
      # the same priority.
      for full_location in sorted(sorted(all_locations), key=_SummaryPriority):
        location = full_location.split(DOT)
        terms = {t for t, l in six.iteritems(self.found_terms_map)
                 if _Equivalent(l.split(DOT), location) and t}
        if location[0] == lookup.FLAGS:
          self._AddFlagToSummary(location, terms)
        elif location[0] == lookup.POSITIONALS:
          self._AddPositionalToSummary(location, terms)
        # path and name are ignored.
        elif lookup.PATH in location or lookup.NAME in location:
          continue
        else:
          self._AddGenericSectionToSummary(location, terms)
      summary = '\n'.join(self._lines)
      return Highlight(summary, self.found_terms_map.keys())
  
  
  def GetSummary(command, found_terms_map,
                 length_per_snippet=DEFAULT_SNIPPET_LENGTH):
    """Gets a summary of certain attributes of a command."""
    return SummaryBuilder(
        command, found_terms_map, length_per_snippet).GetSummary()
  
  
  # TODO(b/113525269): Use a defined text type -- possibly user input.
  def _Stylize(s):
    """Stylize a given string. Currently done by converting to upper-case."""
    return s.upper()
  
  
  def Highlight(text, terms, stylize=None):
    """Stylize desired terms in a string.
  
    Returns a copy of the original string with all substrings matching the given
    terms (with case-insensitive matching) stylized.
  
    Args:
      text: str, the original text to be highlighted.
      terms: [str], a list of terms to be matched.
      stylize: callable, the function to use to stylize the terms.
  
    Returns:
      str, the highlighted text.
    """
    if stylize is None:
      stylize = _Stylize
    for term in filter(bool, terms):
      # Find all occurrences of term and stylize them.
      matches = re.finditer(term, text, re.IGNORECASE)
      match_strings = set([text[match.start():match.end()] for match in matches])
      for match_string in match_strings:
        text = text.replace(match_string, stylize(match_string))
    return text
  
  
  def ProcessResult(command, results):
    """Helper function to create help text resource for listing results.
  
    Args:
      command: dict, json representation of command.
      results: CommandSearchResults, result of searching for each term.
  
    Returns:
      A modified copy of the json command with a summary, and with the dict
          of subcommands replaced with just a list of available subcommands.
    """
    new_command = copy.deepcopy(command)
    if lookup.COMMANDS in six.iterkeys(new_command):
      new_command[lookup.COMMANDS] = sorted([
          c[lookup.NAME]
          for c in new_command[lookup.COMMANDS].values()
          if not c[lookup.IS_HIDDEN]
      ])
    new_command[lookup.RESULTS] = results.FoundTermsMap()
    return new_command
  
  
  def LocateTerm(command, term):
    """Helper function to get first location of term in a json command.
  
    Locations are considered in this order: 'name', 'capsule',
    'sections', 'positionals', 'flags', 'commands', 'path'. Returns a dot-
    separated lookup for the location e.g. 'sections.description' or
    empty string if not found.
  
    Args:
      command: dict, json representation of command.
      term: str, the term to search.
  
    Returns:
      str, lookup for where to find the term when building summary of command.
    """
    # Skip hidden commands.
    if command[lookup.IS_HIDDEN]:
      return ''
  
    # Look in name/path
    regexp = re.compile(re.escape(term), re.IGNORECASE)
    if regexp.search(command[lookup.NAME]):
      return lookup.NAME
    if regexp.search(' '.join(command[lookup.PATH] + [lookup.NAME])):
      return lookup.PATH
  
    def _Flags(command):
      return {flag_name: flag for (flag_name, flag)
              in six.iteritems(command[lookup.FLAGS])
              if not flag[lookup.IS_HIDDEN] and not flag[lookup.IS_GLOBAL]}
  
    # Look in flag and positional names
    for flag_name, flag in sorted(six.iteritems(_Flags(command))):
      if regexp.search(flag_name):
        return DOT.join([lookup.FLAGS, flag[lookup.NAME], lookup.NAME])
    for positional in command[lookup.POSITIONALS]:
      if regexp.search(positional[lookup.NAME]):
        return DOT.join([lookup.POSITIONALS, positional[lookup.NAME],
                         lookup.NAME])
  
    # Look in other help sections
    if regexp.search(command[lookup.CAPSULE]):
      return lookup.CAPSULE
    for section_name, section_desc in sorted(
        six.iteritems(command[lookup.SECTIONS])):
      if regexp.search(section_desc):
        return DOT.join([lookup.SECTIONS, section_name])
  
    # Look in flag sections
    for flag_name, flag in sorted(six.iteritems(_Flags(command))):
      for sub_attribute in [lookup.CHOICES, lookup.DESCRIPTION, lookup.DEFAULT]:
        if regexp.search(six.text_type(flag.get(sub_attribute, ''))):
          return DOT.join([lookup.FLAGS, flag[lookup.NAME], sub_attribute])
  
    # Look in positionals
    for positional in command[lookup.POSITIONALS]:
      if regexp.search(positional[lookup.DESCRIPTION]):
        return DOT.join([lookup.POSITIONALS, positional[lookup.NAME],
                         positional[lookup.DESCRIPTION]])
  
    # Look in subcommands & path
    if regexp.search(
        six.text_type([n for n, c in six.iteritems(command[lookup.COMMANDS])
                       if not c[lookup.IS_HIDDEN]])):
      return lookup.COMMANDS
  
    return ''
  
  
  def SummaryTransform(r):
    """A resource transform function to summarize a command search result.
  
    Uses the "results" attribute of the command to build a summary that includes
    snippets of the help text of the command that include the searched terms.
    Occurrences of the search term will be stylized.
  
    Args:
      r: a json representation of a command.
  
    Returns:
      str, a summary of the command.
    """
    summary = GetSummary(r, r[lookup.RESULTS])
    md = io.StringIO(summary)
    rendered_summary = io.StringIO()
    # Render summary as markdown, ignoring console width.
    render_document.RenderDocument('text',
                                   md,
                                   out=rendered_summary,
                                   # Increase length in case of indentation.
                                   width=len(summary) * 2)
    final_summary = '\n'.join(
        [l.lstrip() for l in rendered_summary.getvalue().splitlines()
         if l.lstrip()])
    return final_summary
  
  
  def PathTransform(r):
    """A resource transform to get the command path with search terms stylized.
  
    Uses the "results" attribute of the command to determine which terms to
    stylize and the "path" attribute of the command to get the command path.
  
    Args:
      r: a json representation of a command.
  
    Returns:
      str, the path of the command with search terms stylized.
    """
    results = r[lookup.RESULTS]
    path = ' '.join(r[lookup.PATH])
    return Highlight(path, results.keys())
  
  
  class CommandSearchResults(object):
    """Class to hold the results of a search."""
  
    def __init__(self, results_data):
      """Create a CommandSearchResults object.
  
      Args:
        results_data: {str: str}, a dictionary from terms to the locations where
          they were found. Empty string values in the dict represent terms that
          were searched but not found. Locations should be formatted as
          dot-separated strings representing the location in the command (as
          created by LocateTerms above).
      """
      self._results_data = results_data
  
    def AllTerms(self):
      """Gets a list of all terms that were searched."""
      return self._results_data.keys()
  
    def FoundTermsMap(self):
      """Gets a map from all terms that were found to their locations."""
      return {k: v for (k, v) in six.iteritems(self._results_data) if v}
  
  
  _TRANSFORMS = {
      'summary': SummaryTransform,
      'commandpath': PathTransform
  }
  
  
  def GetTransforms():
    return _TRANSFORMS
  
  