#!/usr/bin/python3
# SPDX-License-Identifier: MIT
# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
#
# Gather changelog entries for a new upstream release.
#
# The source package is assembled from two upstream components (see
# debian/watch): the hexagon-dsp-binaries release tarball and the 'firmware'
# component, which is a filtered copy of linux-firmware.  This script walks
# the git history of both upstream repositories and turns the commits which
# are relevant for this package into debian/changelog entries.

import importlib.util
import os
import re
import subprocess
import sys
import textwrap

CHANGELOG = 'debian/changelog'
COPYRIGHT = 'debian/copyright'

# Heading of the section listing hexagon-dsp-binaries changes.  An item which
# is already there (possibly reworded, e.g. 'New upstream release, skipping
# 20260110.') is located by the prefix and kept.
UPSTREAM_INTRO_PREFIX = 'New upstream release'
UPSTREAM_INTRO = UPSTREAM_INTRO_PREFIX + ':'

# Heading of the section listing linux-firmware changes.  The version is part
# of the heading, so existing headings are located by their prefix.
FIRMWARE_INTRO_PREFIX = 'New linux-firmware release'
FIRMWARE_INTRO = FIRMWARE_INTRO_PREFIX + ' {}:'

# Paths in the hexagon-dsp-binaries repository which are not part of the
# release tarball (see scripts/dist.sh there), so changes to them are of no
# interest for the package.
UPSTREAM_EXCLUDE = [
    '.github',
    '.gitignore',
    'README.md',
]

# WHENCE is listed in Files-Included-firmware, but linux-firmware touches it
# for every single firmware file it carries.  Commits to it are therefore not
# taken from the file list; they are picked by filtering WHENCE the way
# debian/rules does before building the packages, and keeping the commits
# which change the result.  That covers the entries of the files we ship,
# the symlinks (Link:) pointing at them and, most importantly, the licence
# they are placed under.
FIRMWARE_WHENCE = 'WHENCE'
WHENCE_FILTER = 'filter-firmware-whence.py'

# Subject prefixes which carry no information in our changelog
STRIP_SUBJECT_RE = re.compile(r'^linux-firmware: *')

# Header line of a changelog entry
CHANGELOG_HEADER_RE = re.compile(
    r'^(\S+) \((\S+)\) (\S+); urgency=(\S+)\s*$')


def git(repo, *args, check=True):
    """Run git in REPO and return its output."""
    proc = subprocess.run(['git', '-C', repo, *args],
                          stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                          text=True)
    if check and proc.returncode != 0:
        sys.exit(f'{repo}: git {" ".join(args)} failed')
    return proc.stdout if proc.returncode == 0 else None


def git_lines(repo, *args):
    """Run git in REPO and return its output as a list of lines."""
    return git(repo, *args).splitlines()


def check_repo(repo, *revs):
    """Check that REPO is a git repository holding all of REVS."""
    if not os.path.isdir(repo):
        sys.exit(f'{repo}: no such directory')
    for rev in revs:
        if subprocess.run(['git', '-C', repo, 'rev-parse', '--verify',
                           '--quiet', rev + '^{commit}'],
                          stdout=subprocess.DEVNULL,
                          stderr=subprocess.DEVNULL).returncode != 0:
            sys.exit(f'{repo}: no such tag or commit: {rev}')


def firmware_includes(copyright_file):
    """Read Files-Included-firmware from debian/copyright.

    These are the patterns uscan uses to build the firmware component
    tarball, so they describe exactly the part of linux-firmware which ends
    up in the source package.
    """
    patterns = []
    field = None
    with open(copyright_file, encoding='utf-8') as f:
        for line in f:
            if line.strip() == '':
                # Only the first (header) paragraph is of interest
                if patterns:
                    break
                field = None
            elif line[0] not in ' \t':
                field = line.split(':', 1)[0]
                value = line.split(':', 1)[1] if ':' in line else ''
                if field == 'Files-Included-firmware':
                    patterns.extend(value.split())
            elif field == 'Files-Included-firmware':
                patterns.extend(line.split())

    if not patterns:
        sys.exit(f'{copyright_file}: no Files-Included-firmware field')

    return patterns


def load_whence_filter():
    """Load filter_whence() from debian/filter-firmware-whence.py.

    The very filter debian/rules applies to the WHENCE of the firmware
    component, so that this script looks at exactly what gets shipped.
    """
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                        WHENCE_FILTER)
    spec = importlib.util.spec_from_file_location('filter_firmware_whence',
                                                  path)
    # Don't drop a __pycache__ into the packaging directory
    sys.dont_write_bytecode = True
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module.filter_whence


def format_subjects(lines):
    """Normalise, sort and de-duplicate a list of commit subjects."""
    subjects = [STRIP_SUBJECT_RE.sub('', line).strip()
                for line in lines if line.strip()]
    return sorted(set(subjects), key=str.casefold)


def upstream_log(repo, cur_ver, new_ver):
    """List hexagon-dsp-binaries changes between the two releases."""
    check_repo(repo, cur_ver, new_ver)
    return format_subjects(
        git_lines(repo, 'log', '--no-merges', '--pretty=%s',
                  f'{cur_ver}..{new_ver}', '--',
                  *(f':(exclude){path}' for path in UPSTREAM_EXCLUDE)))


def whence_log(repo, rev_range):
    """List linux-firmware changes to the WHENCE entries we ship.

    linux-firmware rewrites WHENCE for every firmware file it carries, so
    the commits touching it are of interest only when they change the part
    of it which ends up in our packages: the entries of the files we ship,
    the symlinks pointing at them, their versions and the licence they are
    placed under.  Those entries are what debian/copyright has to follow, so
    they are worth a changelog item even when no file changes.
    """
    filter_whence = load_whence_filter()
    filtered = {}

    def filtered_whence(rev):
        if rev not in filtered:
            whence = git(repo, 'show', f'{rev}:{FIRMWARE_WHENCE}', check=False)
            filtered[rev] = None if whence is None else \
                ''.join(filter_whence(whence.splitlines(keepends=True)))
        return filtered[rev]

    lines = []
    for line in git_lines(repo, 'log', '--no-merges', '--pretty=%H %s',
                          rev_range, '--', FIRMWARE_WHENCE):
        rev, _, subject = line.partition(' ')
        if filtered_whence(rev) != filtered_whence(rev + '^'):
            lines.append(subject)

    return lines


def firmware_log(repo, cur_ver, new_ver, patterns):
    """List linux-firmware changes affecting the files we ship."""
    check_repo(repo, cur_ver, new_ver)
    rev_range = f'{cur_ver}..{new_ver}'

    # Commits touching the files included in the firmware component.  The
    # 'glob' pathspec magic makes '**/' match any number of directories,
    # including none, just like the copyright-format globs do.
    pathspecs = [f':(glob){pattern}' for pattern in patterns
                 if pattern != FIRMWARE_WHENCE]
    lines = git_lines(repo, 'log', '--no-merges', '--pretty=%s', rev_range,
                      '--', *pathspecs)

    lines += whence_log(repo, rev_range)

    return format_subjects(lines)


def format_section(intro, subjects):
    lines = [line + '\n'
             for line in textwrap.wrap(intro, width=79,
                                       initial_indent='  * ',
                                       subsequent_indent='    ',
                                       break_long_words=False,
                                       break_on_hyphens=False)]
    for subject in subjects:
        lines += [line + '\n'
                  for line in textwrap.wrap(subject, width=79,
                                            initial_indent='    - ',
                                            subsequent_indent='      ',
                                            break_long_words=False,
                                            break_on_hyphens=False)]
    return lines


def parse_section(lines):
    """Turn the items of a section back into a list of subjects.

    Returns the subjects and the number of lines they occupy, so that long
    items wrapped over several lines are handled as one.
    """
    subjects = []
    for count, line in enumerate(lines):
        if line.startswith('    - '):
            subjects.append(line[len('    - '):].strip())
        elif subjects and line.startswith('      ') and line.strip():
            subjects[-1] += ' ' + line.strip()
        else:
            return subjects, count
    return subjects, len(lines)


def parse_changelog(lines):
    """Split the topmost changelog entry into header, body and the rest."""
    match = CHANGELOG_HEADER_RE.match(lines[0]) if lines else None
    if not match:
        sys.exit('debian/changelog: cannot parse the topmost entry')

    for end, line in enumerate(lines):
        if line.startswith(' -- '):
            break
    else:
        sys.exit('debian/changelog: unterminated topmost entry')

    return match.groups(), lines[1:end], lines[end:]


def uploaded_version(lines, default):
    """Version of the topmost entry which has been uploaded already.

    The entry being worked on collects everything since that version, so it
    is where the changelog of a new release starts.
    """
    for line in lines:
        match = CHANGELOG_HEADER_RE.match(line)
        if match and match.group(3) != 'UNRELEASED':
            return match.group(2)
    return default


def entry_start(body):
    """Index of the first item of a changelog entry."""
    return 1 if body and body[0].strip() == '' else 0


def merge_section(body, intro, prefix, subjects, pos, keep_intro=False):
    """Insert (or refresh) a section of the changelog entry.

    A section which is already there (because the script has been run for a
    yet unreleased version before, or because the item has been added by
    hand) is extended instead of duplicated.  POS is where a new section is
    added; the index just past the section is returned, so that the sections
    keep the order they are given in.  KEEP_INTRO preserves the wording of an
    item which is already there.
    """
    for start, line in enumerate(body):
        if line.startswith(f'  * {prefix}'):
            break
    else:
        section = format_section(intro, subjects)
        body[pos:pos] = section
        return pos + len(section)

    # Skip over the continuation lines of the item itself
    end = start + 1
    heading = body[start][len('  * '):].strip()
    while end < len(body) and body[end].startswith('    ') \
            and not body[end].startswith('    - '):
        heading += ' ' + body[end].strip()
        end += 1

    if keep_intro:
        intro = heading.rstrip('.')
        if not intro.endswith(':'):
            intro += ':'

    old, count = parse_section(body[end:])
    section = format_section(intro, format_subjects(old + subjects))
    body[start:end + count] = section
    return start + len(section)


def main(repo, firmware_repo, version):
    if not os.path.exists(CHANGELOG):
        sys.exit(f'{CHANGELOG}: no such file, run this script from the top'
                 ' of the source package')

    # Both upstreams release under the same YYYYMMDD tag
    new_ver = version.removeprefix('0~')

    lines = open(CHANGELOG, encoding='utf-8').readlines()
    source, top_pkg_ver, distribution, urgency = parse_changelog(lines)[0]

    # An entry which has not been uploaded yet describes the changes since
    # the last upload, no matter how often its version has been bumped in
    # the meantime.
    cur_pkg_ver = uploaded_version(lines, top_pkg_ver)
    cur_ver = cur_pkg_ver.rsplit('-', 1)[0].removeprefix('0~')

    if cur_ver == new_ver:
        print(f'Nothing to update, already at {new_ver}', file=sys.stderr)
        return

    upstream = upstream_log(repo, cur_ver, new_ver)
    firmware = firmware_log(firmware_repo, cur_ver, new_ver,
                            firmware_includes(COPYRIGHT))

    if not upstream and not firmware:
        print(f'Nothing to update ({cur_ver} -> {new_ver})', file=sys.stderr)
        return

    sections = []
    if upstream:
        sections.append((UPSTREAM_INTRO, UPSTREAM_INTRO_PREFIX, upstream,
                         True))
    if firmware:
        # The heading carries the version, so it is always regenerated
        sections.append((FIRMWARE_INTRO.format(new_ver),
                         FIRMWARE_INTRO_PREFIX, firmware, False))

    new_pkg_ver = f'0~{new_ver}-1'

    if distribution != 'UNRELEASED':
        # The current version has been released, so start a new entry.  dch
        # takes care of the maintainer name and the timestamp; the item it
        # inserts is only a placeholder for our sections.
        placeholder = 'RELEASE-UPDATE-PLACEHOLDER'
        subprocess.check_call(['dch', '-v', new_pkg_ver, '-D', 'UNRELEASED',
                               '--', placeholder])
        header, body, tail = \
            parse_changelog(open(CHANGELOG, encoding='utf-8')
                            .readlines())
        body = [line for line in body if placeholder not in line]
        header_line = (f'{header[0]} ({header[1]}) {header[2]};'
                       f' urgency={header[3]}\n')
    else:
        # The current version has not been released yet, so only its version
        # string changes.
        header, body, tail = \
            parse_changelog(open(CHANGELOG, encoding='utf-8')
                            .readlines())
        header_line = (f'{source} ({new_pkg_ver}) {distribution};'
                       f' urgency={urgency}\n')

    pos = entry_start(body)
    for intro, prefix, subjects, keep_intro in sections:
        pos = merge_section(body, intro, prefix, subjects, pos, keep_intro)

    with open(CHANGELOG + '.new', 'w', encoding='utf-8') as new_log:
        new_log.write(header_line)
        new_log.writelines(body)
        new_log.writelines(tail)

    os.rename(CHANGELOG + '.new', CHANGELOG)


if __name__ == '__main__':
    if len(sys.argv) != 4:
        print('''\
Usage: {} REPO FIRMWARE_REPO VERSION
REPO is the hexagon-dsp-binaries git repository
FIRMWARE_REPO is the linux-firmware git repository
VERSION is the upstream version, which both upstreams release under'''
              .format(sys.argv[0]),
              file=sys.stderr)
        sys.exit(2)
    main(*sys.argv[1:])
