"""Deterministic adapter for the approved PostFinance E-Trading DOCX portfolio statement.

The source is a point-in-time valuation statement, not transaction history. Parsing is
in-memory; callers must never persist or log the raw document or its filename.
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
import hashlib
import io
import re
import unicodedata
from xml.etree import ElementTree as ET
from zipfile import BadZipFile, ZipFile

MAX_FILE_BYTES = 2_000_000
MAX_XML_BYTES = 5_000_000
WORD_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
POSITION_HEADER = (
    "Produkt",
    "Anzahl",
    "Einstandskurs",
    "Totalwert",
    "Diff. Vortag",
    "Geldkurs Währung",
    "G&V CHF",
    "Totalwert CHF",
    "Positionen %",
)
CASH_HEADER = (
    "Währung",
    "",
    "Kurs",
    "Kontosaldo",
    "Positionswert",
    "Totalwert",
    "Bewertung CHF",
    "Konto %",
)
SECTIONS = {"Aktien": "stock", "ETFs": "etf"}
CURRENCIES = {"CHF", "EUR", "USD", "GBP", "JPY", "CAD", "AUD", "NOK", "SEK", "DKK"}


@dataclass(frozen=True)
class PostFinancePosition:
    row_number: int
    source_label: str
    normalized_label: str
    asset_class: str
    quantity: Decimal
    average_cost: Decimal | None
    cost_total: Decimal | None
    market_price: Decimal
    currency: str
    pnl_chf: Decimal | None
    market_value_chf: Decimal
    weight_pct: Decimal | None
    row_hash: str


@dataclass(frozen=True)
class PostFinanceCash:
    currency: str
    fx_rate_to_chf: Decimal
    amount_original: Decimal
    amount_chf: Decimal
    row_hash: str


@dataclass(frozen=True)
class PostFinanceDocument:
    file_hash: str
    as_of: str
    positions: tuple[PostFinancePosition, ...]
    cash: tuple[PostFinanceCash, ...]
    total_value_chf: Decimal
    reason_codes: tuple[str, ...]


def decimal_text(value: Decimal | None) -> str | None:
    if value is None:
        return None
    result = format(value, "f")
    return result.rstrip("0").rstrip(".") if "." in result else result


def normalize_label(value: str) -> str:
    ascii_value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
    return re.sub(r"[^a-z0-9]+", " ", ascii_value.lower()).strip()


def _text(node: ET.Element) -> str:
    return " ".join("".join(item.text or "" for item in node.iter(WORD_NS + "t")).split())


def _decimal(value: str, *, required: bool = False) -> Decimal | None:
    cleaned = value.strip().replace("\u00a0", "").replace("\u202f", "").replace("’", "'").replace("−", "-")
    cleaned = re.sub(r"(?<=\d)[ '](?=\d)", "", cleaned)
    if "," in cleaned and "." in cleaned:
        cleaned = cleaned.replace(".", "").replace(",", ".") if cleaned.rfind(",") > cleaned.rfind(".") else cleaned.replace(",", "")
    elif "," in cleaned:
        cleaned = cleaned.replace(",", ".")
    cleaned = re.sub(r"[^0-9+\-.]", "", cleaned)
    if not cleaned:
        if required:
            raise ValueError("PostFinance-Dokument enthält einen fehlenden Zahlenwert")
        return None
    try:
        result = Decimal(cleaned)
    except InvalidOperation as exc:
        raise ValueError("PostFinance-Dokument enthält einen ungültigen Zahlenwert") from exc
    if not result.is_finite():
        raise ValueError("PostFinance-Dokument enthält einen ungültigen Zahlenwert")
    return result


def _price_currency(value: str) -> tuple[Decimal, str]:
    currency_match = re.search(r"\b([A-Z]{3})\b", value)
    if not currency_match or currency_match.group(1) not in CURRENCIES:
        raise ValueError("PostFinance-Position enthält keine unterstützte Währung")
    price = _decimal(value.replace(currency_match.group(1), ""), required=True)
    assert price is not None
    if price < 0:
        raise ValueError("PostFinance-Position enthält einen negativen Kurs")
    return price, currency_match.group(1)


def _safe_document_xml(raw: bytes) -> bytes:
    if not raw or len(raw) > MAX_FILE_BYTES:
        raise ValueError("PostFinance-Datei ist leer oder zu gross")
    try:
        with ZipFile(io.BytesIO(raw)) as archive:
            infos = archive.infolist()
            if len(infos) > 64 or any(info.flag_bits & 0x1 for info in infos):
                raise ValueError("PostFinance-DOCX ist nicht in einem unterstützten Zustand")
            if any(info.filename.startswith(("/", "\\")) or ".." in info.filename.split("/") for info in infos):
                raise ValueError("PostFinance-DOCX enthält ungültige Einträge")
            if sum(info.file_size for info in infos) > MAX_XML_BYTES:
                raise ValueError("PostFinance-DOCX ist entpackt zu gross")
            xml = archive.read("word/document.xml")
    except (BadZipFile, KeyError) as exc:
        raise ValueError("Erwartet wird ein PostFinance Portfolio-DOCX") from exc
    if len(xml) > MAX_XML_BYTES or b"<!DOCTYPE" in xml.upper() or b"<!ENTITY" in xml.upper():
        raise ValueError("PostFinance-DOCX enthält nicht unterstütztes XML")
    return xml


def parse_postfinance_portfolio(raw: bytes) -> PostFinanceDocument:
    file_hash = hashlib.sha256(raw).hexdigest()
    try:
        root = ET.fromstring(_safe_document_xml(raw))
    except ET.ParseError as exc:
        raise ValueError("PostFinance-DOCX enthält ungültiges XML") from exc
    document_text = "\n".join(_text(item) for item in root.iter(WORD_NS + "p"))
    if not re.search(r"(?i)bewertung", document_text):
        raise ValueError("Dokument ist keine erkennbare PostFinance Portfolio-Bewertung")
    dates = set(re.findall(r"(?<!\d)([0-3]?\d)/([01]?\d)/(20\d{2})(?!\d)", document_text))
    if len(dates) != 1:
        raise ValueError("Bewertungsstichtag ist nicht eindeutig")
    day, month, year = next(iter(dates))
    as_of = f"{year}-{int(month):02d}-{int(day):02d}"

    positions: list[PostFinancePosition] = []
    cash: list[PostFinanceCash] = []
    aggregate_candidates: list[Decimal] = []
    unsupported_rows = 0
    row_number = 0
    section: str | None = None
    for table in root.iter(WORD_NS + "tbl"):
        position_header_seen = False
        rows = table.findall("./" + WORD_NS + "tr")
        for row in rows:
            cells = [_text(cell) for cell in row.findall("./" + WORD_NS + "tc")]
            if tuple(cells) == POSITION_HEADER:
                position_header_seen = True
                continue
            if tuple(cells) == CASH_HEADER:
                continue
            if len(cells) == 9 and sum(bool(cell) for cell in cells) == 1 and cells[0] in SECTIONS:
                section = SECTIONS[cells[0]]
                continue
            if not position_header_seen and len(cells) == 8:
                currency = cells[0].upper()
                if currency in CURRENCIES:
                    fx = _decimal(cells[2], required=True)
                    amount = _decimal(cells[3], required=True)
                    assert fx is not None and amount is not None
                    if fx <= 0 or amount < 0:
                        raise ValueError("PostFinance-Cashzeile enthält ungültige Werte")
                    amount_chf = fx * amount
                    row_hash = hashlib.sha256(f"cash|{currency}|{fx}|{amount}|{as_of}".encode()).hexdigest()
                    cash.append(PostFinanceCash(currency, fx, amount, amount_chf, row_hash))
                    continue
                total = _decimal(cells[6])
                if total is not None:
                    aggregate_candidates.append(total)
                continue
            if position_header_seen and len(cells) == 9:
                quantity = _decimal(cells[1])
                market_value = _decimal(cells[7])
                if quantity is None and market_value is None:
                    if any(cells):
                        unsupported_rows += 1
                    continue
                if quantity is None or market_value is None or not cells[0] or section is None:
                    unsupported_rows += 1
                    continue
                if quantity <= 0 or market_value < 0:
                    raise ValueError("PostFinance-Position enthält ungültige Menge oder Bewertung")
                market_price, currency = _price_currency(cells[5])
                average_cost = _decimal(cells[2])
                cost_total = _decimal(cells[3])
                # The real report combines absolute and percentage P&L in one cell and
                # occasionally omits a separator. Do not guess an absolute P&L value.
                pnl_chf = None
                weight = _decimal(cells[8])
                label = cells[0].strip()
                normalized = normalize_label(label)
                row_number += 1
                row_payload = f"position|{normalized}|{section}|{quantity}|{market_price}|{currency}|{market_value}|{as_of}"
                positions.append(PostFinancePosition(row_number, label, normalized, section, quantity, average_cost, cost_total, market_price, currency, pnl_chf, market_value, weight, hashlib.sha256(row_payload.encode()).hexdigest()))

    if not positions or not aggregate_candidates:
        raise ValueError("PostFinance Portfolio-Bewertung enthält keinen vollständigen Positionsbestand")
    duplicate_labels = {item.normalized_label for item in positions if sum(other.normalized_label == item.normalized_label for other in positions) > 1}
    reasons: set[str] = {"baseline_only", "missing_transaction_history", "missing_instrument_identifier"}
    if duplicate_labels:
        reasons.add("ambiguous_instrument")
    if unsupported_rows:
        reasons.add("unsupported_row")
    total = aggregate_candidates[-1]
    calculated = sum((item.market_value_chf for item in positions), Decimal("0")) + sum((item.amount_chf for item in cash), Decimal("0"))
    if abs(calculated - total) > Decimal("0.25"):
        reasons.add("portfolio_total_mismatch")
    return PostFinanceDocument(file_hash, as_of, tuple(positions), tuple(cash), total, tuple(sorted(reasons)))
