"""Tasas de política monetaria: facilidad de depósito del BCE y objetivo de fondos del FOMC.

Protocolo §4. Todas las tasas son fracciones anuales (2.00 % = 0.02).

- BCE (archivo 5): las fechas son de entrada en vigor, así que la tasa vigente en una barra es la
  última observación con fecha igual o anterior a la fecha de la barra.
- FOMC (archivos 3 y 4): la tabla da la fecha de la decisión, normalmente anunciada después del
  fijo del mediodía; la tasa vigente en una barra es la de la última decisión con fecha anterior
  a la de la barra. Desde 2008-12-16 el objetivo es un rango y se usa su punto medio.

Cualquier fila con un formato que el protocolo no cubre detiene el código con una excepción.
"""

from __future__ import annotations

import csv
import io
import re
from bisect import bisect_left, bisect_right
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from html.parser import HTMLParser
from itertools import pairwise

RANGE_START = date(2008, 12, 16)  # protocolo §4

ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
YEAR = re.compile(r"^\d{4}$")
DECIMAL_TEXT = r"\d+(?:\.\d+)?"
LEVEL_SINGLE = re.compile(rf"^({DECIMAL_TEXT})$")
LEVEL_RANGE = re.compile(rf"^({DECIMAL_TEXT})\s*-\s*({DECIMAL_TEXT})$")
DECISION_DATE = re.compile(r"^([A-Za-z]+)\s+(\d{1,2})(\*?)$")
FOOTNOTE_DATE = r"([A-Za-z]+) (\d{1,2}), (\d{4})"
# «* On July 10, 2024, this date was corrected from March 3, 2020, to March 4, 2020.»
CORRECTION_NOTE = re.compile(
    rf"^\*\s*On {FOOTNOTE_DATE}, this date was corrected from {FOOTNOTE_DATE}, to {FOOTNOTE_DATE}\."
)
MONTHS = {
    name: number
    for number, name in enumerate(
        (
            "January",
            "February",
            "March",
            "April",
            "May",
            "June",
            "July",
            "August",
            "September",
            "October",
            "November",
            "December",
        ),
        1,
    )
}


class RatesFormatError(ValueError):
    """Una fila de tasas tiene un formato que el protocolo no cubre."""


class NoRateError(LookupError):
    """No hay una observación o decisión vigente para la fecha pedida."""


@dataclass(frozen=True)
class EcbObservation:
    date: date
    percent_text: str  # tal como viene en el archivo, p. ej. «2.75»
    rate: float  # fracción anual


@dataclass(frozen=True)
class Decision:
    date: date
    level_text: str  # tal como se publicó, p. ej. «7.00» o «0-0.25»
    target: float  # fracción anual; punto medio si el nivel es un rango
    date_text: str = ""  # la fecha tal como se publicó, p. ej. «March 4*»
    note: str = ""  # nota al pie que acompaña a la fecha (vacía si no hay)


def percent_to_fraction(percent: Decimal) -> float:
    """Convierte un porcentaje (Decimal) en fracción anual con una sola conversión a float."""
    return float(percent / Decimal(100))  # protocolo §4: porcentaje a fraccion


def parse_level(text: str, decision_date: date) -> float:
    """Convierte el nivel publicado en fracción anual (punto medio si es un rango).

    Un rango solo es válido desde 2008-12-16 y un valor único solo antes de esa fecha.
    """
    single = LEVEL_SINGLE.match(text)
    ranged = LEVEL_RANGE.match(text)
    if single:
        if decision_date >= RANGE_START:
            raise RatesFormatError(f"{decision_date}: se esperaba un rango y llegó {text!r}")
        return percent_to_fraction(Decimal(single.group(1)))
    if ranged:
        if decision_date < RANGE_START:
            raise RatesFormatError(f"{decision_date}: se esperaba un valor único y llegó {text!r}")
        low, high = Decimal(ranged.group(1)), Decimal(ranged.group(2))
        if low > high:
            raise RatesFormatError(f"{decision_date}: rango invertido {text!r}")
        return percent_to_fraction((low + high) / Decimal(2))  # protocolo §4: punto medio
    raise RatesFormatError(f"{decision_date}: nivel con formato no cubierto {text!r}")


def parse_ecb(text: str) -> tuple[EcbObservation, ...]:
    """Parsea el CSV SDMX del ECB Data Portal (columnas TIME_PERIOD y OBS_VALUE)."""
    reader = csv.DictReader(io.StringIO(text, newline=""))
    if not reader.fieldnames or not {"TIME_PERIOD", "OBS_VALUE"} <= set(reader.fieldnames):
        raise RatesFormatError("el archivo del BCE no trae TIME_PERIOD y OBS_VALUE")
    observations: list[EcbObservation] = []
    for number, row in enumerate(reader, 2):
        raw_date, raw_value = row["TIME_PERIOD"], row["OBS_VALUE"]
        if not ISO_DATE.match(raw_date or ""):
            raise RatesFormatError(f"BCE fila {number}: fecha {raw_date!r} no es AAAA-MM-DD")
        try:
            observed = date.fromisoformat(raw_date)
            percent = Decimal(raw_value)
        except (ValueError, ArithmeticError) as error:
            raise RatesFormatError(f"BCE fila {number}: valor no válido {raw_value!r}") from error
        if not percent.is_finite():
            raise RatesFormatError(f"BCE fila {number}: valor no finito {raw_value!r}")
        observations.append(EcbObservation(observed, raw_value, percent_to_fraction(percent)))
    if not observations:
        raise RatesFormatError("el archivo del BCE no trae observaciones")
    observations.sort(key=lambda item: item.date)
    for before, after in pairwise(observations):
        if before.date == after.date:
            raise RatesFormatError(f"BCE: fecha duplicada {before.date}")
    return tuple(observations)


class _FomcTables(HTMLParser):
    """Recoge las filas de las tablas por año de Open Market Operations."""

    def __init__(self) -> None:
        super().__init__()
        self.year: int | None = None
        self.rows: list[tuple[int | None, list[str]]] = []
        self.tables = 0
        self._in_heading = False
        self._heading: list[str] = []
        self._table_year: int | None = None
        self._cells: list[tuple[str, str]] | None = None
        self._cell: list[str] | None = None
        self._cell_tag = ""
        self.footnotes: list[str] = []  # párrafos que empiezan con «*»
        self._paragraph: list[str] | None = None

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag == "h4":
            self._in_heading, self._heading = True, []
        elif tag == "table":
            self.tables += 1
            self._table_year = self.year
        elif tag == "tr":
            self._cells = []
        elif tag in ("td", "th") and self._cells is not None:
            self._cell, self._cell_tag = [], tag
        elif tag == "p":
            self._paragraph = []

    def handle_data(self, data: str) -> None:
        if self._in_heading:
            self._heading.append(data)
        if self._cell is not None:
            self._cell.append(data)
        if self._paragraph is not None:
            self._paragraph.append(data)

    def handle_endtag(self, tag: str) -> None:
        if tag == "h4" and self._in_heading:
            self._in_heading = False
            text = " ".join("".join(self._heading).split())
            if YEAR.match(text):
                self.year = int(text)
        elif tag in ("td", "th") and self._cell is not None and self._cells is not None:
            self._cells.append((self._cell_tag, " ".join("".join(self._cell).split())))
            self._cell = None
        elif tag == "p" and self._paragraph is not None:
            text = " ".join("".join(self._paragraph).split())
            if text.startswith("*"):
                self.footnotes.append(text)
            self._paragraph = None
        elif tag == "tr" and self._cells is not None:
            kinds = [kind for kind, _ in self._cells]
            if kinds and all(kind == "td" for kind in kinds):
                self.rows.append((self._table_year, [cell for _, cell in self._cells]))
            elif "td" in kinds:
                raise RatesFormatError(f"fila mezclada de encabezado y datos: {self._cells!r}")
            self._cells = None


def parse_fomc(html: str) -> tuple[Decision, ...]:
    """Parsea una página de Open Market Operations (vigente o archivo histórico)."""
    parser = _FomcTables()
    parser.feed(html)
    parser.close()
    if parser.tables == 0 or not parser.rows:
        raise RatesFormatError("la página no trae tablas de decisiones")
    decisions: list[Decision] = []
    for year, cells in parser.rows:
        if year is None:
            raise RatesFormatError(f"tabla sin encabezado de año: {cells!r}")
        if len(cells) != 4:
            raise RatesFormatError(f"{year}: la fila trae {len(cells)} celdas y se esperaban 4")
        match = DECISION_DATE.match(cells[0])
        if not match or match.group(1) not in MONTHS:
            raise RatesFormatError(f"{year}: fecha de decisión no cubierta {cells[0]!r}")
        try:
            decided = date(year, MONTHS[match.group(1)], int(match.group(2)))
        except ValueError as error:
            raise RatesFormatError(f"{year}: fecha inexistente {cells[0]!r}") from error
        note = _correction_note(parser.footnotes, decided, cells[0]) if match.group(3) else ""
        decisions.append(
            Decision(decided, cells[3], parse_level(cells[3], decided), cells[0], note)
        )
    return tuple(decisions)


def _correction_note(footnotes: Sequence[str], decided: date, cell: str) -> str:
    """La nota de una fecha con asterisco; solo vale si corrige la fecha a la de la fila.

    Cualquier otra nota (o ninguna) es un formato que el protocolo no cubre y detiene el código.
    """
    for text in footnotes:
        found = CORRECTION_NOTE.match(text)
        if not found:
            continue
        corrected = (found.group(9), found.group(7), found.group(8))  # a: año, mes, día
        if (corrected[0], MONTHS.get(corrected[1]), corrected[2]) == (
            str(decided.year),
            decided.month,
            str(decided.day),
        ):
            return found.group(0)[1:].strip()
    raise RatesFormatError(
        f"{decided.year}: la fecha {cell!r} lleva una nota que no la corrige: {list(footnotes)!r}"
    )


def merge_decisions(*pages: Sequence[Decision]) -> tuple[Decision, ...]:
    """Une las decisiones de varias páginas en orden cronológico; una fecha repetida es error."""
    merged = sorted((item for page in pages for item in page), key=lambda item: item.date)
    for before, after in pairwise(merged):
        if before.date == after.date:
            raise RatesFormatError(f"FOMC: decisión duplicada el {before.date}")
    return tuple(merged)


def last_on_or_before(dates: Sequence[date], day: date) -> int:
    """Índice de la última fecha <= day en una lista ordenada (-1 si no hay)."""
    return bisect_right(dates, day) - 1


def last_before(dates: Sequence[date], day: date) -> int:
    """Índice de la última fecha < day en una lista ordenada (-1 si no hay)."""
    return bisect_left(dates, day) - 1


def ecb_rate_at(observations: Sequence[EcbObservation], day: date) -> float:
    """Tasa del BCE vigente en la barra de fecha day: última observación con fecha <= day."""
    index = last_on_or_before([item.date for item in observations], day)
    if index < 0:
        raise NoRateError(f"el BCE no tiene observación vigente el {day}")
    return observations[index].rate


def fomc_target_at(decisions: Sequence[Decision], day: date) -> float:
    """Objetivo del FOMC vigente en la barra de fecha day: última decisión con fecha < day."""
    index = last_before([item.date for item in decisions], day)
    if index < 0:
        raise NoRateError(f"el FOMC no tiene decisión anterior al {day}")
    return decisions[index].target


def rates_for_dates(
    days: Sequence[date],
    observations: Sequence[EcbObservation],
    decisions: Sequence[Decision],
) -> tuple[list[float], list[float]]:
    """Devuelve (r_EUR, r_USD) vigentes en cada fecha de days, alineadas con days."""
    ecb_dates = [item.date for item in observations]
    fomc_dates = [item.date for item in decisions]
    r_eur: list[float] = []
    r_usd: list[float] = []
    for day in days:
        i = last_on_or_before(ecb_dates, day)
        j = last_before(fomc_dates, day)
        if i < 0:
            raise NoRateError(f"el BCE no tiene observación vigente el {day}")
        if j < 0:
            raise NoRateError(f"el FOMC no tiene decisión anterior al {day}")
        r_eur.append(observations[i].rate)
        r_usd.append(decisions[j].target)
    return r_eur, r_usd
