#
# scmver.core
#
#   Copyright (c) 2019-2025 Akinori Hattori <hattya@gmail.com>
#
#   SPDX-License-Identifier: MIT
#

from __future__ import annotations
from collections.abc import Mapping, Sequence
import datetime
import importlib
import os
import re
import sys
import textwrap
from typing import cast, Any, NamedTuple

from ._typing import Path, Segment, RawSegment


__all__ = ['generate', 'get_version', 'load_version', 'next_version', 'load_project', 'stat',
           'SCMInfo', 'Version', 'VersionError']

_TEMPLATE = textwrap.dedent("""\
    # file generated by scmver; DO NOT EDIT.

    version = '{version}'
""")

_pep440_re = re.compile(r"""
    \A
    v?
    # public version identifiers
    (?:             # epoch segment
        (?P<epoch>[0-9]+) !
    )?
    (?P<release>    # release segment
        [0-9]+ (?:\. [0-9]+)*
    )
    (?:             # pre-release segment
        (?P<pre_sep>[-._])?
        (?P<pre_s>
            a  | alpha |
            b  | beta  |
            rc | c     | pre (?:view)?
        )
        (?P<pre_opt_sep>[-._])?
        (?P<pre_n>[0-9]*)
    )?
    (?:             # post-release segment
        (?:
            (?:
                (?P<post_sep>[-._])?
                (?P<post_s>
                    post | r (?:ev)?
                )
                (?P<post_opt_sep>[-._])?
            ) |
            -
        )
        (?P<post_n>(?(post_s)[0-9]* | [0-9]+))
    )?
    (?:             # development release segment
        (?P<dev_sep>[-._])?
        (?P<dev_s>
            dev
        )
        (?P<dev_opt_sep>[-._])?
        (?P<dev_n>[0-9]*)
    )?
    # local version identifiers
    (?:
        \+
        (?P<local>
            [a-z0-9] (?:[a-z0-9-_.]* [a-z0-9])?
        )
    )?
    \Z
""", re.IGNORECASE | re.VERBOSE)
_sep_re = re.compile(r'[-._]')
_version_re = re.compile(r'(?P<version>v?\d+.*)\Z')


def generate(path: Path, version: str | None, info: SCMInfo | None = None, template: str = _TEMPLATE) -> None:
    kwargs: dict[str, Any] = {'version': version or ''}
    if info:
        kwargs.update(revision=info.revision or '',
                      branch=info.branch or '')
    with open(path, 'w') as fp:
        fp.write(template.format(**kwargs))


def get_version(root: Path = '.', **kwargs: Any) -> str | None:
    def take(d: Mapping[str, str], *keys: str) -> dict[str, Any]:
        return {k: d[k] for k in d if k in keys}

    root = os.path.abspath(root)
    if info := stat(root, **{k: kwargs[k] for k in kwargs if k.endswith('.tag')}):
        version = next_version(info, **take(kwargs, 'spec', 'local', 'version'))
        if 'write_to' in kwargs:
            generate(os.path.join(root, kwargs['write_to']), version, info, **take(kwargs, 'template'))
        return version
    elif 'fallback' in kwargs:
        fallback = kwargs['fallback']
        if callable(fallback):
            return cast(str, fallback())
        elif isinstance(fallback, str):
            return load_version(fallback, root)
        elif isinstance(fallback, Sequence):
            return load_version(fallback[0], os.path.join(root, fallback[1]))
    return None


def load_version(spec: str, path: Path | None = None) -> str:
    v = spec.split(':')
    if len(v) != 2:
        raise ValueError('invalid format')

    if path:
        sys.path.append(str(path))
        try:
            o = importlib.import_module(v[0])
        finally:
            sys.path.pop()
    else:
        o = importlib.import_module(v[0])

    for a in v[1].split('.'):
        o = getattr(o, a)
    return cast(str, o() if callable(o) else o)


def next_version(info: SCMInfo, spec: str = 'post', local: str = '{local:%Y-%m-%d}', version: re.Pattern[str] = _version_re) -> str | None:
    m = version.search(info.tag)
    if not m:
        raise VersionError('cannot parse version from SCM tag')

    pv = Version(m.group('version'))
    if info.distance > 0:
        pv.update(spec, info.distance)

    if callable(local):
        lv = local(info)
    elif info.dirty:
        lv = local.format(distance=info.distance,
                          revision=info.revision,
                          branch=info.branch,
                          utc=datetime.datetime.now(datetime.timezone.utc),
                          local=datetime.datetime.now())
    else:
        lv = None
    return str(pv) if not lv else f'{pv}+{lv}'


def load_project(path: Path = 'pyproject.toml') -> dict[str, Any] | None:
    try:
        if sys.version_info >= (3, 11):
            import tomllib as toml
        else:
            import tomli as toml
    except ImportError:
        return None

    if not os.path.isfile(path):
        return None
    with open(path, 'rb') as fp:
        proj = toml.load(fp)
    if not ('tool' in proj
            and 'scmver' in proj['tool']):
        return None

    scmver: dict[str, Any] = proj['tool']['scmver']
    # root
    root = os.path.dirname(os.path.abspath(path))
    scmver['root'] = os.path.join(root, scmver['root']) if 'root' in scmver else root
    # write-to
    if 'write-to' in scmver:
        scmver['write_to'] = scmver.pop('write-to')
    # fallback
    if ('fallback' in scmver
        and isinstance(scmver['fallback'], Mapping)):
        fallback = scmver['fallback']
        scmver['fallback'] = [fallback['attr'], fallback['path']] if 'path' in fallback else fallback['attr']
    # flatten tables
    for k in tuple(scmver):
        if isinstance(scmver[k], Mapping):
            scmver.update({f'{k}.{sk}': v for sk, v in scmver.pop(k).items()})
    return scmver


def stat(path: Path, **kwargs: Any) -> SCMInfo | None:
    import importlib.metadata

    impls = tuple((ep.name, ep.load()) for ep in importlib.metadata.entry_points(group='scmver.parse'))
    if not impls:
        from . import bazaar as bzr, darcs, fossil as fsl, git, mercurial as hg, subversion as svn

        impls = (('.bzr', bzr.parse), ('_darcs', darcs.parse), ('.fslckout', fsl.parse), ('_FOSSIL_', fsl.parse),
                 ('.git', git.parse), ('.hg', hg.parse), ('.hg_archival.txt', hg.parse), ('.svn', svn.parse))

    path = os.path.abspath(path)
    while True:
        for name, parse in impls:
            if (kwargs.get(name, True)
                and os.path.exists(os.path.join(path, name))):
                if info := parse(path, name=name, **kwargs):
                    return info
        p, path = path, os.path.dirname(path)
        if path == p:
            return None


class SCMInfo(NamedTuple):

    tag: str = '0.0'
    distance: int = 0
    revision: int | str | None = None
    dirty: bool = False
    branch: str | None = None


class Version:

    __slots__ = ('epoch', 'release', '_pre', '_post', '_dev', 'local')

    _pre: RawSegment | None
    _post: RawSegment | None
    _dev: RawSegment | None

    def __init__(self, version: str) -> None:
        m = _pep440_re.match(version.strip())
        if not m:
            raise VersionError(f'invalid version: {version!r}')

        self.epoch = int(m.group('epoch')) if m.group('epoch') else 0
        self.release = tuple(map(int, m.group('release').split('.')))
        for g in ('pre', 'post', 'dev'):
            s = m.group(f'{g}_s')
            n = m.group(f'{g}_n')
            setattr(self, f'_{g}', (m.group(f'{g}_sep') or '', s, m.group(f'{g}_opt_sep') or '', int(n) if n else -1) if s or n else None)
        self.local = m.group('local')

    def __repr__(self) -> str:
        return f'<{self.__class__.__name__}({self})>'

    def __str__(self) -> str:
        def seg(v: RawSegment) -> tuple[str, ...]:
            return (v[0], v[1], v[2], str(v[3]) if v[3] >= 0 else '')

        buf = []
        if self.epoch != 0:
            buf.append(f'{self.epoch}!')
        buf.append('.'.join(map(str, self.release)))
        if self._pre:
            buf += seg(self._pre)
        if self._post:
            if self._post[1]:
                buf += seg(self._post)
            else:
                buf.append(f'-{self._post[3]}')
        if self._dev:
            buf += seg(self._dev)
        if self.local:
            buf.append(f'+{self.local}')
        return ''.join(buf)

    @property
    def pre(self) -> Segment | None:
        return self._pre[1::2] if self._pre else None

    @property
    def post(self) -> Segment | None:
        return self._post[1::2] if self._post else None

    @property
    def dev(self) -> Segment | None:
        return self._dev[1::2] if self._dev else None

    def normalize(self) -> Version:
        def seg(s: str, v: RawSegment, sep: str = '.') -> RawSegment:
            return (sep, s, '', v[3] if v[3] >= 0 else 0)

        v = self.__class__(str(self).lower())
        if v._pre:
            s = v._pre[1]
            if s == 'alpha':
                s = 'a'
            elif s == 'beta':
                s = 'b'
            elif s in ('c', 'pre', 'preview'):
                s = 'rc'
            v._pre = seg(s, v._pre, sep='')
        if v._post:
            v._post = seg('post', v._post)
        if v._dev:
            v._dev = seg('dev', v._dev)
        if v.local:
            v.local = '.'.join(str(int(s)) if s.isdigit() else s for s in _sep_re.split(v.local))
        return v

    def update(self, spec: str, value: int = 1) -> None:
        if self.local:
            raise VersionError('local version identifiers exists')

        def update(ver: int, val: int) -> int:
            if ver < 0:
                return val if val > 0 else -1
            return ver + val

        def zero(v: int) -> int:
            return v if v >= 0 else 0

        spec = spec.lower()
        if spec == 'major':
            self.release = (zero(self.release[0] + value),) + self.release[1:]
            self._pre = self._post = self._dev = None
        elif spec == 'minor':
            if len(self.release) < 2:
                self.release += (zero(value),)
            else:
                self.release = self.release[:1] + (zero(self.release[1] + value),) + self.release[2:]
            self._pre = self._post = self._dev = None
        elif spec in ('micro', 'patch'):
            if len(self.release) < 2:
                self.release += (0, zero(value),)
            elif len(self.release) < 3:
                self.release += (zero(value),)
            else:
                self.release = self.release[:2] + (zero(self.release[2] + value),) + self.release[3:]
            self._pre = self._post = self._dev = None
        elif spec in ('pre', 'dev'):
            v = getattr(self, f'_{spec}')
            if not v:
                raise VersionError(f'{"pre-" if spec != "dev" else "development "}release segment does not exist')
            setattr(self, f'_{spec}', v[:3] + (update(v[3], value),))
        elif spec == 'post':
            if self._post:
                if self._post[1]:
                    self._post = self._post[:3] + (update(self._post[3], value),)
                else:
                    self._post = self._post[:3] + (zero(self._post[3] + value),)
            elif value >= 0:
                self._post = ('.', 'post', '', value if value > 1 else -1)
        elif spec.endswith('.dev'):
            spec = spec[:-len('.dev')]
            if spec == 'major':
                i = 1
            elif spec == 'minor':
                i = 2
            elif spec in ('micro', 'patch'):
                i = 3
            else:
                raise VersionError('invalid segment specifier')
            if value < 0:
                raise VersionError('invalid value')

            self.release = self.release[:i] + (0,) * (len(self.release) - i)
            self.update(spec)
            self._dev = ('.', 'dev', '', value if value > 1 else -1)


class VersionError(ValueError):
    pass
