#!/usr/bin/env python3
"""Genera la tabla fecha -> entrada del libro (book_index 1-500) + ciclo A/B/C
para el shortcode del Evangelio de cada día, 2026-2040.

Reescritura completa (issue #20) tras retirar el commit 2713e13: aquella
version numeraba los domingos con un desfase de -1, contaba Cuaresma en
bloques de 7 desde Ceniza, evaluaba Adviento antes que Navidad (fuga el
26-31 de diciembre), y dejaba sin clave propia Bautismo/Ascension/Familia de
Nazaret/Cristo Rey, ademas de dar precedencia a fiestas fijas (Anunciacion,
San Juan Bautista...) sobre domingos y hasta sobre la Pascua misma.

Diseño distinto a proposito: en vez de una clave de texto libre (que hay que
mantener sincronizada a mano con el vocabulario del libro), cada fecha
referencia directamente el `source_index` (1-500) de
data/evangelio-diario/entradas-libro.json. Los tests de este fichero
comprueban cobertura bidireccional contra esas 500 entradas.

Fuentes:
- Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente
  de cualquier tabla publicada.
- Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de
  precedencia (Adviento/Cuaresma/Pascua > solemnidades fijas; entre
  solemnidades fijas del Señor > de un Santo) y regla especifica de traslado
  de la Anunciación.
- Practica de la CEE: Ascensión y Corpus Christi trasladados al domingo.
- CEE, "Tabla temporal de las principales celebraciones del Año Litúrgico
  2021-2044" (referencia general, NO la unica fuente usada para validar --
  ver issue #20 para el detalle de fuentes cruzadas independientes).
"""
from __future__ import annotations

from datetime import date, timedelta
import json
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
OUT_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"

YEAR_FROM = 2026
YEAR_TO = 2040


def easter_sunday(year: int) -> date:
    a = year % 19
    b = year // 100
    c = year % 100
    d_ = b // 4
    e = b % 4
    f = (b + 8) // 25
    g = (b - f + 1) // 3
    h = (19 * a + b - d_ - g + 15) % 30
    i = c // 4
    k = c % 4
    l = (32 + 2 * e + 2 * i - h - k) % 7
    m = (a + 11 * h + 22 * l) // 451
    month = (h + l - 7 * m + 114) // 31
    day = ((h + l - 7 * m + 114) % 31) + 1
    return date(year, month, day)


def prev_sunday_on_or_before(d: date) -> date:
    return d - timedelta(days=(d.weekday() + 1) % 7)


def next_sunday_on_or_after(d: date) -> date:
    return d + timedelta(days=(6 - d.weekday()) % 7)


def cycle_for_civil_year(y: int) -> str:
    return {1: "A", 2: "B", 0: "C"}[y % 3]


class YearFacts:
    """Fechas moviles/fijas derivadas de la Pascua y de Navidad para el año
    civil `y`. El tramo de Adviento/Navidad de diciembre de `y` referencia el
    ciclo A/B/C del año litúrgico SIGUIENTE (y+1), que es el que empieza."""

    def __init__(self, y: int):
        self.y = y
        self.easter = easter_sunday(y)
        self.ash_wed = self.easter - timedelta(days=46)
        self.palm_sunday = self.easter - timedelta(days=7)
        self.holy_mon = self.easter - timedelta(days=6)
        self.holy_tue = self.easter - timedelta(days=5)
        self.holy_wed = self.easter - timedelta(days=4)
        self.holy_thu = self.easter - timedelta(days=3)
        self.good_fri = self.easter - timedelta(days=2)
        self.holy_sat = self.easter - timedelta(days=1)
        self.pentecost = self.easter + timedelta(days=49)
        self.trinity = self.pentecost + timedelta(days=7)
        self.corpus = self.trinity + timedelta(days=7)      # trasladado a domingo (CEE)
        self.ascension = self.easter + timedelta(days=42)    # trasladada al 7º domingo (CEE)

        self.epiphany = date(y, 1, 6)
        self.baptism = (
            self.epiphany + timedelta(days=1)
            if self.epiphany.weekday() == 6
            else next_sunday_on_or_after(self.epiphany)
        )

        xmas_prev = date(y - 1, 12, 25)
        self.holy_family = (
            date(y - 1, 12, 30)
            if xmas_prev.weekday() == 6
            else next_sunday_on_or_after(xmas_prev)
        )

        cand = next_sunday_on_or_after(date(y, 1, 2))
        self.dom2_navidad = cand if cand.day <= 5 else None

        self.christmas = date(y, 12, 25)
        sun_before_xmas_eve = prev_sunday_on_or_before(self.christmas - timedelta(days=1))
        self.advent1 = sun_before_xmas_eve - timedelta(days=21)
        self.advent2 = self.advent1 + timedelta(days=7)
        self.advent3 = self.advent1 + timedelta(days=14)
        self.advent4 = self.advent1 + timedelta(days=21)
        self.christ_king = self.advent1 - timedelta(days=7)

        # Numeracion de Tiempo Ordinario con omision de semanas (norma real:
        # se cuenta hacia atras desde Cristo Rey=semana 34). L = ultima semana
        # de TO alcanzada (entera o parcial) antes de Ceniza.
        self.week2_sunday = self.baptism + timedelta(days=7)
        self.L = 2 + (self.ash_wed - self.week2_sunday).days // 7
        first_post = self.corpus + timedelta(days=7)
        self.K = (self.christ_king - first_post).days // 7 + 1
        self.resume_week = 35 - self.K


def lent_week_base(n: int) -> int:
    return {1: 145, 2: 154, 3: 163, 4: 172, 5: 181}[n]


def ot_week_base(n: int) -> int:
    """idx base [DomA,DomB,DomC,Lun..Sab] de la semana N de TO (2..34)."""
    if 2 <= n <= 9:
        return 69 + (n - 2) * 9
    if 10 <= n <= 33:
        return 265 + (n - 10) * 9
    if n == 34:
        return 481
    raise ValueError(f"semana de TO fuera de rango: {n}")


CYC_OFFSET = {"A": 0, "B": 1, "C": 2}


def build_calendar(year_from: int, year_to: int) -> dict[str, dict]:
    facts = {y: YearFacts(y) for y in range(year_from - 1, year_to + 2)}
    out: dict[str, dict] = {}

    def set_day(d: date, idx: int, cyc: str, note: str) -> None:
        iso = d.isoformat()
        if iso in out:
            raise RuntimeError(
                f"colision interna en {iso}: ya tenia idx={out[iso]['idx']} "
                f"({out[iso]['note']}), se queria poner idx={idx} ({note})"
            )
        out[iso] = {"idx": idx, "cycle": cyc, "note": note}

    for y in range(year_from, year_to + 1):
        f = facts[y]
        cyc = cycle_for_civil_year(y)

        # -- Tiempo Ordinario semana 1 (tras el Bautismo, sin domingo propio) --
        wk1_start = f.baptism + timedelta(days=1)
        for i in range(6):
            set_day(wk1_start + timedelta(days=i), 63 + i, cyc, "TOwk1")

        # -- Tiempo Ordinario semanas 2..L (antes de Ceniza) --
        for n in range(2, f.L + 1):
            base = ot_week_base(n)
            sun = f.week2_sunday + timedelta(days=(n - 2) * 7)
            set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Sun")
            for i in range(6):
                d = sun + timedelta(days=1 + i)
                if d >= f.ash_wed:
                    break
                set_day(d, base + 3 + i, cyc, f"TOwk{n}dia{i}")

        # -- Cuaresma --
        set_day(f.ash_wed, 141, cyc, "Ceniza")
        for i, idx in enumerate((142, 143, 144)):
            set_day(f.ash_wed + timedelta(days=1 + i), idx, cyc, "post-ceniza")
        for n in range(1, 6):
            base = lent_week_base(n)
            sun = f.easter - timedelta(days=49 - 7 * n)
            set_day(sun, base + CYC_OFFSET[cyc], cyc, f"Cuaresma{n}Dom")
            for i in range(6):
                set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"Cuaresma{n}dia{i}")

        # -- Semana Santa / Triduo --
        set_day(f.palm_sunday, 190 + CYC_OFFSET[cyc], cyc, "Ramos")
        for d, idx in ((f.holy_mon, 193), (f.holy_tue, 194), (f.holy_wed, 195), (f.holy_thu, 196)):
            set_day(d, idx, cyc, "SemanaSanta")
        set_day(f.good_fri, 197, cyc, "ViernesSanto")
        set_day(f.holy_sat, 198, cyc, "VigiliaPascual")

        # -- Pascua --
        set_day(f.easter, 199, cyc, "Resurreccion")
        for i in range(6):
            set_day(f.easter + timedelta(days=1 + i), 200 + i, cyc, "OctavaPascua")
        set_day(f.easter + timedelta(days=7), 206, cyc, "DomIIPascua")
        pascua_blocks = [(207, 213), (216, 222), (225, 231), (234, 240)]
        for wi, (mon_base, sun_base) in enumerate(pascua_blocks):
            prev_sun = f.easter + timedelta(days=7 * (wi + 1))
            for i in range(6):
                set_day(prev_sun + timedelta(days=1 + i), mon_base + i, cyc, f"Pascua{wi}")
            sun = prev_sun + timedelta(days=7)
            set_day(sun, sun_base + CYC_OFFSET[cyc], cyc, f"PascuaDom{wi}")
        wk7_mon = f.easter + timedelta(days=7 * 5 + 1)
        for i in range(6):
            set_day(wk7_mon + timedelta(days=i), 243 + i, cyc, "Pascua wk7")
        set_day(f.ascension, 249 + CYC_OFFSET[cyc], cyc, "Ascension")
        wk8_mon = f.ascension + timedelta(days=1)
        for i in range(6):
            d = wk8_mon + timedelta(days=i)
            if d >= f.pentecost:
                break
            set_day(d, 252 + i, cyc, "Pascua wk8")
        set_day(f.pentecost, 258, cyc, "Pentecostes")
        set_day(f.trinity, 259 + CYC_OFFSET[cyc], cyc, "Trinidad")
        set_day(f.corpus, 262 + CYC_OFFSET[cyc], cyc, "Corpus")

        # Las 3 semanas "tapadas" por Pentecostes/Trinidad/Corpus: el TO
        # arranca el lunes despues de Pentecostes (norma real); solo el
        # domingo de cada una de esas 3 semanas lo tapa la solemnidad, sus
        # ferias Lun-Sab SI son de Tiempo Ordinario.
        first_post = f.corpus + timedelta(days=7)
        for gap_i, gap_sunday in enumerate((f.pentecost, f.trinity, f.corpus)):
            gap_n = f.resume_week - 3 + gap_i
            gap_base = ot_week_base(gap_n)
            for i in range(6):
                set_day(gap_sunday + timedelta(days=1 + i), gap_base + 3 + i, cyc, f"TOwk{gap_n}gap")

        # -- Tiempo Ordinario semanas resume_week..34 --
        for n in range(f.resume_week, 35):
            sun = first_post + timedelta(days=(n - f.resume_week) * 7)
            if n == 34:
                set_day(sun, 481 + CYC_OFFSET[cyc], cyc, "CristoRey")
                for i in range(6):
                    set_day(sun + timedelta(days=1 + i), 484 + i, cyc, "TOwk34dia")
            else:
                base = ot_week_base(n)
                set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Dom-post")
                for i in range(6):
                    set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"TOwk{n}dia-post")

        # -- Adviento (ciclo del año litúrgico que empieza, y+1) --
        cyc_next = cycle_for_civil_year(y + 1)
        set_day(f.advent1, 1 + CYC_OFFSET[cyc_next], cyc_next, "Adviento1")
        set_day(f.advent2, 10 + CYC_OFFSET[cyc_next], cyc_next, "Adviento2")
        set_day(f.advent3, 19 + CYC_OFFSET[cyc_next], cyc_next, "Adviento3")
        set_day(f.advent4, 35 + CYC_OFFSET[cyc_next], cyc_next, "Adviento4")
        # ferias genericas de Adviento (semanas 1-3), consumidas dia a dia
        # (no-domingo) desde adviento1+1 hasta el 16 de diciembre inclusive.
        pool = [4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 22, 23, 24, 25, 26]
        pi = 0
        d = f.advent1 + timedelta(days=1)
        cutover = date(y, 12, 17)
        while d < cutover:
            if d.weekday() != 6:
                if pi >= len(pool):
                    raise RuntimeError(f"pool de ferias de Adviento agotado en {d.isoformat()}")
                set_day(d, pool[pi], cyc_next, "AdvientoFeria")
                pi += 1
            d += timedelta(days=1)
        # 17..23 de diciembre por fecha fija.
        fixed_dec = {17: 27, 18: 28, 19: 29, 20: 30, 21: 31, 22: 32, 23: 33}
        for day_num, idx in fixed_dec.items():
            d = date(y, 12, day_num)
            if d.weekday() == 6:
                continue  # ya asignado como domingo de Adviento arriba
            set_day(d, idx, cyc_next, f"{day_num}dic")
        # 24 de diciembre: SIEMPRE Nochebuena (idx38) salvo que sea Adviento4
        # (domingo). La feria generica "24 diciembre" (idx34) por tanto no se
        # usa nunca con este diseño -- decision de contenido a confirmar, ver
        # nota en el JSON de salida.
        d24 = date(y, 12, 24)
        if d24.weekday() != 6:
            set_day(d24, 38, cyc_next, "Nochebuena")
        set_day(date(y, 12, 25), 39, cyc_next, "Navidad")

    # -- 26 dic .. Bautismo, atraviesa el limite de año civil --
    for y in range(year_from - 1, year_to + 1):
        cyc_adv = cycle_for_civil_year(y + 1)
        fnext = facts[y + 1]
        holy_family = fnext.holy_family
        fixed_oct = {26: 41, 27: 42, 28: 43, 29: 44, 30: 45, 31: 46}
        for day_num, idx in fixed_oct.items():
            d = date(y, 12, day_num)
            if d == holy_family:
                set_day(d, 40, cyc_adv, "SagradaFamilia")
            else:
                set_day(d, idx, cyc_adv, f"{day_num}dic-octava")

        ny = y + 1
        if ny > year_to + 1:
            continue
        fb = facts[ny]
        cyc_ny = cycle_for_civil_year(ny)
        set_day(date(ny, 1, 1), 47, cyc_adv, "MariaMadre")
        dom2 = fb.dom2_navidad
        for day_num in (2, 3, 4, 5):
            d = date(ny, 1, day_num)
            if dom2 is not None and d == dom2:
                set_day(d, 48, cyc_adv, "DomIINavidad")
            else:
                idx = {2: 49, 3: 50, 4: 51, 5: 52}[day_num]
                set_day(d, idx, cyc_adv, f"{day_num}ene")
        set_day(fb.epiphany, 53, cyc_ny, "Epifania")
        set_day(fb.baptism, 54 + CYC_OFFSET[cyc_ny], cyc_ny, "Bautismo")
        d = fb.epiphany + timedelta(days=1)
        fixed_ene = {7: 57, 8: 58, 9: 59, 10: 60, 11: 61, 12: 62}
        while d < fb.baptism:
            if d.day in fixed_ene:
                set_day(d, fixed_ene[d.day], cyc_ny, f"{d.day}ene")
            d += timedelta(days=1)

    return out, facts


def apply_fixed_feasts(out: dict, facts: dict, year_from: int, year_to: int) -> list[tuple[str, str, int]]:
    """Aplica las 11 solemnidades de fecha fija del libro (8dic..2nov) con
    precedencia real: ceden ante domingos de Adviento/Cuaresma/Pascua, Ramos,
    lunes-jueves de Semana Santa, Triduo, Ascension y Pentecostes (rank1), y
    ante Trinidad/Corpus/Cristo Rey (rank2 "del Señor" > rank2 "de un
    Santo"). En cualquier otro dia (feria ordinaria) gana la fija."""
    fixed = {
        (12, 8): 490, (3, 19): 491, (3, 25): 492, (6, 24): 493, (6, 29): 494,
        (7, 22): 495, (7, 25): 496, (8, 6): 497, (8, 15): 498, (11, 1): 499, (11, 2): 500,
    }

    def rank1_days(f) -> set[date]:
        s = {f.ash_wed, f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed,
             f.holy_thu, f.good_fri, f.holy_sat, f.easter, f.pentecost,
             f.advent1, f.advent2, f.advent3, f.advent4}
        for n in range(1, 6):
            s.add(f.easter - timedelta(days=49 - 7 * n))
        for i in range(6):
            s.add(f.easter + timedelta(days=1 + i))
        for n in range(1, 7):
            s.add(f.easter + timedelta(days=7 * n))
        return s

    def rank2_movable_days(f) -> set[date]:
        return {f.trinity, f.corpus, f.christ_king}

    transferred: list[tuple[str, str, int]] = []
    for y in range(year_from, year_to + 1):
        f = facts[y]
        r1 = rank1_days(f)
        r2m = rank2_movable_days(f)
        for (mo, da), idx in fixed.items():
            d = date(y, mo, da)
            iso = d.isoformat()
            if iso not in out:
                continue
            if d not in r1 and d not in r2m:
                out[iso] = {"idx": idx, "cycle": out[iso]["cycle"], "note": "fija-normal"}
                continue
            loser_note = out[iso]["note"]
            cyc = out[iso]["cycle"]
            if idx == 492:  # Anunciacion: regla especifica documentada
                if d in {f.easter} or any(d == f.easter + timedelta(days=i) for i in range(1, 7)) \
                        or d in {f.easter + timedelta(days=7 * n) for n in range(1, 7)} \
                        or d in {f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed, f.holy_thu}:
                    target = f.easter + timedelta(days=8)  # lunes tras el 2º domingo de Pascua
                else:
                    target = d + timedelta(days=1)  # domingo de Cuaresma -> dia siguiente
            else:
                target = d + timedelta(days=1)  # norma general: dia siguiente libre
            tries = 0
            while target in r1 or target in r2m or (
                target.isoformat() in out and out[target.isoformat()]["idx"] >= 490
            ):
                target += timedelta(days=1)
                tries += 1
                if tries > 15:
                    raise RuntimeError(f"no se encontro hueco para trasladar idx={idx} desde {iso}")
            set_iso = target.isoformat()
            out[set_iso] = {"idx": idx, "cycle": cyc, "note": f"TRASLADADA de {iso} ({loser_note})"}
            transferred.append((iso, set_iso, idx))
    return transferred


def coverage_report(out: dict) -> tuple[list[int], list[int]]:
    book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
    used = {v["idx"] for v in out.values()}
    invalid = sorted(i for i in used if i < 1 or i > len(book))
    never_used = sorted(set(range(1, len(book) + 1)) - used)
    return invalid, never_used


def main() -> None:
    out, facts = build_calendar(YEAR_FROM, YEAR_TO)
    transferred = apply_fixed_feasts(out, facts, YEAR_FROM, YEAR_TO)
    out = {k: v for k, v in sorted(out.items()) if f"{YEAR_FROM}-01-01" <= k <= f"{YEAR_TO}-12-31"}

    expected_days = (date(YEAR_TO, 12, 31) - date(YEAR_FROM, 1, 1)).days + 1
    assert len(out) == expected_days, f"faltan fechas: {len(out)} != {expected_days}"

    invalid, never_used = coverage_report(out)
    assert not invalid, f"indices fuera de 1-500: {invalid}"

    dates_out = {k: {"book_index": v["idx"], "cycle": v["cycle"]} for k, v in out.items()}
    result = {
        "schema": 2,
        "range": {"from": f"{YEAR_FROM}-01-01", "to": f"{YEAR_TO}-12-31"},
        "sources": [
            "https://www.conferenciaepiscopal.es/tabla-temporal-celebraciones-ano-liturgico-2021-2044/",
            "Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente de tablas publicadas",
            "Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de precedencia y traslados",
        ],
        "notes": [
            "Cada fecha referencia book_index (1-500) sobre entradas-libro.json, no una clave de texto libre.",
            "TO: numeracion con omision de semanas segun la norma real (cuenta atras desde Cristo Rey=semana34).",
            "Ascension y Corpus trasladados al domingo (CEE); Bautismo/Sagrada Familia/Cristo Rey con indice propio.",
            "Solemnidades fijas con logica de precedencia real (Adviento/Cuaresma/Pascua/Ramos/Triduo/Ascension/"
            "Pentecostes ganan; entre solemnidades 'del Señor' > 'de un Santo'; Anunciacion con regla especifica).",
            f"idx nunca alcanzado en {YEAR_FROM}-{YEAR_TO}: {never_used} (aritmetica real del rango, ver issue #20; "
            "idx34 '24 diciembre' es ademas una decision de contenido: Nochebuena(38) gana siempre el 24-dic).",
        ],
        "dates": dates_out,
    }
    OUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    print(f"escritas {len(dates_out)} fechas en {OUT_PATH}")
    print(f"solemnidades fijas trasladadas: {len(transferred)}")
    for t in transferred:
        print(" ", t)
    print(f"indices del libro nunca alcanzados en {YEAR_FROM}-{YEAR_TO}: {never_used}")


if __name__ == "__main__":
    main()
