from __future__ import annotations

import re
from collections.abc import Iterable, Mapping

NON_ACCESSOR_CHARS = re.compile(r"[^\w ]")
WHITESPACE = re.compile(r"\s+")


def to_accessor(heading: str) -> str:
    """Convert a human-readable heading into a data attribute accessor.

    This is done by:

    1. Converting the heading to lower case;
    2. Removing any character that can't be used in a Python identifier
    3. Replacing all whitespace with "_"
    4. Prepending `_` if the first character is a digit.

    Examples:

    * 'Heading 1' -> 'heading_1'
    * 'Heading - Title' -> 'heading_title'
    * 'Heading!' -> 'heading'
    * '1 Heading' -> '_1_heading'
    * '你好' -> '你好'

    :param heading: The column heading.
    :returns: The accessor derived from the heading.
    :raises ValueError: If the heading cannot be converted into an accessor.
    """
    value = WHITESPACE.sub(
        "_",
        NON_ACCESSOR_CHARS.sub("", heading.lower()),
    )

    try:
        if value[0].isdigit():
            value = f"_{value}"
    except IndexError as exc:
        raise ValueError(
            f"Unable to automatically generate accessor from heading {heading!r}."
        ) from exc

    return value


def build_accessors(
    headings: Iterable[str],
    accessors: Iterable[str | None] | Mapping[str, str] | None,
) -> list[str]:
    """Convert a list of headings (with accessor overrides) to a finalised list of
    accessors.

    :param headings: The list of headings.
    :param accessors: The list of accessor overrides. Can be specified as:

        * A list the same length as headings. Each entry in the list is a string that
          is the override name for the accessor, or [`None`][] if the default accessor
          for the heading at that index should be used.
        * A dictionary mapping heading names to accessor names. If a heading name isn't
          present in the dictionary, the default accessor will be used.

    :returns: The final list of accessors.
    """
    if accessors is not None:
        if isinstance(accessors, Mapping):
            return [
                accessors[h] if h in accessors else to_accessor(h) for h in headings
            ]
        else:
            try:
                zipped = list(zip(headings, accessors, strict=True))
            except ValueError as exc:
                raise ValueError(
                    "Number of accessors must match number of headings"
                ) from exc
            return [a if a is not None else to_accessor(h) for h, a in zipped]
    else:
        return [to_accessor(h) for h in headings]
