"""Scriv configuration."""

from __future__ import annotations

import configparser
import contextlib
import logging
import pkgutil
import re
from pathlib import Path
from typing import Any

import attr

from .exceptions import ScrivException
from .literals import find_literal
from .optional import tomllib
from .shell import run_shell_command

logger = logging.getLogger(__name__)


def subset_validator(allowed):
    """Create a validator that ensures a list is a subset of allowed values."""

    def validate_subset(_instance, attribute, value):
        """
        Validate that value is a subset of allowed values.
        """
        value = convert_list(value)
        bad_values = set(value) - set(allowed)
        if bad_values:
            raise ValueError(
                f"{attribute.name!r} must be chosen from: {', '.join(allowed)}."
                + f" {', '.join(map(repr, bad_values))} are not allowed."
            )

    return validate_subset


DEFAULT_FORMAT = "rst"
DEFAULT_CHANGELOG = "CHANGELOG.${config:format}"


@attr.s
class _Options:
    """
    All the settable options for Scriv.
    """

    # The directory for fragments waiting to be collected. Also can have
    # templates and settings for scriv.
    fragment_directory = attr.ib(
        type=str,
        default="changelog.d",
        metadata={
            "doc": """\
                The directory for fragments.  This directory must exist, it
                will not be created.
                """,
        },
    )

    # What format for fragments? reStructuredText ("rst") or Markdown ("md").
    format = attr.ib(  # type: ignore[assignment]
        type=str,
        default=None,
        validator=attr.validators.optional(attr.validators.in_(["rst", "md"])),
        metadata={
            "doc": """\
                The format to use for fragments and for the output changelog
                file.  Can be either "rst" or "md".
                """,
            "doc_default": f"""\
                Derived from the changelog file name if provided,
                otherwise "{DEFAULT_FORMAT}".
                """,
        },
    )

    fragment_name_fields = attr.ib(
        type=list,
        default=["created", "author", "branch"],
        validator=attr.validators.optional(  # type: ignore[arg-type]
            subset_validator(["created", "author", "branch"])
        ),
        metadata={
            "doc": """\
                The components to use when making new fragment file names.
                "created" is a timestamp, "author" is the user nick, and
                "branch" is the current Git branch name if it isn't one of the
                main branch names.
                """,
        },
    )

    # The categories for changelog fragments. Can be empty for no
    # categorization.
    categories = attr.ib(
        type=list,
        default=[
            "Removed",
            "Added",
            "Changed",
            "Deprecated",
            "Fixed",
            "Security",
        ],
        metadata={
            "doc": """\
                Categories to use as headings for changelog items.
                See :ref:`categories`.
                """,
        },
    )

    changelog = attr.ib(
        type=str,
        default=None,
        metadata={
            "doc": """\
                The changelog file managed and read by scriv.  The old name
                for this setting is :ref:`output_file <deprecated_config>`.
                """,
            "doc_default": f"``{DEFAULT_CHANGELOG}``",
        },
    )

    start_marker = attr.ib(
        type=str,
        default="scriv-insert-here",
        metadata={
            "doc": """\
                A marker string indicating where in the changelog file new
                entries should be inserted.  The old name for this setting is
                :ref:`insert_marker <deprecated_config>`.
                """,
        },
    )

    end_marker = attr.ib(
        type=str,
        default="scriv-end-here",
        metadata={
            "doc": """\
                A marker string indicating where in the changelog file the
                changelog ends.
                """,
        },
    )

    # The characters to use for header and section underlines in rst files.
    rst_header_chars = attr.ib(
        type=str,
        default="=-",
        validator=attr.validators.matches_re(r"\S\S"),
        metadata={
            "doc": """\
                Two characters: for reStructuredText changelog files, these
                are the two underline characters to use.  The first is for the
                heading for each changelog entry, the second is for the
                category sections within the entry.
                """,
        },
    )

    # What header level to use for markdown changelog entries?
    md_header_level = attr.ib(
        type=str,
        default="1",
        validator=attr.validators.matches_re(r"[123456]"),
        converter=attr.converters.optional(str),
        metadata={
            "doc": """\
                A number: for Markdown changelog files, this is the heading
                level to use for the entry heading.
                """,
        },
    )

    md_setext_chars = attr.ib(
        type=str,
        default="",
        validator=attr.validators.matches_re(r"^$|\S\S"),
        metadata={
            "doc": """\
                Two characters: for Markdown changelog files, these enable
                setext-style underlined headings if set. The first is for the
                heading for each changelog entry, the second is for the category
                sections within the entry. Use "=-" for standard formatting.
                """,
            "doc_default": "(empty)",
        },
    )

    md_html_anchors = attr.ib(
        type=bool,
        default=True,
        converter=attr.converters.to_bool,
        metadata={
            "doc": """\
                If true, Markdown sections will have HTML anchors added
                for precise linking.
                """,
        },
    )

    compact_fragments = attr.ib(
        type=bool,
        default=False,
        converter=attr.converters.to_bool,
        metadata={
            "doc": """\
                If true, fragments will be concatenated without blank lines
                between them, allowing for compact bullet lists.
                """,
        },
    )

    # The name of the template for new fragments.
    new_fragment_template = attr.ib(
        type=str,
        default="file: new_fragment.${config:format}.j2",
        metadata={
            "doc": """\
                The `Jinja`_ template to use for new fragments.
                """,
        },
    )

    # The template for the title of the changelog entry.
    entry_title_template = attr.ib(
        type=str,
        default=(
            "{% if version %}{{ version }} — {% endif %}"
            + "{{ date.strftime('%Y-%m-%d') }}"
        ),
        metadata={
            "doc": """\
                The `Jinja`_ template to use for the entry heading text for
                changelog entries created by ":ref:`cmd_collect`".
                """,
        },
    )

    # The version string to include in the title if wanted.
    version = attr.ib(
        type=str,
        default="",
        metadata={
            "doc": """\
                The string to use as the version number in the next header
                created by ``scriv collect``.  Often, this will be a
                ``literal:`` directive, to get the version from a string in a
                source file.
                """,
            "doc_default": "(empty)",
        },
    )

    # Branches that aren't interesting enough to use in fragment file names.
    main_branches = attr.ib(
        type=list,
        default=["master", "main", "develop"],
        metadata={
            "doc": """\
                The branch names considered uninteresting to use in new
                fragment file names.
                """,
        },
    )

    # Glob for files in the fragments directory that should not be collected.
    skip_fragments = attr.ib(
        type=str,
        default="README.*",
        metadata={
            "doc": """\
                A glob pattern for files in the fragment directory that should
                not be collected.
                """,
        },
    )

    # Template for GitHub releases
    ghrel_template = attr.ib(
        type=str,
        default="{{body}}",
        metadata={
            "doc": """\
                The template to use for GitHub releases created by the
                ``scriv github-release`` command.

                The extracted Markdown text is available as ``{{body}}``.  You
                must include this to use the text from the changelog file.  The
                version is available as ``{{version}}`` and the title of the
                entry is available as ``{{title}}``.

                The data for the release is available in a ``{{release}}``
                object, including ``{{release.prerelease}}``.  It's a boolean,
                true if this is a pre-release version.

                The scriv configuration is available in a ``{{config}}`` object.
                """,
        },
    )

    def post_create(self):
        """
        Reconcile some interdependent settings after creating the object.
        """
        if self.format is None:
            if self.changelog is not None and "${" not in self.changelog:
                self.format = Path(self.changelog).suffix[1:]
            else:
                self.format = DEFAULT_FORMAT
        if self.changelog is None:
            self.changelog = DEFAULT_CHANGELOG


# Map of old config names to new config names.
DEPRECATED_NAMES = [
    ("output_file", "changelog"),
    ("insert_marker", "start_marker"),
]


@contextlib.contextmanager
def validator_exceptions():
    """
    Context manager for attrs operations that validate.

    Attrs >= 22 says ValueError will have a bunch of arguments, and we only want
    to see the first, and raised as ScrivException.

    """
    try:
        yield
    except ValueError as ve:
        raise ScrivException(f"Invalid configuration: {ve.args[0]}") from ve


class Config:
    """
    Configuration for Scriv.

    All the settable options for Scriv, with resolution of values within other
    values.

    """

    def __init__(self, post_create_=True, **kwargs):
        """All values in _Options can be set as keywords."""
        with validator_exceptions():
            self._options = _Options(**kwargs)
        if post_create_:
            self._options.post_create()

    def __getattr__(self, name):
        """Proxy to self._options, and resolve the value."""
        fields = attr.fields_dict(_Options)
        if name not in fields:
            raise AttributeError(f"Scriv configuration has no {name!r} option")
        attrdef = fields[name]
        value = getattr(self._options, name)
        if attrdef.type is list:
            value = convert_list(value)
        elif isinstance(value, str):
            try:
                value = self.resolve_value(value)
            except ScrivException as se:
                raise ScrivException(
                    f"Couldn't read {name!r} setting: {se}"
                ) from se
        setattr(self, name, value)
        return value

    @classmethod
    def read(cls, file_name: str | None = None) -> Config:
        """
        Read the configuration to use.

        Configuration will be read from setup.cfg, tox.ini, or
        changelog.d/scriv.ini.  If setup.cfg or tox.ini defines
        a new fragment_directory, then scriv.ini is read from there.

        The section can be named ``[scriv]`` or ``[tool.scriv]``.

        """
        config = cls(post_create_=False)
        if file_name is not None:
            config.read_one_config(file_name)
        else:
            config.read_one_config("setup.cfg")
            config.read_one_config("tox.ini")
            config.read_one_toml("pyproject.toml")
            config.read_one_config(
                str(Path(config.fragment_directory) / "scriv.ini")
            )
        with validator_exceptions():
            attr.validate(config._options)
        config._options.post_create()
        return config

    def get_set_option(self, scriv_data, config_name, opt_name):
        """
        Set one option from a config file setting.
        """
        try:
            val: Any = scriv_data[config_name]
        except KeyError:
            pass
        else:
            attrdef = attr.fields_dict(_Options)[opt_name]
            if callable(attrdef.converter):
                val = attrdef.converter(val)
            setattr(self._options, opt_name, val)

    def read_one_config(self, configfile: str) -> None:
        """
        Read one configuration file, adding values to `self`.
        """
        logger.debug(f"Looking for config file {configfile}")
        parser = configparser.ConfigParser()
        files_read = parser.read(configfile, encoding="utf-8")
        if not files_read:
            logger.debug(f"{configfile} doesn't exist")
            return
        logger.debug(f"{configfile} was read")

        section_name = next(
            (
                name
                for name in ["scriv", "tool.scriv"]
                if parser.has_section(name)
            ),
            None,
        )
        if section_name:
            scriv_data = parser[section_name]
            for old, new in DEPRECATED_NAMES:
                self.get_set_option(scriv_data, old, new)
            for attrdef in attr.fields(_Options):
                self.get_set_option(scriv_data, attrdef.name, attrdef.name)

    def read_one_toml(self, tomlfile: str) -> None:
        """
        Read one .toml file if it exists, adding values to `self`.
        """
        logger.debug(f"Looking for config file {tomlfile}")
        tomlpath = Path(tomlfile)
        if not tomlpath.exists():
            logger.debug(f"{tomlfile} doesn't exist")
            return

        toml_text = tomlpath.read_text(encoding="utf-8")
        logger.debug(f"{tomlfile} was read")

        if tomllib is None:
            # Toml support isn't installed. Only print an exception if the
            # config file seems to have settings for us.
            has_scriv = re.search(r"(?m)^\[tool\.scriv\]", toml_text)
            if has_scriv:
                msg = (
                    "Can't read {!r} without TOML support. "
                    + "Install with [toml] extra"
                ).format(tomlfile)
                raise ScrivException(msg)
        else:
            # We have toml installed, parse the file and look for our settings.
            data = tomllib.loads(toml_text)
            try:
                scriv_data = data["tool"]["scriv"]
            except KeyError:
                # No settings for us
                return
            for old, new in DEPRECATED_NAMES:
                self.get_set_option(scriv_data, old, new)
            for attrdef in attr.fields(_Options):
                self.get_set_option(scriv_data, attrdef.name, attrdef.name)

    def resolve_value(self, value: str) -> str:
        """
        Interpret prefixes in config files to find the actual value.

        Also, "${config:format}" is replaced with the configured
        format ("rst" or "md").

        Prefixes:
            "file:" read the content from a file.
            "literal:" read a literal string from a file.
            "command:" read the output of a shell command.

        """
        if self._options.format is not None:
            value = value.replace("${config:format}", self._options.format)
        if value.startswith("file:"):
            file_name = value.partition(":")[2].strip()
            value = self.read_file_value(file_name)
        elif value.startswith("literal:"):
            try:
                _, file_name, literal_name = value.split(":", maxsplit=2)
            except ValueError as ve:
                raise ScrivException(f"Missing value name: {value!r}") from ve
            file_name = file_name.strip()
            if not file_name:
                raise ScrivException(f"Missing file name: {value!r}")
            literal_name = literal_name.strip()
            if not literal_name:
                raise ScrivException(f"Missing value name: {value!r}")
            try:
                found = find_literal(file_name, literal_name)
            except Exception as exc:
                raise ScrivException(
                    f"Couldn't find literal {value!r}: {exc}"
                ) from exc
            if found is None:
                raise ScrivException(
                    f"Couldn't find literal {literal_name!r} in {file_name}: "
                    + f"{value!r}"
                )
            value = found
        elif value.startswith("command:"):
            cmd = value.partition(":")[2].strip()
            ok, out = run_shell_command(cmd)
            if not ok:
                raise ScrivException(f"Command {cmd!r} failed:\n{out}")
            if out.count("\n") == 1:
                out = out.rstrip("\r\n")
            value = out
        return value

    def read_file_value(self, file_name: str) -> str:
        """
        Find the value of a setting that has been specified as a file name.
        """
        value = None
        possibilities = []
        if not re.match(r"\.\.?[/\\]", file_name):
            possibilities.append(Path(self.fragment_directory) / file_name)
        possibilities.append(Path(".") / file_name)

        for file_path in possibilities:
            if file_path.exists():
                value = file_path.read_text()
                break
        else:
            # No path, and doesn't exist: try it as a built-in.
            try:
                file_bytes = pkgutil.get_data("scriv", "templates/" + file_name)
            except OSError:
                pass
            else:
                assert file_bytes
                value = file_bytes.decode("utf-8")

        if value is None:
            raise ScrivException(f"No such file: {file_name}")

        return value


def convert_list(val: str | list[str]) -> list[str]:
    """
    Convert a string value from a config into a list of strings.

    Elements can be separated by commas or newlines.
    """
    if isinstance(val, str):
        vals = re.split(r"[\n,]", val)
        vals = [v.strip() for v in vals]
        vals = [v for v in vals if v]
        return vals
    else:
        return val
