#!/usr/bin/env python3

from __future__ import annotations

import os
import csv
import re
from pathlib import Path
from collections.abc import Mapping, Iterable, Collection


_TYPES_LIST_RE = re.compile(
    r"<!--\s*types-list:start\s*-->(.*?)<!--\s*types-list:end\s*-->",
    re.DOTALL,
)


def _parse_types_block(block: str, source: str) -> list[str]:
    """Extract type names from the raw text between the marker comments."""
    types: list[str] = []
    for line in block.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if not stripped.startswith("- "):
            raise ValueError(
                f"{source}: unexpected line inside types-list block: {line!r}"
            )
        types.append(stripped[2:].strip())
    return types


def normalize_types_in_readme(readme_path: str | Path) -> list[str]:
    """Validate, normalize, and load the authoritative `type` list from README.md.

    Trims leading/trailing whitespace from each item, deduplicates
    case-insensitively (preserving first-seen casing), and sorts the list
    case-insensitively. If the on-disk list differs from the normalized
    form, the README is rewritten in place. Returns the normalized list.

    Raises ValueError if the markers are missing, the block is empty, a
    line doesn't start with `- `, or two entries differ only by casing.
    """
    path = Path(readme_path)
    text = path.read_text(encoding="utf-8")
    m = _TYPES_LIST_RE.search(text)
    if not m:
        raise ValueError(
            f"{path}: missing <!-- types-list:start --> / <!-- types-list:end --> markers"
        )
    raw_types = _parse_types_block(m.group(1), str(path))
    if not raw_types:
        raise ValueError(f"{path}: types-list block is empty")

    seen: dict[str, str] = {}
    for t in raw_types:
        key = t.lower()
        if key in seen and seen[key] != t:
            raise ValueError(
                f"{path}: types-list contains case-conflicting entries: "
                f"{seen[key]!r} and {t!r}"
            )
        seen.setdefault(key, t)
    normalized = sorted(seen.values(), key=str.lower)

    if normalized != raw_types:
        new_block = "\n".join(f"- {t}" for t in normalized)
        replacement = f"<!-- types-list:start -->\n{new_block}\n<!-- types-list:end -->"
        new_text = text[: m.start()] + replacement + text[m.end() :]
        path.write_text(new_text, encoding="utf-8")
    return normalized


def load_types_from_readme(readme_path: str | Path) -> list[str]:
    """Read the authoritative `type` list out of README.md without rewriting.

    Use `normalize_types_in_readme` to additionally sort, dedupe, and
    rewrite the block in place. This thin wrapper is kept for callers
    that only want to read the list (e.g. tests, downstream tools).
    """
    path = Path(readme_path)
    text = path.read_text(encoding="utf-8")
    m = _TYPES_LIST_RE.search(text)
    if not m:
        raise ValueError(
            f"{path}: missing <!-- types-list:start --> / <!-- types-list:end --> markers"
        )
    types = _parse_types_block(m.group(1), str(path))
    if not types:
        raise ValueError(f"{path}: types-list block is empty")
    return types


class CSVValidationError(Exception):
    def __init__(self, errors: list[str]):
        super().__init__("\n".join(errors))
        self.errors = errors


def sort_csv(
    filepath: str | Path,
    field: str,
    *,
    sort_field_value_must_be_unique: bool = True,
    strip_whitespace: bool = True,
    fields_to_lowercase: Iterable[str] | None = None,
    case_insensitive_sort: bool = False,
    required_fields: Iterable[str] | None = None,
    allowed_values: Mapping[str, Collection[str]] | None = None,
) -> None:
    """
    Read a CSV, optionally normalize rows (strip whitespace, lowercase certain fields),
    validate field values, and write the sorted CSV back to the same path.

    - filepath: Path to the CSV to sort.
    - field: The field name to sort by.
    - fields_to_lowercase: Permanently lowercases these field(s) in the data.
    - strip_whitespace: Remove all whitespace at the beginning and of field values.
    - case_insensitive_sort: Ignore case when sorting without changing values.
    - required_fields: A list of fields that must have data in all rows.
    - allowed_values: A mapping of allowed values for fields.
    """
    path = Path(filepath)
    required_fields = set(required_fields or [])
    lower_set = set(fields_to_lowercase or [])
    allowed_sets = {k: set(v) for k, v in (allowed_values or {}).items()}
    seen_sort_field_values: list[str] = []

    with path.open("r", newline="") as infile:
        reader = csv.DictReader(infile)
        fieldnames = reader.fieldnames or []
        if field not in fieldnames:
            raise CSVValidationError([f"Missing sort column: {field!r}"])
        missing_headers = required_fields - set(fieldnames)
        if missing_headers:
            raise CSVValidationError(
                [f"Missing required header(s): {sorted(missing_headers)}"]
            )
        rows = list(reader)

    def normalize_row(row: dict[str, str]) -> None:
        if strip_whitespace:
            for k, v in row.items():
                if isinstance(v, str):
                    row[k] = v.strip()
        for fld in lower_set:
            if fld in row and isinstance(row[fld], str):
                row[fld] = row[fld].lower()

    def validate_row(
        row: dict[str, str], sort_field: str, line_no: int, errors: list[str]
    ) -> None:
        if sort_field_value_must_be_unique:
            if row[sort_field] in seen_sort_field_values:
                errors.append(f"Line {line_no}: Duplicate row for '{row[sort_field]}'")
            else:
                seen_sort_field_values.append(row[sort_field])
        for rf in required_fields:
            val = row.get(rf)
            if val is None or val == "":
                errors.append(
                    f"Line {line_no}: Missing value for required field '{rf}'"
                )
        for field, allowed_values in allowed_sets.items():
            if field in row:
                val = row[field]
                if val not in allowed_values:
                    errors.append(
                        f"Line {line_no}: '{val}' is not an allowed value for '{field}' "
                        f"(allowed: {sorted(allowed_values)})"
                    )

    errors: list[str] = []
    for idx, row in enumerate(rows, start=2):  # header is line 1
        normalize_row(row)
        validate_row(row, field, idx, errors)

    if errors:
        raise CSVValidationError(errors)

    def sort_key(r: dict[str, str]):
        v = r.get(field, "")
        if isinstance(v, str) and case_insensitive_sort:
            return v.casefold()
        return v

    rows.sort(key=sort_key)

    with open(filepath, "w", newline="") as outfile:
        writer = csv.DictWriter(outfile, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def sort_list_file(
    filepath: str | Path,
    *,
    lowercase: bool = True,
    strip: bool = True,
    deduplicate: bool = True,
    remove_blank_lines: bool = True,
    ending_newline: bool = True,
    newline: str | None = "\n",
):
    """Read a list from a file, sort it, optionally strip and deduplicate the values,
    then write that list back to the file.

    - Filepath: The path to the file.
    - lowercase: Lowercase all values prior to sorting.
    - remove_blank_lines: Remove any plank lines.
    - ending_newline: End the file with a newline, even if remove_blank_lines is true.
    - newline: The newline character to use.
    """
    with open(filepath, mode="r", newline=newline) as infile:
        lines = infile.readlines()
        for i in range(len(lines)):
            if lowercase:
                lines[i] = lines[i].lower()
            if strip:
                lines[i] = lines[i].strip()
        if deduplicate:
            lines = list(set(lines))
        if remove_blank_lines:
            while "" in lines:
                lines.remove("")
        lines = sorted(lines)
        if ending_newline:
            if lines[-1] != "":
                lines.append("")
    with open(filepath, mode="w", newline=newline) as outfile:
        outfile.write("\n".join(lines))


def _main():
    map_file = "base_reverse_dns_map.csv"
    map_key = "base_reverse_dns"
    list_files = ["known_unknown_base_reverse_dns.txt", "psl_overrides.txt"]
    readme_file = "README.md"

    if not os.path.exists(readme_file):
        print(f"Error: {readme_file} does not exist")
        exit(1)
    try:
        types = normalize_types_in_readme(readme_file)
    except ValueError as e:
        print(f"Error: {e}")
        exit(1)

    map_allowed_values = {"type": types}

    for list_file in list_files:
        if not os.path.exists(list_file):
            print(f"Error: {list_file} does not exist")
            exit(1)
        sort_list_file(list_file)
    if not os.path.exists(map_file):
        print(f"Error: {map_file} does not exist")
        exit(1)
    try:
        sort_csv(
            map_file,
            map_key,
            case_insensitive_sort=True,
            allowed_values=map_allowed_values,
        )
    except CSVValidationError as e:
        print(f"{map_file} did not validate: {e}")


if __name__ == "__main__":
    _main()
