#!/usr/bin/env python3
"""ASUCB Content Remediation — pure deterministic fixers.

Each fixer is:
    def fix_x(html: str) -> tuple[str, int]

returning (new_html, n_changes). All fixers are pure (no I/O) and idempotent
(running twice yields 0 further changes on the already-fixed output).

No third-party deps: stdlib ``re`` and ``html.parser`` only.

Fixers
------
fix_dashes(html)
    Replace U+2014 (em dash) and U+2013 (en dash) — and their HTML entity
    forms — with contextually appropriate punctuation:
      • Inside <h1..h6>: dash → colon; but if the heading already contains a
        colon, use comma instead (avoids "Lesson 0.8: Long and Short: Skill").
      • Body text: dash → comma.
    NEVER touches U+002D (hyphen) or U+2212 (minus sign U+2212).

fix_heading_title_case(html)
    Lowercase APA minor words mid-title in h1-h4 and <summary> headings.
    Uses the exact MINOR_WORDS set from scripts/apa_title_case.py.

fix_table_row_background(html)
    Move background/background-color (and color) from <tr style="..."> onto
    each child <th>/<td> that lacks its own background. Mirrors the condition
    in preflight_checks._style_has_background.

fix_ai_slop_binary_contrast(html)
    DOCUMENTED NO-OP. Rephrasing "not X, but Y" -> "Y" is semantic and unsafe
    to automate. Returns (html, 0) always. Listed so callers know it is
    intentionally a no-op.
"""

import re
import sys

# ── Constants ──────────────────────────────────────────────────────────────

# Literal em/en dash characters targeted by fix_dashes.
EM_DASH = "—"   # —
EN_DASH = "–"   # –
MINUS   = "−"   # − (MUST NEVER be touched)
HYPHEN  = "-"   # - (MUST NEVER be touched)

# All tokens that fix_dashes replaces (characters + HTML entities).
_DASH_TOKENS = [EM_DASH, EN_DASH, "&mdash;", "&ndash;"]

# Heading tags that fix_dashes treats specially (-> colon / comma).
_HEADING_RE = re.compile(
    r"(<h[1-6]\b[^>]*>)(.*?)(</h[1-6]>)",
    re.DOTALL | re.I,
)

# For fix_dashes body replacement: surround-whitespace-optional dash -> ", "
_BODY_DASH_PATTERN = r"\s*(?:" + "|".join(re.escape(t) for t in _DASH_TOKENS) + r")\s*"
_BODY_DASH_RE = re.compile(_BODY_DASH_PATTERN)

# ── Minor word list (must match apa_title_case.MINOR_WORDS exactly) ────────

MINOR_WORDS = {
    # articles
    "a", "an", "the",
    # coordinating conjunctions
    "and", "but", "or", "nor", "so", "yet", "for",
    # short prepositions (<=3 letters)
    "as", "at", "by", "in", "of", "off", "on", "per", "to", "up", "via", "vs",
}

# Heading tags linted by fix_heading_title_case.
_TC_HEADING_RE = re.compile(
    r"(<(?:h[1-4]|summary)\b[^>]*>)(.*?)(</(?:h[1-4]|summary)>)",
    re.DOTALL | re.I,
)

# Strip-chars used when judging a word token (mirrors apa_title_case).
_STRIP_CHARS = "\"'“”‘’()[]{}.,:;!?…—–-"

# ── Table row background regexes (mirrors preflight_checks._TR_WITH_BG) ───

_TR_WITH_BG = re.compile(
    r"(<tr\b[^>]*\bstyle\s*=\s*)([\"'])(?P<style>.*?)\2([^>]*>)",
    re.DOTALL | re.I,
)
_CELL_OPEN = re.compile(r"(<(?:th|td)\b[^>]*>)", re.DOTALL | re.I)
_CELL_STYLE_ATTR = re.compile(r'\bstyle\s*=\s*(["\'])(.*?)\1', re.DOTALL | re.I)


# =========================================================================
# Helpers
# =========================================================================

def _style_has_background(style: str) -> bool:
    """True if an inline style declares a non-empty/non-transparent background.

    Mirrors preflight_checks._style_has_background exactly.
    """
    for m in re.finditer(r"background(?:-color)?\s*:\s*([^;]+)", style, re.I):
        value = m.group(1).strip()
        if value and value.lower() not in ("none", "transparent", "inherit", "initial", "unset"):
            return True
    return False


def _extract_bg_declarations(style: str):
    """Split an inline style string into (bg_part, rest_part).

    bg_part: background and color declarations to move onto cells.
    rest_part: the remaining declarations to keep on the <tr>.
    """
    bg_decls = []
    rest_decls = []
    for decl in re.split(r";", style):
        decl_stripped = decl.strip()
        if not decl_stripped:
            continue
        prop = decl_stripped.split(":")[0].strip().lower()
        if prop in ("background", "background-color", "color"):
            bg_decls.append(decl_stripped)
        else:
            rest_decls.append(decl_stripped)
    return "; ".join(bg_decls), "; ".join(rest_decls)


def _inject_style_into_cell(cell_tag: str, extra_style: str) -> str:
    """Return cell_tag with extra_style prepended to its inline style."""
    sm = _CELL_STYLE_ATTR.search(cell_tag)
    if sm:
        q = sm.group(1)
        existing = sm.group(2).strip().rstrip(";")
        new_style = (extra_style + "; " + existing).strip("; ")
        return cell_tag[:sm.start()] + f"style={q}{new_style}{q}" + cell_tag[sm.end():]
    # No existing style attribute: insert one before the closing >.
    return cell_tag[:-1] + f' style="{extra_style}">'


# =========================================================================
# fix_dashes
# =========================================================================

def fix_dashes(html: str):
    """Replace em/en dashes (U+2014, U+2013, &mdash;, &ndash;) in HTML.

    Rules:
      * Inside <h1..h6>: dash (with optional surrounding whitespace) -> ": ".
        If the heading text (tags stripped) already contains a literal ":",
        use ", " instead to avoid double-colon.
      * Elsewhere in the document: surrounding-whitespace-optional dash -> ", ".

    NEVER modifies U+002D (hyphen) or U+2212 (minus sign).

    Returns:
        (new_html, n_changes): n_changes is the total number of dash tokens
        replaced (each occurrence counts as 1).

    Postconditions (asserted):
      - Count of U+2212 is unchanged.
      - 0 em/en dashes remain (literal or entity).
    """
    if not html:
        return html, 0

    minus_before = html.count(MINUS)
    n = [0]  # use list so nested function can mutate

    def _replace_heading(m):
        open_tag, inner, close_tag = m.group(1), m.group(2), m.group(3)
        # Strip inner tags to check for existing colon.
        inner_text = re.sub(r"<[^>]+>", "", inner)
        has_colon = ":" in inner_text
        replacement = ", " if has_colon else ": "
        # Count occurrences before replacing.
        count = sum(inner.count(t) for t in _DASH_TOKENS)
        n[0] += count
        new_inner = inner
        for tok in _DASH_TOKENS:
            new_inner = re.sub(
                r"\s*" + re.escape(tok) + r"\s*",
                replacement,
                new_inner,
            )
        return open_tag + new_inner + close_tag

    new_html = _HEADING_RE.sub(_replace_heading, html)

    # Body pass: replace remaining dashes.
    def _replace_body(m):
        n[0] += 1
        return ", "

    new_html = _BODY_DASH_RE.sub(_replace_body, new_html)

    # Postconditions.
    minus_after = new_html.count(MINUS)
    assert minus_after == minus_before, (
        f"fix_dashes altered minus (U+2212) count: {minus_before} -> {minus_after}"
    )
    remaining = sum(new_html.count(t) for t in _DASH_TOKENS)
    assert remaining == 0, (
        f"fix_dashes left {remaining} em/en dash token(s) in output"
    )

    return new_html, n[0]


# =========================================================================
# fix_heading_title_case
# =========================================================================

def _has_alpha(token: str) -> bool:
    return any(c.isalpha() for c in token)


def _strip_core(word: str) -> str:
    return word.strip(_STRIP_CHARS)


def _is_acronym(core: str) -> bool:
    """All-caps token of 2+ letters (ASU, VWAP, US, AI) — leave as-is."""
    letters = [c for c in core if c.isalpha()]
    return len(letters) >= 2 and all(c.isupper() for c in letters)


def _apply_title_case(text: str):
    """Apply APA title-case to a plain-text string.

    Returns (new_text, changed).
    """
    tokens = text.split()
    if not tokens:
        return text, False

    out = []
    n = len(tokens)
    for i, tok in enumerate(tokens):
        core = _strip_core(tok)
        if not _has_alpha(core):
            out.append(tok)
            continue
        lower_core = core.lower()
        is_first = i == 0
        is_last = i == n - 1
        # After-colon/dash: capitalize next word.
        # Test the last char of the RAW previous token so that "5:" (which
        # strips to "5") still triggers force-capitalize for the next word.
        after_delimiter = (
            i > 0 and bool(re.search(r"[:\-—–]", tokens[i - 1][-1]))
        )
        # Acronyms (VWAP, US, AI, ASU) are never touched — preserve as-is.
        if _is_acronym(core):
            out.append(tok)
            continue
        if is_first or is_last or after_delimiter or lower_core not in MINOR_WORDS:
            # Capitalize: preserve surrounding stripped chars.
            prefix = tok[: len(tok) - len(tok.lstrip(_STRIP_CHARS))]
            suffix = tok[len(tok.rstrip(_STRIP_CHARS)):]
            cap = core[0].upper() + core[1:] if core else core
            out.append(prefix + cap + suffix)
        else:
            prefix = tok[: len(tok) - len(tok.lstrip(_STRIP_CHARS))]
            suffix = tok[len(tok.rstrip(_STRIP_CHARS)):]
            out.append(prefix + lower_core + suffix)

    new_text = " ".join(out)
    return new_text, new_text != text


def fix_heading_title_case(html: str):
    """Lower-case APA minor words in h1-h4 and <summary> headings.

    Only the visible text tokens inside heading tags are modified; HTML markup
    inside headings is preserved. Idempotent: a second pass returns 0 changes.

    Returns:
        (new_html, n_headings_changed)
    """
    if not html:
        return html, 0

    n = [0]

    def _fix_heading(m):
        open_tag, inner, close_tag = m.group(1), m.group(2), m.group(3)
        # Split inner HTML into tag vs text segments; apply title case to text.
        parts = re.split(r"(<[^>]+>)", inner)
        new_parts = []
        heading_changed = False
        for part in parts:
            if part.startswith("<"):
                new_parts.append(part)
            else:
                new_part, changed = _apply_title_case(part)
                new_parts.append(new_part)
                if changed:
                    heading_changed = True
        if heading_changed:
            n[0] += 1
        return open_tag + "".join(new_parts) + close_tag

    new_html = _TC_HEADING_RE.sub(_fix_heading, html)
    return new_html, n[0]


# =========================================================================
# fix_table_row_background
# =========================================================================

def fix_table_row_background(html: str):
    """Move background/background-color (+ color) from <tr> onto each <td>/<th>.

    Mirrors the condition in preflight_checks._style_has_background and
    check_table_header_contrast. For each <tr style="...background..."> whose
    child cells lack their own background:
      1. Extract background/color declarations from the <tr> style.
      2. Prepend those declarations to each child <th>/<td> that lacks a bg.
      3. Remove the moved declarations from the <tr> style (keep others).

    Idempotent: if cells already carry their own background, the row is skipped.

    Returns:
        (new_html, n_rows_fixed)
    """
    if not html or "<tr" not in html.lower():
        return html, 0

    n = 0
    result = []
    pos = 0

    for tr_match in _TR_WITH_BG.finditer(html):
        style = tr_match.group("style")
        if not _style_has_background(style):
            result.append(html[pos:tr_match.end()])
            pos = tr_match.end()
            continue

        row_start = tr_match.end()
        close_m = re.search(r"</tr\s*>", html[row_start:], re.I)
        if not close_m:
            result.append(html[pos:tr_match.end()])
            pos = tr_match.end()
            continue

        row_end = row_start + close_m.end()
        row_inner = html[row_start: row_start + close_m.start()]

        cells = list(_CELL_OPEN.finditer(row_inner))
        if not cells:
            result.append(html[pos:row_end])
            pos = row_end
            continue

        def _cell_has_bg(cell_tag):
            sm = _CELL_STYLE_ATTR.search(cell_tag)
            return bool(sm) and _style_has_background(sm.group(2))

        if all(_cell_has_bg(c.group(1)) for c in cells):
            result.append(html[pos:row_end])
            pos = row_end
            continue

        bg_part, rest_part = _extract_bg_declarations(style)
        if not bg_part:
            result.append(html[pos:row_end])
            pos = row_end
            continue

        # Rebuild <tr> open tag with background stripped.
        # If rest_part is empty, drop the style attribute entirely so we don't
        # leave a bare style="" on the element.
        q = tr_match.group(2)
        if rest_part:
            new_tr_open = tr_match.group(1) + q + rest_part + q + tr_match.group(4)
        else:
            # Remove the style="..." group entirely from the <tr> open tag.
            # group(1) is everything up to and including the opening quote of style=,
            # minus the "style =" portion; reconstruct without the style attr.
            raw_tr_open = tr_match.group(0)
            new_tr_open = re.sub(
                r'\s*\bstyle\s*=\s*' + re.escape(q) + re.escape(style) + re.escape(q),
                "",
                raw_tr_open,
            )

        # Inject bg into cells that lack it.
        new_row_inner = row_inner
        offset = 0
        for c in cells:
            cell_tag = c.group(1)
            if _cell_has_bg(cell_tag):
                continue
            new_cell_tag = _inject_style_into_cell(cell_tag, bg_part)
            c_start = c.start() + offset
            c_end = c.end() + offset
            new_row_inner = new_row_inner[:c_start] + new_cell_tag + new_row_inner[c_end:]
            offset += len(new_cell_tag) - len(cell_tag)

        result.append(html[pos:tr_match.start()])
        result.append(new_tr_open)
        result.append(new_row_inner)
        result.append(html[row_start + close_m.start(): row_end])
        pos = row_end
        n += 1

    result.append(html[pos:])
    return "".join(result), n


# =========================================================================
# fix_ai_slop_binary_contrast — documented no-op
# =========================================================================

def fix_ai_slop_binary_contrast(html: str):
    """Documented no-op. Returns (html, 0) always.

    Why: Rephrasing "not X, but Y" -> "Y" is a semantic rewrite that requires
    understanding which clause is the substantive claim vs. the rhetorical
    negation. Automating this risks inverting the author's intent or silently
    removing valid contrastive structure in academic prose (e.g., "not
    procedural, but substantive due process"). Detection is handled by
    preflight_checks.check_slop; remediation stays manual per
    docs/plan-content-remediation.md section A.

    Callers that iterate all fixers should still call this so that the
    binary-contrast criterion appears in per-fixer reports with count 0.
    """
    return html, 0


# =========================================================================
# CLI shim
# =========================================================================

if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser(description="Apply content fixers to an HTML file.")
    p.add_argument("file", nargs="?", help="HTML file (default: stdin)")
    p.add_argument(
        "--fixer",
        choices=["dashes", "title-case", "table-bg", "all"],
        default="all",
    )
    args = p.parse_args()

    src = open(args.file, encoding="utf-8").read() if args.file else sys.stdin.read()
    if args.fixer in ("dashes", "all"):
        src, cnt = fix_dashes(src)
        print(f"fix_dashes: {cnt} change(s)", file=sys.stderr)
    if args.fixer in ("title-case", "all"):
        src, cnt = fix_heading_title_case(src)
        print(f"fix_heading_title_case: {cnt} change(s)", file=sys.stderr)
    if args.fixer in ("table-bg", "all"):
        src, cnt = fix_table_row_background(src)
        print(f"fix_table_row_background: {cnt} change(s)", file=sys.stderr)
    print(src, end="")
