#!/usr/bin/env python3
# USAGE {{{1
"""
Bump version and release date.

The directory must be clean (no uncommitted changes) for the bump to occur. Tag 
is applied for minor and major release, but not patches.

usage:
    bump [options] major [dev|alpha|beta|rc|release|post]
    bump [options] minor [dev|alpha|beta|rc|release|post]
    bump [options] patch
    bump [options] [dev|alpha|beta|rc|release|post]
    bump [options]
    bump clean
    bump initialize [python|semver]
    bump version

options:
    -h, --help      show this help message and exit
    -q, --quiet     run without narration
    -t, --trialrun  create new files rather than updating existing files

If style is python, the compact PEP 440 form of semantic versioning is 
supported.
"""
__version__ = "2.0"
__released__ = "2023-04-08"


# IMPORTS {{{1
from docopt import docopt
from inform import (
    Inform,
    Error,
    codicil,
    conjoin,
    cull,
    display,
    error,
    fatal,
    full_stop,
    is_collection,
    os_error,
    output,
    terminate,
    warn,
)
from shlib import rm, Run, to_path, set_prefs
from textwrap import dedent
from arrow import now
from voluptuous import Schema, Invalid, MultipleInvalid, Optional, Required
import re
import nestedtext as nt


# CONSTANTS {{{1
set_prefs(use_inform=True)
CFG_FILE = ".bump.cfg.nt"

DEFAULT_STYLE = "python"

STYLES = dict(
    python = dict(
        # this is the style recommended for Python by PEP 440
        dev = ".dev",       # developmental release
        alpha = "a",        # alpha pre-release
        beta = "b",         # beta pre-release
        rc = "rc",          # release candidate (may also use just "c" if you prefer)
        release = None,     # final release
        post = ".post",     # post release
                            # meant for trivial changes only,
                            # like a change to the release notes
    ),
    semver = dict(
        # this is the style recommended by semver.org
        dev = "-dev.",      # developmental release
        alpha = "-alpha.",  # alpha pre-release
        beta = "-beta.",    # beta pre-release
        rc = "-rc.",        # release candidate
        release = None,     # final release
        post = "-post.",    # post release
                            # meant for trivial changes only,
                            # like a change to the release notes)
    ),
)

# give available release types in the order they must be given in
RELEASE_TYPES = ["dev", "alpha", "beta", "rc", "release", "post"]
# assure that each style supports the expected release types
assert STYLES["python"].keys() - set(RELEASE_TYPES) == set()
assert STYLES["semver"].keys() - set(RELEASE_TYPES) == set()

INITIAL_CFG_FILE_VALUES = dict(
    major = 0,
    minor = 0,
    patch = 0,
    revision = 0,
    type = "release",
    style = DEFAULT_STYLE,
    files = {
        "⟪file-path⟫": dict(
            date="⟪date-variable⟫",
            version="⟪version-variable⟫",
        )
    },
)

ASSIGNMENT_REGEX = r"""\b{}(\s*)([:=])(\s*)(["']?){}(['"]?)"""

DATE_FMT = "YYYY-MM-DD"

DATE_REGEX = r"\d\d\d\d-\d?\d-\d?\d"

VERSION_REGEX = r"\d+\.\d+(?:\.\d+)?(?:[.-]?(?:a|b|c|alpha|beta|rc|dev|post)\.?\d+)?"

NEW_EXTENSION = ".new"

REVISION_CONTROL_COMMANDS = {
    "git": {
        "status_command": "git status -s",
        "commit_command": [
            "git add .",
            "git commit -m 'bump version to {version}'",
        ],
        "tag_command": [
            "git tag -s v{version} -m 'bump version to {version}'",
            "git push origin v{version}",
        ],
    },
    "hg": {
        "status_command": "hg status -q",
        "commit_command": "hg commit -m 'bump version to {version}'",
        "tag_command": "hg tag v{version} -m 'bump version to {version}'",
    },
}

# CONFIGURATION SCHEMA {{{1
voluptuous_error_msg_mapings = {
    "extra keys not allowed": "unknown key",
}


def downcase(key, _):
    return key.lower()


def as_number(arg):
    try:
        value = int(arg)
        if value >= 0:
            return value
    except ValueError:
        pass
    raise Invalid("expected whole number.")


def as_release_type(arg):
    if arg not in RELEASE_TYPES:
        raise Invalid(f"unrecognized type, choose from {conjoin(RELEASE_TYPES)}.")
    return arg


def as_style(arg):
    if arg not in STYLES:
        raise Invalid(f"unrecognized style, choose from {conjoin(STYLES)}.")
    return arg


def as_path(arg):
    path = to_path(arg)
    if path.exists():
        return path
    raise Invalid(f"path does not exist.")


def as_identifier(arg):
    if arg.isidentifier():
        return arg
    raise Invalid(f"expected an identifier.")

validate_config = Schema({
    Required("major"): as_number,
    Required("minor"): as_number,
    Required("patch"): as_number,
    Required("revision"): as_number,
    Required("type"): as_release_type,
    Optional("style"): as_style,
    Required("files"): {as_path: dict(date=as_identifier, version=as_identifier)}
})


# UTILITIES {{{1
# render_verison() {{{2
def render_version(cfg):
    version = f"{cfg['major']}.{cfg['minor']}.{cfg['patch']}"
    if cfg["style"] == "python" and cfg["patch"] == 0:
        version = f"{cfg['major']}.{cfg['minor']}"
    if cfg["type"] == "release":
        assert cfg["revision"] == 0
        return version
    else:
        assert cfg["revision"] != 0
        qualifier = f"{RELEASE_TYPE_NAMES[cfg['type']]}{cfg['revision']}"
        return f"{version}{qualifier}"


# major_bump() {{{2
def major_bump(cfg):
    cfg["major"] += 1
    cfg["minor"] = 0
    cfg["patch"] = 0
    cfg["revision"] = 0
    cfg["type"] = "release"


# minor_bump() {{{2
def minor_bump(cfg):
    cfg["minor"] += 1
    cfg["patch"] = 0
    cfg["revision"] = 0
    cfg["type"] = "release"


# patch_bump() {{{2
def patch_bump(cfg):
    cfg["patch"] += 1
    cfg["revision"] = 0
    cfg["type"] = "release"


# revision_bump() {{{2
def revision_bump(cfg):
    cfg["revision"] += 1


# revision_reset() {{{2
def revision_reset(cfg, new_type):
    cfg["type"] = new_type
    cfg["revision"] = 0 if new_type == "release" else 1


# MAIN {{{1
try:
    # Read the command line {{{2
    cmdline = docopt(__doc__)
    Inform(quiet=cmdline["--quiet"])
    extension = NEW_EXTENSION if cmdline["--trialrun"] else ""

    # Clean old --trialrun files if requested {{{2
    if cmdline["clean"]:
        to_remove = list(to_path(".").glob("**/*" + NEW_EXTENSION))
        if cmdline["--trialrun"]:
            if to_remove:
                output("To remove:", *to_remove, sep="\n    ")
            else:
                output("Nothing to remove.")
        else:
            rm(to_remove)
        terminate()

    # Determine the revision control system in use {{{2
    if to_path(".git").exists():
        status_command = REVISION_CONTROL_COMMANDS["git"]["status_command"]
        commit_command = REVISION_CONTROL_COMMANDS["git"]["commit_command"]
        tag_command = REVISION_CONTROL_COMMANDS["git"]["tag_command"]
    elif to_path(".hg").exists():
        status_command = REVISION_CONTROL_COMMANDS["hg"]["status_command"]
        commit_command = REVISION_CONTROL_COMMANDS["hg"]["commit_command"]
        tag_command = REVISION_CONTROL_COMMANDS["hg"]["tag_command"]
    else:
        status_command = None
        commit_command = []
        tag_command = []
        warn("could not determine revision control system.")
        codicil("Expected presence of ./.git or ./.hg.")

    # Create initial config file and exit if requested {{{2
    if cmdline["initialize"]:
        if not extension:
            if to_path(CFG_FILE).exists():
                fatal("config file already exists.", culprit=CFG_FILE)
        if cmdline["python"]:
            INITIAL_CFG_FILE_VALUES["style"] = "python"
        if cmdline["semver"]:
            INITIAL_CFG_FILE_VALUES["style"] = "semver"
        nt.dump(INITIAL_CFG_FILE_VALUES, CFG_FILE + extension, default=str)
        terminate()

    # Otherwise, read config file {{{2
    cfg = nt.load(CFG_FILE, top=dict, keymap=(keymap:={}))
    cfg = validate_config(cfg)
    style = cfg.get("style", DEFAULT_STYLE)
    cfg["style"] = style
    RELEASE_TYPE_NAMES = STYLES[style]

    # Output version if requested {{{2
    if cmdline["version"]:
        output(render_version(cfg))
        terminate()

    # Determine requested level {{{2
    LEVELS = ["patch", "minor", "major"]
    requested_level = None
    for l in LEVELS:
        if cmdline[l]:
            assert requested_level is None
            requested_level = l

    # Determine requested type {{{2
    requested_type = None
    for t in RELEASE_TYPES:
        if cmdline[t]:
            assert requested_type is None
            requested_type = t

    # Determine new version {{{2
    previous_type = cfg["type"]
    if requested_level in ["major", "minor", "patch"]:
        if requested_level == "major":
            major_bump(cfg)
        elif requested_level == "minor":
            minor_bump(cfg)
        else:
            patch_bump(cfg)
        if requested_type and requested_type != "release":
            revision_reset(cfg, requested_type)
    elif previous_type == "release":
        if requested_type == "post":
            revision_reset(cfg, requested_type)
        else:
            patch_bump(cfg)
            if requested_type not in [None, "release"]:
                revision_reset(cfg, requested_type)
    elif previous_type == requested_type or requested_type is None:
        revision_bump(cfg)
    else:
        if requested_type == "post":
            raise Error("not yet released.")
        if RELEASE_TYPES.index(previous_type) > RELEASE_TYPES.index(requested_type):
            raise Error("no longer available.", culprit=requested_type)
        revision_reset(cfg, requested_type)
    new_version = render_version(cfg)

    # Check for a clean release {{{2
    if status_command:
        cmd = status_command.format()
        check = Run(cmd, "sOEW")
        clean = check.stdout.strip() == ""
        if cmdline["--trialrun"]:
            display("clean" if clean else "dirty", "release.")
        if not clean:
            if cmdline["--trialrun"]:
                warn("uncommitted files.")
            else:
                raise Error("uncommitted files.")

    # Update the variables {{{2
    new_date = now().format(DATE_FMT)
    for path, variables in cfg["files"].items():
        rm(str(path) + NEW_EXTENSION)
        contents = orig_contents = path.read_text()

        if "date" in variables:
            varname = variables["date"]
            matcher = re.compile(ASSIGNMENT_REGEX.format(varname, DATE_REGEX))
            new_assign = rf"{varname}\1\2\3\g<4>{new_date}\5"
            new_contents = matcher.sub(new_assign, contents)
            if new_contents != contents:
                display("date updated.", culprit=path)
                contents = new_contents
            elif not matcher.search(contents):
                warn("date assignment not found.", culprit=(path, varname))

        if "version" in variables:
            varname = variables["version"]
            matcher = re.compile(ASSIGNMENT_REGEX.format(varname, VERSION_REGEX))
            new_assign = rf"{varname}\1\2\3\g<4>{new_version}\5"
            new_contents = matcher.sub(new_assign, contents)
            if new_contents != contents:
                display("version updated.", culprit=path)
                contents = new_contents
            elif not matcher.search(contents):
                warn("version assignment not found.", culprit=(path, varname))

        if contents != orig_contents:
            to_path(str(path) + extension).write_text(contents)

    # Update the config file {{{2
    display(f"Updating to version {new_version}.")
    rm(CFG_FILE + NEW_EXTENSION)
    nt.dump(cfg, CFG_FILE + extension, default=str)

    # Commit the changes {{{2
    if not is_collection(commit_command):
        commit_command = [commit_command]
    for command in commit_command:
        cmd = command.format(version=new_version)
        if cmdline["--trialrun"]:
            display(cmd)
        else:
            Run(cmd, "sOEW")

    # Tag the commit {{{2
    if cfg["patch"] == 0 and cfg["type"] == "release":
        tag = f"{cfg['major']}.{cfg['minor']}"
        display("applying tag:", tag)

        if not is_collection(tag_command):
            tag_command = [tag_command]

        for command in tag_command:
            cmd = command.format(version=tag)
            if cmdline["--trialrun"]:
                display(cmd)
            else:
                Run(cmd, "sOEW")

# EXCEPTIONS {{{1
except OSError as e:
    error(os_error(e))
except (Error, nt.NestedTextError) as e:
    e.report(codicil=cull((e.cmd, e.stdout)))
except MultipleInvalid as e:
    for err in e.errors:
        msg = voluptuous_error_msg_mapings.get(err.msg, err.msg)
        kind = "key" if "key" in msg or "path" in msg else "value"
        loc = keymap.get(tuple(err.path))
        codicil = loc.as_line(kind) if loc else None
        keys = nt.join_keys(err.path, keymap=keymap)
        error(full_stop(msg), culprit=(CFG_FILE, keys), codicil=codicil)
terminate()
