"""Parsing for the indexed_range argument syntax.

An indexed_range selects positions in one dimension of a grid, with an
optional extent in the other dimension.  The same syntax serves the table
klammer's :hline and :vline arguments (index = boundary, subsets = how far
along the line) and its :colspan and :rowspan arguments (index = row or
column, subsets = the cells to merge).  Which dimension the index selects
is a property of the argument, not of the syntax.

This module replaces the former sequences.py and span.py (see debris).
"""

import re

syntax_description = """
An indexed_range is a selector, optionally followed by parenthesized
subsets, written with no spaces:

    <selector>              full extent
    <selector>(<subsets>)   restricted extent

The selector is a single index "3", a closed index range "2-5", an open
index range "2-" (to the last index), or a name defined by the argument
(for example "top" or "inner" for table lines).  Subsets are separated by
commas; each is an index "4", a closed range "1-4", or an open range "6-"
(to the end).  All indices are zero-origin.

A negative index counts from the end, as in Python: -1 is the last index,
-2 the second to last, and so on.  Ranges are inclusive, so "1--2" is index
1 through the second-to-last index (the last index excluded).

Examples:
    3            index 3, full extent
    2-5(0-2)     indices 2 through 5, each restricted to 0 through 2
    3(1-4,6-9)   index 3, restricted to 1-4 and 6-9
    -1           the last index
    1--2         index 1 through the second-to-last index
    head(1-)     with table hline names: boundary 1, from column 1 on
""".strip()


class Range_error(Exception):
    def __init__(self, message):
        super().__init__(f"{message}\n\n{syntax_description}")


# A numeric selector is a signed integer, optionally followed by a range
# part: a separating hyphen and an optional signed end index (empty end =
# open range).  The leading sign lets an index count from the end (-1 is
# the last), matching Python list indexing.  The separating hyphen never
# collides with a minus sign because \d+ never consumes it.
item_rgx = re.compile(r"(?:(-?\d+)(-(-?\d+)?)?|([A-Za-z]+))(?:\(([-\d,]+)\))?$")
subset_rgx = re.compile(r"(-?\d+)(-(-?\d+)?)?$")


def hline_names(count):
    """Boundary-name map for horizontal lines; count = row_count + 1."""
    last = count - 1
    return {"top": [0],
            "head": [1],
            "bottom": [last],
            "inner": list(range(1, last)),
            "all": list(range(count)),
            "none": []}


def vline_names(count):
    """Boundary-name map for vertical lines; count = column_count + 1."""
    last = count - 1
    return {"outer": [0, last],
            "inner": list(range(1, last)),
            "all": list(range(count)),
            "none": []}


class Indexed_range:
    """The selected extent for one primary-dimension index."""

    def __init__(self, index, maxval):
        self.index = index
        self.maxval = maxval
        self.all = False        # Full extent (no subsets given)
        self.ranges = []        # [[start, end], ...], inclusive

    def add_full(self):
        self.all = True
        self.ranges = [[0, self.maxval]]

    def add_ranges(self, ranges):
        if not self.all:
            self.ranges += ranges

    def has(self, i):
        return any(start <= i <= end for start, end in self.ranges)

    def items(self, invert=False):
        result = []
        for start, end in self.ranges:
            for e in range(start, end + 1):
                result.append((e, self.index) if invert else (self.index, e))
        return result

    def __str__(self):
        subsets = ",".join([f"{s}-{e}" for s, e in self.ranges])
        return f"{self.index}({subsets})"

    def __repr__(self):
        return self.__str__()


class Indexed_ranges:
    """A parsed indexed_range argument: Indexed_range entries by index.

    count    - number of valid primary indices (0 .. count-1)
    maxval   - largest valid subset value (the cross dimension)
    specs    - the argument value: a list of items (from the argtype's
               python_cast), a whitespace-separated string, or None
    names    - map of selector names to index lists (hline_names, ...)
    argument - argument name for error messages (":hline", ...)

    Items targeting the same index merge: their subsets are unioned, and a
    full-extent item absorbs any subsets.
    """

    def __init__(self, count, maxval, specs, names=None, argument=""):
        self.count = count
        self.maxval = maxval
        self.names = names or {}
        self.argument = argument
        self.by_index = {}
        if specs is None:
            specs = []
        elif isinstance(specs, str):
            specs = specs.split()
        for spec in specs:
            self.parse(spec)

    def error(self, message):
        argument = f"{self.argument} argument: " if self.argument else ""
        raise Range_error(f"{argument}{message}")

    def normalize(self, raw, spec, count):
        """Resolve a possibly-negative index to 0..count-1 (Python-style):
        a negative index counts from the end (-1 is the last)."""
        i = raw + count if raw < 0 else raw
        if not 0 <= i < count:
            self.error(f'In "{spec}", index {raw} is out of range '
                       f"(0 through {count - 1}, or -1 through -{count}).")
        return i

    def parse(self, spec):
        match = item_rgx.match(spec)
        if not match:
            self.error(f'"{spec}" is not a valid indexed_range.')
        number, range_part, end, name, subsets = match.groups()
        if name is not None:
            if name not in self.names:
                known = " ".join(self.names) or "none"
                self.error(f'"{name}" is not a valid name here '
                           f"(valid names: {known}).")
            indices = self.names[name]
        elif range_part is None:
            indices = [self.normalize(int(number), spec, self.count)]
        else:
            first = self.normalize(int(number), spec, self.count)
            last = (self.normalize(int(end), spec, self.count)
                    if end else self.count - 1)
            if first > last:
                self.error(f'In "{spec}", the index range start {first} '
                           f"is greater than its end {last}.")
            indices = list(range(first, last + 1))
        ranges = self.parse_subsets(spec, subsets) if subsets else None
        for i in indices:
            entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval))
            if ranges is None:
                entry.add_full()
            else:
                entry.add_ranges(ranges)

    def parse_subsets(self, spec, subsets):
        # Subset indices run 0..maxval inclusive, so their count is
        # maxval + 1 and a negative subset index resolves against it.
        count = self.maxval + 1
        ranges = []
        for part in subsets.split(","):
            match = subset_rgx.match(part)
            if not match:
                self.error(f'In "{spec}", "{part}" is not a valid subset.')
            number, range_part, end = match.groups()
            if range_part is None:
                start = last = self.normalize(int(number), spec, count)
            else:
                start = self.normalize(int(number), spec, count)
                last = (self.normalize(int(end), spec, count)
                        if end else self.maxval)
            if start > last:
                self.error(f'In "{spec}", the subset start {start} '
                           f"is greater than its end {last}.")
            ranges.append([start, last])
        return ranges

    def __getitem__(self, index):
        return self.by_index.get(index)

    def __iter__(self):
        return iter(self.by_index)

    def has(self, index, i):
        entry = self[index]
        return entry.has(i) if entry else False

    def __str__(self):
        return " ".join([str(self.by_index[i]) for i in sorted(self.by_index)])

    def __repr__(self):
        return self.__str__()
