# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import collections
import logging
import re
import shutil
import shlex
import subprocess

# CSV-file like separators. The templating language doesn't support escaping,
# so we use
# https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Field_separators
_NEWLINE = '\x1c'
_COMMA = '\x1d'
_LIST = '\x1e'

_TRAILER = re.compile(r'([a-zA-Z0-9\-_]+): (.*)')
_CHANGE_PRETTY = '''change_id.short() ++
if(current_working_copy, " (@)") ++
" " ++
description.first_line()
'''


def list_template(expr: str) -> str:
  """Wraps a list-valued expression so `jj_log` returns it as list[str].

  Use this for any template which produces a list (eg. parents, bookmarks).
  """
  # List items are separated with _LIST. We also prefix the list with _LIST as a
  # sentinel so that empty and single-item lists are also detected as lists.
  return f'"{_LIST}" ++ ({expr}).join("{_LIST}")'


MUTABLE_PARENTS = list_template(
  'parents.filter(|c| !c.immutable()).map(|p| p.change_id())'
)

IMMUTABLE_PARENTS = list_template(
  'parents.filter(|p| p.immutable()).map(|p| p.commit_id())'
)


def run_command(
  args: list[str], check=True, **kwargs
) -> subprocess.CompletedProcess:
  # Resolve the binary path, or in the case of Windows, the batch script path,
  # including the .bat extension.
  resolved = shutil.which(args[0])
  if not resolved:
    logging.error('Could not find %s on PATH', args[0])
    exit(1)
  args = [resolved] + args[1:]
  logging.debug('Running command %s', shlex.join(map(str, args)))
  ps = subprocess.run(args, **kwargs, check=False)
  if check and ps.returncode:
    # Don't create a stack trace.
    exit(ps.returncode)
  return ps


def run_jj(
  args: list[str], ignore_working_copy=False, **kwargs
) -> subprocess.CompletedProcess:
  prefix = ['jj', '--no-pager']
  if ignore_working_copy:
    prefix.append('--ignore-working-copy')
  return run_command(prefix + args, **kwargs)


def _log(
  args: list[str], templates: dict[str, str], **kwargs
) -> list[dict[str, str]]:
  """Log acts akin to a database query on a table.

  The user will provide templates such as {
    'change_id': 'change_id',
    'parents': list_template('parents.map(|c| c.change_id())'),
  }

  And a set of revisions to lookup (eg. 'a|b').

  And it would then return [
     {'change_id': 'a', 'parents': ['<parent of a's id>', ..]}
     {'change_id': 'b', 'parents': ['<parent of b's id>', ..]}
  ]
  """
  templates.setdefault('name', _CHANGE_PRETTY)
  # Start by assigning indexes based on the field name.
  fields, templates = zip(*sorted(templates.items()))
  # We're just creating a jj template that outputs CSV files.
  template = f' ++ "{_COMMA}" ++ '.join(templates)
  template += f' ++ "{_NEWLINE}"'

  stdout = run_jj(
    [*args, '--no-graph', '-T', template],
    stdout=subprocess.PIPE,
    text=True,
    **kwargs,
  ).stdout

  def _parse_field(value: str):
    # Fields starting with _LIST are list-valued and split into list[str].
    if value.startswith(_LIST):
      rest = value[len(_LIST) :]
      return rest.split(_LIST) if rest else []

    # Everything else is a str.
    return value

  # Now we parse our CSV file.
  return [
    {
      field: _parse_field(value)
      for field, value in zip(fields, change.split(_COMMA))
    }
    for change in stdout.rstrip(_NEWLINE).split(_NEWLINE)
  ]


def jj_log(
  *, templates: dict[str, str], revisions='@', **kwargs
) -> list[dict[str, str]]:
  """Retrieves information about jj revisions.

  See _log for details."""
  return _log(['log', '-r', revisions], templates, **kwargs)


def split_description(description: str) -> tuple[str, dict[str, list[str]]]:
  """Splits a description into the description and git trailers."""
  trailers = collections.defaultdict(list)
  user_desc, sep, trailer_paragraph = description.rstrip().rpartition('\n\n')
  # If the description has only a single paragraph, we should interpret it as
  # a user description (eg. "WIP: blah" is not a trailer).
  if not user_desc and not sep:
    return trailer_paragraph, {}

  trailer_lines = trailer_paragraph.lstrip().split('\n')
  # Note: for multiline values, we only retrieve the first line here.
  for line in trailer_lines:
    match = _TRAILER.match(line)
    if match is not None:
      trailers[match.group(1)].append(match.group(2))
  if not trailers:
    return description, {}
  return user_desc, trailers


def join_revsets(revs: list[str]):
  if len(revs) == 1:
    return revs[0]
  else:
    return ' | '.join(f'({r})' for r in revs)


def add_trailers(
  rev: dict[str, str], trailers: dict[str, list[str]], commit: bool
) -> str:
  old_desc = rev['desc'].rstrip()
  old_trailers = rev['trailers'].rstrip()
  assert old_desc.endswith(old_trailers)
  old_desc = old_desc.removesuffix(old_trailers).rstrip()
  if not old_desc:
    # We need a non-empty first line in order for it to be treated as a trailer.
    old_desc = 'TODO: add description'

  if old_trailers:
    lines = [old_desc, '', old_trailers]
  else:
    lines = [old_desc, '']
  for k, vs in trailers.items():
    for v in vs:
      lines.append(f'{k}: {v}')
  new_desc = '\n'.join(lines)

  if commit:
    run_jj(
      ['describe', '-m', new_desc, '-r', rev['change_id']],
      ignore_working_copy=True,
    )
  return new_desc


def percent_encode_for_git_ref(original: str) -> str:
  """Pulled from the PercentEncodeForGitRef function in gerrit_util.py.

  Applies percent-encoding for strings sent to Gerrit via git ref metadata.

  The encoding used is based on but stricter than URL encoding (Section 2.1 of
  RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
  (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).

  For more information, see the Gerrit docs here:

  https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
  """
  safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
  encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)

  # Spaces are not allowed in git refs; gerrit will interpret either '_' or
  # '+' (or '%20') as space. Use '_' since that has been supported the longest.
  return encoded.replace(' ', '_')
