#!/usr/bin/env python3

from __future__ import annotations

import argparse
import dataclasses
import re
import sys
import time
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from collections.abc import Iterator, Sequence

script_dir = Path(__file__).parent
package_dir = script_dir.parent


@dataclasses.dataclass(frozen=True, slots=True)
class VersionInfo:
    major: int
    minor: int
    micro: int
    level: Literal['a', 'b', 'rc', 'final']
    serial: int

    @property
    def releaselevel(self) -> Literal['alpha', 'beta', 'candidate', 'final']:
        if self.level == 'final':
            return 'final'
        if self.level == 'a':
            return 'alpha'
        if self.level == 'b':
            return 'beta'
        if self.level == 'rc':
            return 'candidate'
        msg = f'Unknown release level: {self.level}'
        raise RuntimeError(msg)

    @property
    def is_final(self) -> bool:
        return self.level == 'final'

    @property
    def version(self) -> str:
        return f'{self.major}.{self.minor}.{self.micro}'

    @property
    def release(self) -> str:
        return f'{self.major}.{self.minor}.{self.micro}{self.level}{self.serial}'

    @property
    def version_tuple(self) -> tuple[int, int, int]:
        return self.major, self.minor, self.micro

    @property
    def release_tuple(self) -> tuple[int, int, int, str, int]:
        return self.major, self.minor, self.micro, self.releaselevel, self.serial


def parse_version(version: str) -> VersionInfo:
    # Final version:
    # - "X.Y.Z" -> (X, Y, Z, 'final', 0)
    # - "X.Y" -> (X, Y, 0, 'final', 0) [shortcut]
    if matched := re.fullmatch(r'(\d+)\.(\d+)(?:\.(\d+))?', version):
        major, minor, micro = matched.groups(default='0')
        return VersionInfo(int(major), int(minor), int(micro), 'final', 0)

    # Pre-release versions:
    # - "X.Y.ZaN" -> (X, Y, Z, 'alpha', N)
    # - "X.Y.ZbN" -> (X, Y, Z, 'beta', N)
    # - "X.Y.ZrcN" -> (X, Y, Z, 'candidate', N)
    if matched := re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(a|b|rc)(\d+)', version):
        major, minor, micro, level, serial = matched.groups()
        return VersionInfo(int(major), int(minor), int(micro), level, int(serial))  # type: ignore[arg-type]

    msg = f'Unknown version: {version}'
    raise RuntimeError(msg)


def bump_version(
    path: Path, version_info: VersionInfo, in_develop: bool = True
) -> None:
    if in_develop or version_info.is_final:
        version = version_info.version
    else:
        version = version_info.release

    with open(path, encoding='utf-8') as f:
        lines = f.read().splitlines(keepends=True)

    for i, line in enumerate(lines):
        if line.startswith('__version__ = '):
            lines[i] = f"__version__ = '{version}'\n"
            continue
        if line.startswith('version_info = '):
            lines[i] = f'version_info = {version_info.release_tuple}\n'
            continue
        if line.startswith('_in_development = '):
            lines[i] = f'_in_development = {in_develop}\n'
            continue

    with open(path, 'w', encoding='utf-8') as f:
        f.writelines(lines)


class Changes:
    def __init__(self, path: Path) -> None:
        self.path = path

        with open(self.path, encoding='utf-8') as f:
            version = f.readline().strip()
            matched = re.fullmatch(r'Release (.*) \((.*)\)', version)
            if matched is None:
                msg = f'Unknown CHANGES format: {version}'
                raise RuntimeError(msg)

            self.version, release_date = matched.groups()
            self.in_development = release_date == 'in development'
            self.version_tuple = parse_version(self.version).version_tuple

    def finalise_release_date(self) -> None:
        release_date = time.strftime('%b %d, %Y')
        heading = f'Release {self.version} (released {release_date})'
        with open(self.path, 'r+', encoding='utf-8') as f:
            f.readline()  # skip first two lines
            f.readline()
            body = f.read()

            f.seek(0)
            f.truncate(0)
            f.write(heading + '\n')
            f.write('=' * len(heading) + '\n')
            f.write(self.filter_empty_sections(body))

    def add_release(self, version_info: VersionInfo) -> None:
        heading = f'Release {version_info.version} (in development)'
        tmpl = (script_dir / 'CHANGES_template.rst').read_text(encoding='utf-8')
        with open(self.path, 'r+', encoding='utf-8') as f:
            body = f.read()

            f.seek(0)
            f.truncate(0)
            f.write(heading + '\n')
            f.write('=' * len(heading) + '\n')
            f.write('\n')
            f.write(tmpl)
            f.write('\n')
            f.write(body)

    @staticmethod
    def filter_empty_sections(body: str) -> str:
        return re.sub(
            '^\n.+\n-{3,}\n+(?=\n.+\n[-=]{3,}\n)', '', body, flags=re.MULTILINE
        )


class Skip(Exception):
    pass


@contextmanager
def processing(message: str) -> Iterator[None]:
    try:
        print(message + ' ... ', end='')
        yield
    except Skip as exc:
        print(f'skip: {exc}')
    except Exception:
        print('error')
        raise
    else:
        print('done')


def parse_options(argv: Sequence[str]) -> tuple[VersionInfo, bool]:
    parser = argparse.ArgumentParser()
    parser.add_argument('version', help='A version number (cf. 1.6.0b0)')
    parser.add_argument('--in-develop', action='store_true')
    options = parser.parse_args(argv)
    return parse_version(options.version), options.in_develop


def main() -> None:
    version, in_develop = parse_options(sys.argv[1:])

    with processing('Rewriting sphinx/__init__.py'):
        bump_version(package_dir / 'sphinx' / '__init__.py', version, in_develop)

    with processing('Rewriting CHANGES'):
        changes = Changes(package_dir / 'CHANGES.rst')
        if changes.version_tuple == version.version_tuple:
            if changes.in_development and version.is_final and not in_develop:
                changes.finalise_release_date()
            else:
                reason = 'version not changed'
                raise Skip(reason)
        else:
            if changes.in_development:
                print(f'WARNING: last version is not released yet: {changes.version}')
            changes.add_release(version)


if __name__ == '__main__':
    main()
