from __future__ import annotations

import dataclasses
import logging
import re
import warnings
from pathlib import Path
from re import Pattern
from typing import TYPE_CHECKING, Any, NoReturn

from . import _config
from . import _types as _t
from ._config import Configuration, TagConfiguration
from ._environment import resolve_runtime_env

if TYPE_CHECKING:
    from ._config import ParseFunction
    from ._environment import VcsEnvironment
    from ._version_cls import Version as _VersionType

# Backward-compat re-export used by vcs-versioning/setup.py
from ._legacy_parse import (
    resolved_fallback_root as resolved_fallback_root,  # noqa: F401
)
from ._overrides import _apply_metadata_overrides, _read_pretended_version_for
from ._scm_version import ScmVersion
from ._version_cls import _validate_version_cls
from ._version_schemes import format_version as _format_version

EMPTY_TAG_REGEX_DEPRECATION = DeprecationWarning(
    "empty regex for tag regex is invalid, using default"
)

log = logging.getLogger(__name__)


def parse_version(config: Configuration) -> ScmVersion | None:
    """Backward-compat shim for setuptools-scm <=10.0.x.

    Those releases import ``parse_version`` from this module.  The function
    was inlined during the 10.1 / vcs-versioning 2.0 refactor, but we keep
    the name importable so that older setuptools-scm pins still work with
    newer vcs-versioning releases.
    """
    scm_version = _resolve_version(config)
    return _apply_metadata_overrides(scm_version, config)


def _finalize(
    scm_version: ScmVersion,
    config: Configuration,
    *,
    force_write: bool,
) -> str:
    """Apply metadata overrides, format, and optionally write version files."""
    applied = _apply_metadata_overrides(scm_version, config)
    assert applied is not None
    version_string = _format_version(applied)
    if force_write:
        write_version_files(config, version=version_string, scm_version=applied)
    return version_string


def _resolve_version(config: Configuration) -> ScmVersion | None:
    """Run the version pipeline: pretend -> discovery -> legacy EPs.

    Discovery handles ``config.parse`` via ``LegacyParseWorkdir`` (deprecated).

    Returns the raw ``ScmVersion`` (before metadata overrides are applied) or
    ``None`` when no version could be determined.
    """
    from ._worktree_discovery import discover_workdir

    pretended = _read_pretended_version_for(config)
    if pretended is not None:
        return pretended

    workdir = discover_workdir(config)
    if workdir is not None:
        scm_version = workdir.get_scm_version()
        if scm_version is not None:
            return scm_version

    from ._legacy_parse import (
        has_legacy_parse_eps,
        parse_fallback_version,
        parse_scm_version,
    )

    if has_legacy_parse_eps():
        scm_version = parse_scm_version(config) or parse_fallback_version(config)
        if scm_version is not None:
            return scm_version

    return None


def _warn_if_tracked(target: Path, root: Path, config: Configuration) -> bool:
    """Warn when *target* is tracked in version control (#468).

    Writing a version file that is tracked makes ``git describe --dirty``
    report a dirty tree on tag checkouts, causing wrong version numbers.

    Returns False if target resolves outside root (caller should skip the write).
    """
    from ._backends._git import GitWorkdir
    from ._backends._hg import HgWorkdir

    if not target.is_absolute():
        target = root / target
    resolved_target = target.resolve()
    resolved_root = root.resolve()
    try:
        resolved_target.relative_to(resolved_root)
    except ValueError:
        # todo: emit as GitHub Actions warning via ::warning:: syntax
        warnings.warn(
            f"version file target {target} resolves outside of the project root {root}",
            stacklevel=2,
        )
        return False

    for workdir_cls in (GitWorkdir, HgWorkdir):
        try:
            wd = workdir_cls.from_potential_worktree(root, config)
        except Exception:
            continue
        if wd is not None and wd.is_file_tracked(target):
            warnings.warn(
                f"version file {target.relative_to(root)} is tracked by"
                " version control. This will cause dirty-state version bumps"
                " when the file is rewritten during builds."
                " Remove it from version control and add it to your VCS ignore file."
                " See https://github.com/pypa/setuptools-scm/issues/468",
                stacklevel=3,
            )
            return True
    return True


def write_version_files(
    config: Configuration, version: str, scm_version: ScmVersion
) -> None:
    root = Path(config.absolute_root)
    if config.write_to is not None:
        from ._dump_version import dump_version

        write_to = Path(config.write_to)
        target = root / write_to if not write_to.is_absolute() else write_to
        if not _warn_if_tracked(target, root, config):
            return

        dump_version(
            root=config.root,
            version=version,
            scm_version=scm_version,
            write_to=config.write_to,
            template=config.write_to_template,
        )
    if config.version_file:
        from ._dump_version import write_version_to_path

        version_file = Path(config.version_file)
        assert not version_file.is_absolute(), f"{version_file=}"
        # todo: use a better name than fallback root
        assert config.relative_to is not None
        target = Path(config.relative_to).parent.joinpath(version_file)
        if not _warn_if_tracked(target, root, config):
            return

        write_version_to_path(
            target,
            template=config.version_file_template,
            version=version,
            scm_version=scm_version,
        )


def _get_version(
    config: Configuration, force_write_version_files: bool | None = None
) -> str | None:
    scm_version = _resolve_version(config)
    if scm_version is None:
        return None

    if force_write_version_files is None:
        force_write_version_files = True
        warnings.warn(
            "force_write_version_files ought to be set,"
            " presuming the legacy True value",
            DeprecationWarning,
            stacklevel=2,
        )

    return _finalize(scm_version, config, force_write=force_write_version_files)


def _find_scm_in_parents(config: Configuration) -> Path | None:
    """Search parent directories for SCM repositories when relative_to is not set."""
    if config.search_parent_directories:
        return None

    from ._backends._scm_workdir import ScmWorkdir
    from ._worktree_discovery import discover_workdir

    searching_config = dataclasses.replace(config, search_parent_directories=True)
    result = discover_workdir(searching_config)
    if result is not None and isinstance(result, ScmWorkdir):
        return result.path
    return None


def _version_missing(
    config: Configuration, *, tool: str = "SETUPTOOLS_SCM"
) -> NoReturn:
    base_error = (
        f"setuptools-scm was unable to detect version for {config.absolute_root}.\n\n"
    )

    # If relative_to is not set, check for SCM repositories in parent directories
    scm_parent = None
    if config.relative_to is None:
        scm_parent = _find_scm_in_parents(config)

    if scm_parent is not None:
        if tool == "SETUPTOOLS_SCM":
            api_example = "setuptools_scm.get_version(relative_to=__file__)"
            tool_section = "[tool.setuptools_scm]"
        else:
            api_example = "vcs_versioning.get_version(relative_to=__file__)"
            tool_section = "[tool.vcs-versioning]"

        error_msg = (
            base_error
            + f"However, a repository was found in a parent directory: {scm_parent}\n\n"
            f"To fix this, you have a few options:\n\n"
            f"1. Use the 'relative_to' parameter to specify the file as reference:\n"
            f"   {api_example}\n\n"
            f"2. Enable parent directory search in your configuration:\n"
            f"   {tool_section}\n"
            f"   search_parent_directories = true\n\n"
            f"3. Change your working directory to the repository root: {scm_parent}\n\n"
            f"4. Set the root explicitly in your configuration:\n"
            f"   {tool_section}\n"
            f'   root = "{scm_parent}"\n\n'
            "For more information, see: https://setuptools-scm.readthedocs.io/en/latest/config/"
        )
    else:
        error_msg = (
            base_error
            + "Make sure you're either building from a fully intact git repository "
            "or PyPI tarballs. Most other sources (such as GitHub's tarballs, a "
            "git checkout without the .git folder) don't contain the necessary "
            "metadata and will not work.\n\n"
            "For example, if you're using pip, instead of "
            "https://github.com/user/proj/archive/master.zip "
            "use git+https://github.com/user/proj.git#egg=proj\n\n"
            "Alternatively, set the version with the environment variable "
            "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${NORMALIZED_DIST_NAME} as described "
            "in https://setuptools-scm.readthedocs.io/en/latest/config/"
        )

    raise LookupError(error_msg)


def get_version(
    root: _t.PathT = ".",
    version_scheme: _t.VERSION_SCHEME = _config.DEFAULT_VERSION_SCHEME,
    local_scheme: _t.VERSION_SCHEME = _config.DEFAULT_LOCAL_SCHEME,
    write_to: _t.PathT | None = None,
    write_to_template: str | None = None,
    version_file: _t.PathT | None = None,
    version_file_template: str | None = None,
    relative_to: _t.PathT | None = None,
    tag_regex: str | Pattern[str] = _config.DEFAULT_TAG_REGEX,
    parentdir_prefix_version: str | None = None,
    fallback_version: str | None = None,
    fallback_root: _t.PathT = ".",
    parse: ParseFunction | None = None,
    git_describe_command: _t.CMD_TYPE | None = None,
    dist_name: str | None = None,
    version_cls: type[_VersionType] | str | None = None,
    normalize: bool = True,
    search_parent_directories: bool = False,
    scm: dict[str, Any] | None = None,
    _env: VcsEnvironment | None = None,
) -> str:
    """
    If supplied, relative_to should be a file from which root may
    be resolved. Typically called by a script or module that is not
    in the root of the repository to direct setuptools-scm to the
    root of the repository by supplying ``__file__``.
    """

    version_cls = _validate_version_cls(version_cls, normalize)
    del normalize

    if tag_regex is _config.DEFAULT_TAG_REGEX:
        tag_config = TagConfiguration()
    else:
        warnings.warn(
            "get_version() parameter 'tag_regex' is deprecated. "
            "Use 'tag.regex' in pyproject.toml or pass a TagConfiguration "
            "via Configuration(tag=...) instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        tag_config = TagConfiguration(regex=parse_tag_regex(tag_regex))

    scm_config = _config.ScmConfiguration.from_data(data=scm)

    if _env is None:
        _env = resolve_runtime_env()

    config = _config.Configuration(
        root=root,
        version_scheme=version_scheme,
        local_scheme=local_scheme,
        write_to=write_to,
        write_to_template=write_to_template,
        version_file=version_file,
        version_file_template=version_file_template,
        relative_to=relative_to,
        parentdir_prefix_version=parentdir_prefix_version,
        fallback_version=fallback_version,
        fallback_root=fallback_root,
        parse=parse,
        git_describe_command=git_describe_command,
        dist_name=dist_name,
        version_cls=version_cls,
        search_parent_directories=search_parent_directories,
        scm=scm_config,
        tag=tag_config,
        _env=_env,
    )
    maybe_version = _get_version(config, force_write_version_files=True)

    if maybe_version is None:
        _version_missing(config)
    return maybe_version


def parse_tag_regex(tag_regex: str | Pattern[str]) -> Pattern[str]:
    """Pre-validate and convert tag_regex to Pattern before Configuration.

    This ensures get_version() emits the deprecation warning for empty strings
    before Configuration.__post_init__ runs.
    """
    if isinstance(tag_regex, str):
        if tag_regex == "":
            warnings.warn(EMPTY_TAG_REGEX_DEPRECATION, stacklevel=3)
            return _config.DEFAULT_TAG_REGEX
        else:
            return re.compile(tag_regex)
    else:
        return tag_regex
