"""Django MultiRangeField — a CharField for page numbers and ranges.

Stores a normalized, comma-separated string like "30, 41, 51-57, 68", and
exposes the expanded list of ints (handy for matching) two ways:
  - field.to_list(value), and
  - a generated `<field>_list` property on any model that uses it,
    e.g. `page in obj.pages_list`.

Modernized for Django 3.2+ / 5.x and Python 3 (the original relied on the
long-removed SubfieldBase metaclass and _get_val_from_obj).
"""

__author__ = "Mark Boszko"

import re
from itertools import groupby

from django import forms
from django.core.exceptions import ValidationError
from django.db import models

# Page-range strings may only contain digits, commas, hyphens, and whitespace.
_VALID_CHARS = re.compile(r"^[0-9,\s-]*$")


def depack(value):
    """Expand a page-range string into an ascending list of ints.

    "30, 41, 51-53" -> [30, 41, 51, 52, 53]. Reversed ranges ("53-51") are
    tolerated; malformed parts ("12-13-15", letters) are skipped, never raised.
    """
    pages = []
    for part in re.sub(r"\s", "", value or "").split(","):
        if "-" in part:
            lo, _, hi = part.partition("-")  # split on the first hyphen only
            if lo.isdigit() and hi.isdigit():
                a, b = int(lo), int(hi)
                pages.extend(range(min(a, b), max(a, b) + 1))
        elif part.isdigit():
            pages.append(int(part))
    return pages


def repack(pages):
    """Collapse a list of ints into a sorted, de-duplicated range string.

    [3, 1, 2, 6, 8] -> "1-3, 6, 8". Consecutive runs group together because, in
    a sorted list, (value - position) stays constant across a run.
    """
    chunks = []
    for _, run in groupby(
        enumerate(sorted(set(pages))), key=lambda pos_val: pos_val[1] - pos_val[0]
    ):
        nums = [val for _, val in run]
        chunks.append(f"{nums[0]}-{nums[-1]}" if len(nums) > 1 else str(nums[0]))
    return ", ".join(chunks)


def first_page(value):
    """The lowest page number in the range, or 0 if there are none."""
    pages = depack(value)
    return pages[0] if pages else 0


def _normalize(value):
    """Round-trip a page-range string into canonical form (sorted, ranges)."""
    return repack(depack(value)) if value else ""


class MultiRangeField(models.CharField):
    description = "Page numbers and ranges, e.g. '30, 41, 51-57, 68'"

    def __init__(self, *args, **kwargs):
        kwargs["max_length"] = 1000
        kwargs.setdefault("help_text", "Comma-separated pages and page ranges.")
        super().__init__(*args, **kwargs)

    def deconstruct(self):
        # Drop the values __init__ hardcodes so migrations don't pin them.
        name, path, args, kwargs = super().deconstruct()
        kwargs.pop("max_length", None)
        kwargs.pop("help_text", None)
        return name, path, args, kwargs

    def from_db_value(self, value, expression, connection):
        # Normalize on read too, so legacy/imported rows come back canonical.
        return _normalize(value)

    def to_python(self, value):
        return _normalize(value)

    def get_prep_value(self, value):
        return _normalize(value)

    def value_to_string(self, obj):
        # value_from_object replaced the _get_val_from_obj removed in Django 1.10.
        return _normalize(self.value_from_object(obj))

    def to_list(self, value):
        """The pages as an ascending list of ints, for matching."""
        return depack(value)

    def contribute_to_class(self, cls, name, **kwargs):
        super().contribute_to_class(cls, name, **kwargs)
        # Expose `<name>_list` on the model: the pages expanded to ints.
        setattr(cls, f"{name}_list", property(lambda obj, n=name: depack(getattr(obj, n))))

    def formfield(self, **kwargs):
        return super().formfield(**{"form_class": MultiRangeFormField, **kwargs})


class MultiRangeFormField(forms.CharField):
    """Form field that rejects stray characters (strict, for human input)."""

    def clean(self, value):
        value = super().clean(value)
        if value and not _VALID_CHARS.match(value):
            raise ValidationError("Only numbers, commas, hyphens, and spaces are allowed.")
        return _normalize(value)