#!/usr/bin/env python

from __future__ import print_function
import os
import sys
import glob
import datetime
import itertools
import subprocess


# {{{ Redefinitions

# Ensure certain order for known sections.
SECTIONS_TOP = ['core', 'memtx', 'vinyl', 'replication', 'swim',
                'raft', 'luajit', 'lua', 'sql']
SECTIONS_BOTTOM = ['build', 'misc']

# Prettify headers, apply the ordering.
REDEFINITIONS = {
    (2, 'feature'): {
        'header': 'Functionality added or changed',
        'subsections_top': SECTIONS_TOP,
        'subsections_bottom': SECTIONS_BOTTOM,
    },
    (2, 'bugfix'): {
        'header': 'Bugs fixed',
        'subsections_top': SECTIONS_TOP,
        'subsections_bottom': SECTIONS_BOTTOM,
    },
    (3, 'luajit'): {
        'header': 'LuaJIT',
    },
    (3, 'sql'): {
        'header': 'SQL',
    },
    (3, 'http client'): {
        'header': 'HTTP client',
    },

}

# }}} Redefinitions


# {{{ Templates

FEATURES_ANCHOR = '{{FEATURES}}'
BUGFIXES_ANCHOR = '{{BUGFIXES}}'

OVERVIEW_TEMPLATE = """
## Overview

// {{{ 3.x

Tarantool 3.x is the recommended release series. Users of Tarantool 2.11 are
encouraged to update to the latest 3.x release.

This release introduces {{FEATURES}} improvements and resolves {{BUGFIXES}}
bugs since **TBD**.

Notable changes are:

* **TBD**
* **TBD**
* **TBD**

// }}} 3.x

// {{{ 2.11

2.x is the old stable release series. Users are encouraged to update to the
latest 3.x release.

This is a bugfix release. It resolves {{BUGFIXES}} issues since the previous
version.

// }}} 2.11

Please consider the full list of user-visible changes below.
""".strip()  # noqa: E501 line too long

COMPATIBILITY_TEMPLATE = """
## Compatibility

Tarantool 2.x and 3.x are compatible in the binary data layout, client-server
protocol, and replication protocol. It means upgrade may be performed with zero
downtime for read requests and the order-of-network-lag downtime for write
requests.

Please follow the [upgrade procedure][upgrade] to plan your update actions.

// {{{ 3.x

Users of Tarantool 2.x may be interested in the [compat][compat] options that
allow to imitate some 2.x behavior. This allows to perform application code
update step-by-step, not all-at-once.

[compat]: https://www.tarantool.io/en/doc/latest/reference/configuration/configuration_reference/#compat

// }}} 3.x

[upgrade]: https://www.tarantool.io/en/doc/latest/book/admin/upgrades/
""".strip()  # noqa: E501 line too long

ENTERPRISE_TEMPLATE = """
// {{{ ENTERPRISE EDITION ONLY

## Community edition

Please consult the corresponding [page][ce-release] for details.

[ce-release]: https://github.com/tarantool/tarantool/releases/tag/**TBD**

// }}} ENTERPRISE EDITION ONLY
""".strip()  # noqa: E501 line too long

# }}} Templates


# {{{ Helpers

def popen(cmdline):
    """ Wrapper around Popen.subprocess() that redirects the output to a pipe,
        correctly handles encoding and raises a RuntimeError if the executable
        was not found. Works on both Python 2 and 3.
    """
    popen_kwargs = {
        'stdout': subprocess.PIPE,
    }
    if sys.version_info[0] == 3:
        popen_kwargs['encoding'] = 'utf-8'

    if sys.version_info[0] == 2:
        global FileNotFoundError
        FileNotFoundError = OSError

    try:
        return subprocess.Popen(cmdline, **popen_kwargs)
    except FileNotFoundError as e:
        raise RuntimeError("Unable to find '{}' executable: {}".format(
            cmdline[0], str(e)))


# }}} Helpers


# {{{ Collecting

def changelog_entries_sorted(entries_dir):
    """ Acquire order of appearance from ```git log``.

        Misses uncommitted entries and contains files that were
        deleted.
    """
    processed = set()
    res = []
    process = popen(['git', '-C', entries_dir, 'log', '--reverse', '--pretty=',
                     '--name-only', '--relative'])
    for line in process.stdout:
        entry_file = line.rstrip()
        if entry_file not in processed and entry_file.endswith('.md'):
            processed.add(entry_file)
            res.append(os.path.join(entries_dir, entry_file))
    process.wait()
    return res


def changelog_entries(entries_dir):
    """ Return a list of changelog entry files in the given directory.

    Sort the entries according to ``git log`` (by a time of the
    first appearance) and append ones out of the git tracking
    afterwards with the alphabetical order.
    """
    if not os.path.exists(entries_dir):
        raise RuntimeError('The changelog entries directory {} does not '
                           'exist'.format(entries_dir))
    if not os.path.isdir(entries_dir):
        raise RuntimeError('The path {} that is expected to be the changelog '
                           'entries directory is not a directory'.format(
                            entries_dir))

    # Uncommitted entries are not present here. Deleted entries
    # are present.
    entries_sorted = changelog_entries_sorted(entries_dir)
    entries_known = set(entries_sorted)

    res = []

    # Add entries according to 'git log' order first.
    for entry in entries_sorted:
        if os.path.exists(entry):
            res.append(entry)

    # Add the rest in the alphabetical order.
    entries_glob = os.path.join(entries_dir, '*.md')
    entries = sorted(glob.glob(entries_glob))
    # NB: It is perfectly okay to have no entries at all: say, we
    # just start to work on a future release.
    for entry in entries:
        if entry not in entries_known:
            res.append(entry)

    return res

# }}} Collecting


# {{{ Parsing

class ParserError(RuntimeError):
    pass


class Parser:
    """ Parse a changelog entry file.

    Usage:

        parser = Parser()
        try:
            parser.parse(fh)
        except parser.error:
            pass  # Handle error.
        print(parser.header)
        print(parser.content)
        if parser.comment:
            print(parser.comment)

    ``parser.content`` and ``parser.comment`` are free form texts,
    so it may contain newlines and always end with a newline.
    """
    EOF = None
    EOS = '----\n'
    error = ParserError

    def __init__(self):
        self._process = self._wait_header

        # Parsed data.
        self.header = None
        self.content = None
        self.comment = None

    def parse(self, fh):
        for line in fh:
            self._process(line)
        self._process(self.EOF)

    def _wait_header(self, line):
        if line is self.EOF:
            raise self.error(
                'Unexpected end of file: no header')

        if line.startswith('## '):
            header = line.split(' ', 1)[1].strip()
            fst = header.split('/', 1)[0]
            if fst not in ('feature', 'bugfix', 'tools', 'testing'):
                raise self.error("Unknown header: '{}', should be "
                                 "'feature/<...>' or 'bugfix/<...>' or"
                                 "'tools/<...>' or 'testing/<...>'"
                                 .format(header))
            self.header = header
            self._process = self._wait_content
            return

        raise self.error(
            "The first line should be a header, got '{}'".format(line.strip()))

    def _wait_content(self, line):
        if line is self.EOF:
            if not self.content:
                raise self.error('Unexpected end of file (empty content)')
            self.content = self.content.strip() + '\n'
            return

        if line == self.EOS:
            self._process = self._wait_comment
            return

        if self.content is None:
            self.content = line
        else:
            self.content += line

    def _wait_comment(self, line):
        if line is self.EOF:
            if not self.comment:
                raise self.error('Unexpected end of file (empty comment)')
            self.comment = self.comment.strip() + '\n'
            return

        if self.comment is None:
            self.comment = line
        else:
            self.comment += line


def parse_changelog_entries(changelog_entries):
    """ Parse and structurize changelog entries content.
    """
    # 'feature' and 'bugfix' sections are always present.
    entry_section = {
        'header': 'main',
        'level': 1,
        'entries': [],
        'subsections': {
            'feature': {
                'header': 'feature',
                'level': 2,
                'entries': [],
                'subsections': dict(),
                'nested_entry_count': 0,
            },
            'bugfix': {
                'header': 'bugfix',
                'level': 2,
                'entries': [],
                'subsections': dict(),
                'nested_entry_count': 0,
            },
        },
        'nested_entry_count': 0,
    }
    comments = []

    for entry_file in changelog_entries:
        parser = Parser()
        try:
            with open(entry_file, 'r') as fh:
                parser.parse(fh)
        except parser.error as e:
            raise RuntimeError(
                'Unable to parse {}: {}'.format(entry_file, str(e)))

        section = entry_section

        # Nested slash delimited subcategories.
        for section_header in parser.header.split('/'):
            # Group entries by a section header (case
            # insensitively). Save first header variant as is (not
            # lowercased) into the 'header' field to prettify in
            # ``print_section()`` later.
            section_key = section_header.lower()
            if section_key not in section['subsections']:
                section['subsections'][section_key] = {
                    'header': section_header,
                    'level': section['level'] + 1,
                    'entries': [],
                    'subsections': dict(),
                    'nested_entry_count': 0,
                }
            section = section['subsections'][section_key]
            section['nested_entry_count'] += 1

        section['entries'].append(parser.content)

        if parser.comment:
            comments.append(parser.comment)

    return (entry_section, comments)

# }}} Parsing


# {{{ Printing

is_block_first = True


def print_block(block):
    """ Print a Markdown block.

    Separate it from the previous one with an empty line.

    Add a newline at the end if it is not here.
    """
    global is_block_first

    if is_block_first:
        is_block_first = False
    else:
        print('\n', end='')

    if isinstance(block, (list, tuple)):
        block = ''.join(block)

    if not block.endswith('\n'):
        block += '\n'

    print(block, end='')


def print_section(section, redefinitions):
    """ Print a Markdown section (header, content, subsections).

    Order of the content entries is kept (as defined by
    ``changelog_entries()``).

    Order of subsection is alphabetical when is not predefined (as
    for typical third level sections, see
    ``parse_changelog_entries()``).
    """
    level = section['level']
    header = section['header']

    # Prettify the header if it is in the lower case.
    redefinition = redefinitions.get((level, header))
    if redefinition:
        section.update(redefinition)
    elif header.islower():
        section['header'] = header.capitalize()

    level = section['level']
    header = section['header']

    print_block('{} {}'.format('#' * level, header))
    if section['entries']:
        print_block(section['entries'])

    # Keep defined ordering for predefined top and bottom
    # subsections, but ensure the alphabetical order for others.
    subsections = section['subsections']
    subheaders_top = section.get('subsections_top', [])
    subheaders_bottom = section.get('subsections_bottom', [])
    subheaders_known = set(subheaders_top) | set(subheaders_bottom)
    subheaders_middle = sorted(set(subsections.keys()) - subheaders_known)

    it = itertools.chain(subheaders_top, subheaders_middle, subheaders_bottom)
    for subheader in it:
        if subheader in subsections:
            print_section(subsections[subheader], redefinitions)

    if not section['entries'] and not section['subsections']:
        print_block('*No changes.*')


def print_templates(feature_count, bugfix_count):
    for template in (OVERVIEW_TEMPLATE,
                     COMPATIBILITY_TEMPLATE,
                     ENTERPRISE_TEMPLATE):
        block = template                                  \
            .replace(FEATURES_ANCHOR, str(feature_count)) \
            .replace(BUGFIXES_ANCHOR, str(bugfix_count))
        print_block(block)


def print_release_notes(entry_section, comments, redefinitions):
    print_block('# **TBD**')
    print_block((
        'Date: {}\n'.format(datetime.date.today().isoformat()),
        'Tag: **TBD**\n',
    ))

    if comments:
        print_block('## Comments for a release manager')
        print_block("**TBD:** Don't forget to remove this section before "
                    "publishing.")
        print_block(comments)

    features = entry_section['subsections']['feature']
    bugfixes = entry_section['subsections']['bugfix']
    feature_count = features['nested_entry_count']
    bugfix_count = bugfixes['nested_entry_count']
    print_templates(feature_count, bugfix_count)

    # Keep features first, bugfixes next. Print other sections
    # in the alphabetical order afterwards.
    headers = sorted(entry_section['subsections'].keys())
    headers_head = ['feature', 'bugfix']
    headers_tail = sorted(set(headers) - set(headers_head))
    for header in itertools.chain(headers_head, headers_tail):
        section = entry_section['subsections'][header]
        print_section(section, redefinitions)

# }}} Printing


if __name__ == '__main__':
    try:
        # Be immutable to a caller current working directory.
        # Sic: Use abspath rather than realpath because the script
        # may be called via a symlink from another repository.
        script_file = os.path.abspath(__file__)
        script_dir = os.path.dirname(script_file)
        source_dir = os.path.dirname(script_dir)
        entries_dir = os.path.join(source_dir, 'changelogs', 'unreleased')
        entries = changelog_entries(entries_dir)
        entry_section, comments = parse_changelog_entries(entries)
        print_release_notes(entry_section, comments, REDEFINITIONS)
    except RuntimeError as e:
        print(str(e))
        exit(1)
