from __future__ import annotations

import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal, InvalidOperation
from tkinter import messagebox, ttk

from ccma.domain.contributions import (
    claim_balance,
    claim_status,
    decimal_value,
    donation_balance,
    money_text,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.donation_dialog import DonationEditDialog


class _AllocationTable:
    """Embeds a combined claims + donations allocation tree into a payment dialog.

    Only claims/donations that are still open (or already linked to this payment) are
    listed, so fully-settled ones don't clutter the picture. A row's visibility is
    decided once, from a snapshot taken when the dialog opens (or when a new donation
    is created inline) -- it never disappears again for the rest of the editing
    session just because the user unassigned it. That way "Zuordnung lösen" followed
    by "Zuordnung setzen" always works on the same row instead of the target vanishing.

    Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in
    place, so the owning dialog can read them back at save time. Nothing is written to
    the repository until the dialog's own Save button does so.
    """

    def __init__(
        self,
        parent: tk.Misc,
        repository: MemberRepository,
        member_id: str,
        *,
        claim_allocations: dict[str, str],
        donation_allocations: dict[str, str],
        on_change: Callable[[], None],
        get_payment_amount: Callable[[], Decimal],
    ):
        self.repository = repository
        self.member_id = member_id
        self.claim_allocations = claim_allocations
        self.donation_allocations = donation_allocations
        self.on_change = on_change
        self.get_payment_amount = get_payment_amount
        # Fixed snapshot of what this payment already covered when the dialog opened.
        # Capacities are computed against this baseline rather than the live, editable
        # dicts above -- otherwise "Maximal zuordenbar" would inflate every time the
        # user changes an amount, since claim_balance() reflects only persisted state.
        self._baseline_claim_allocations = dict(claim_allocations)
        self._baseline_donation_allocations = dict(donation_allocations)
        self._visible_claim_ids: set[str] = set(claim_allocations)
        self._visible_donation_ids: set[str] = set(donation_allocations)
        self._load_targets()
        self._build(parent)
        self._refresh()

    def _load_targets(self) -> None:
        self.data = self.repository.get_contributions(self.member_id)
        self._visible_claim_ids |= {
            str(claim.get("claim_id", ""))
            for claim in self.data.claims
            if str(claim.get("claim_id", ""))
            and claim_status(self.data, claim) != "cancelled"
            and claim_balance(self.data, claim) > 0
        }
        self._visible_donation_ids |= {
            str(item.get("donation_id", ""))
            for item in self.data.donations
            if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0
        }
        self.claims_by_id = {
            str(claim.get("claim_id", "")): claim
            for claim in self.data.claims
            if str(claim.get("claim_id", "")) in self._visible_claim_ids
        }
        self.donations_by_id = {
            str(item.get("donation_id", "")): item
            for item in self.data.donations
            if str(item.get("donation_id", "")) in self._visible_donation_ids
        }

    def _capacities(self) -> None:
        self.claim_capacity: dict[str, Decimal] = {}
        for claim_id, claim in self.claims_by_id.items():
            baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0"))
            self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0"))
        self.donation_capacity: dict[str, Decimal] = {}
        for donation_id, donation in self.donations_by_id.items():
            baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0"))
            self.donation_capacity[donation_id] = max(
                donation_balance(self.data, donation) + baseline, Decimal("0")
            )

    def _build(self, parent: tk.Misc) -> None:
        parent.columnconfigure(0, weight=1)
        parent.rowconfigure(0, weight=1)
        self.tree = ttk.Treeview(
            parent,
            columns=("kind", "title", "date", "capacity", "allocated"),
            show="headings",
            selectmode="browse",
        )
        for key, title, width in (
            ("kind", "Typ", 90),
            ("title", "Forderung / Spende", 260),
            ("date", "Fällig / Datum", 110),
            ("capacity", "Maximal zuordenbar", 140),
            ("allocated", "Aktuell zugeordnet", 140),
        ):
            self.tree.heading(key, text=title)
            self.tree.column(key, width=width, anchor="w")
        self.tree.grid(row=0, column=0, sticky="nsew")
        scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.tree.configure(yscrollcommand=scrollbar.set)
        self.tree.bind("<<TreeviewSelect>>", self._select)

        actions = ttk.Frame(parent)
        actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
        ttk.Label(actions, text="Betrag für Auswahl").pack(side="left")
        self.amount_var = tk.StringVar()
        ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8))
        ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left")
        ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0))
        ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10)
        ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left")

    def _refresh(self) -> None:
        selected = self.tree.selection()
        self.tree.delete(*self.tree.get_children())
        self._capacities()
        for claim_id, claim in sorted(
            self.claims_by_id.items(),
            key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()),
        ):
            self.tree.insert(
                "",
                "end",
                iid=f"claim:{claim_id}",
                values=(
                    "Forderung",
                    claim.get("title", "Forderung"),
                    format_date_for_display(str(claim.get("due_date", ""))),
                    f"{money_text(self.claim_capacity[claim_id])} EUR",
                    f"{self.claim_allocations.get(claim_id, '0.00')} EUR",
                ),
            )
        for donation_id, donation in sorted(
            self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True
        ):
            label = donation.get("reference") or donation.get("purpose") or "Spende"
            self.tree.insert(
                "",
                "end",
                iid=f"donation:{donation_id}",
                values=(
                    "Spende",
                    label,
                    format_date_for_display(str(donation.get("date", ""))),
                    f"{money_text(self.donation_capacity[donation_id])} EUR",
                    f"{self.donation_allocations.get(donation_id, '0.00')} EUR",
                ),
            )
        if selected and self.tree.exists(selected[0]):
            self.tree.selection_set(selected[0])
        self.on_change()

    @staticmethod
    def _target(iid: str) -> tuple[str, str]:
        kind, target_id = iid.split(":", 1)
        return kind, target_id

    def _select(self, _event: tk.Event | None = None) -> None:
        selected = self.tree.selection()
        if not selected:
            return
        kind, target_id = self._target(selected[0])
        allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
        current = allocations.get(target_id)
        if current:
            # Already allocated: show the existing amount so it can be reviewed/edited.
            self.amount_var.set(current)
            return
        # Not yet allocated: suggest whichever is smaller -- what this claim/donation
        # still needs, or what's left of the payment -- so the common case is just
        # "click the row, then Zuordnung setzen".
        capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
        free = max(self._payment_amount() - self.total_allocated(), Decimal("0"))
        self.amount_var.set(money_text(min(capacity, free)))

    def _payment_amount(self) -> Decimal:
        try:
            return self.get_payment_amount()
        except ValueError:
            return Decimal("0")

    def _set(self) -> None:
        selected = self.tree.selection()
        if not selected:
            messagebox.showerror(
                "Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
            )
            return
        kind, target_id = self._target(selected[0])
        try:
            value = decimal_value(self.amount_var.get(), "Zuordnung")
        except ValueError as exc:
            messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree)
            return
        capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
        if value < 0 or value > capacity:
            messagebox.showerror(
                "Ungültige Zuordnung",
                f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.",
                parent=self.tree,
            )
            return
        allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
        if value:
            allocations[target_id] = money_text(value)
        else:
            allocations.pop(target_id, None)
        self._refresh()

    def _remove(self) -> None:
        selected = self.tree.selection()
        if not selected:
            messagebox.showerror(
                "Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
            )
            return
        kind, target_id = self._target(selected[0])
        allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
        allocations.pop(target_id, None)
        self.amount_var.set("0.00")
        self._refresh()

    def _create_donation(self) -> None:
        DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created)

    def _donation_created(self) -> None:
        self._load_targets()
        self._refresh()

    def total_allocated(self) -> Decimal:
        return sum(
            (
                decimal_value(value)
                for value in (*self.claim_allocations.values(), *self.donation_allocations.values())
            ),
            Decimal("0"),
        )


class PaymentCreateDialog(tk.Toplevel):
    """Records a payment and lets the board allocate parts of it to open claims and/or
    donations right away — including creating a new donation on the fly — instead of
    having to save the bare payment first and assign it in a separate step."""

    def __init__(
        self,
        master: tk.Misc,
        repository: MemberRepository,
        member_id: str,
        on_saved: Callable[[], None],
    ):
        super().__init__(master)
        self.repository = repository
        self.member_id = member_id
        self.on_saved = on_saved
        self.claim_allocations: dict[str, str] = {}
        self.donation_allocations: dict[str, str] = {}
        self.allocation_table: _AllocationTable | None = None
        self.title("Zahlung anlegen")
        self.transient(master.winfo_toplevel())
        self.geometry("880x560")
        self.minsize(720, 460)
        self.protocol("WM_DELETE_WINDOW", self.destroy)
        self.bind("<Escape>", lambda _event: self.destroy())
        self._build_ui()
        self.after_idle(self._activate)

    def _activate(self) -> None:
        try:
            self.deiconify()
            self.lift()
            self.focus_force()
            self.grab_set()
        except tk.TclError:
            return

    def _build_ui(self) -> None:
        self.columnconfigure(0, weight=1)
        self.rowconfigure(1, weight=1)
        form = ttk.Frame(self, padding=16)
        form.grid(row=0, column=0, sticky="ew")
        form.columnconfigure(1, weight=1)
        self.variables = {
            "date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
            "amount": tk.StringVar(),
            "gnucash": tk.StringVar(),
            "reference": tk.StringVar(),
        }
        fields = (
            (f"Zahlungsdatum ({date_input_hint()})", "date"),
            ("Zahlungsbetrag", "amount"),
            ("GnuCash-ID (optional)", "gnucash"),
            ("Referenz", "reference"),
        )
        for row, (label, key) in enumerate(fields):
            ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
            ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
                row=row, column=1, sticky="ew", pady=4
            )
        self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
        self.total_var = tk.StringVar()

        allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12)
        allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
        self.allocation_table = _AllocationTable(
            allocation_frame,
            self.repository,
            self.member_id,
            claim_allocations=self.claim_allocations,
            donation_allocations=self.donation_allocations,
            on_change=self._refresh_totals,
            get_payment_amount=self._current_payment_amount,
        )

        ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
            row=2, column=0, sticky="w", padx=16, pady=(0, 8)
        )

        buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
        buttons.grid(row=3, column=0, sticky="e")
        ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
        ttk.Button(
            buttons, text="Speichern", style="Accent.TButton", command=self._save
        ).pack(side="left")
        self._refresh_totals()

    def _current_payment_amount(self) -> Decimal:
        return decimal_value(self.variables["amount"].get())

    def _refresh_totals(self) -> None:
        allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
        try:
            payment_amount = self._current_payment_amount()
            free = payment_amount - allocated
            self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR")
        except (ValueError, InvalidOperation):
            self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")

    def _save(self) -> None:
        try:
            self.repository.create_payment(
                self.member_id,
                payment_date=self.variables["date"].get(),
                amount=self.variables["amount"].get(),
                claim_allocations=self.claim_allocations,
                donation_allocations=self.donation_allocations,
                gnucash_transaction_id=self.variables["gnucash"].get(),
                reference=self.variables["reference"].get(),
            )
        except RepositoryError as exc:
            messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
            return
        self.destroy()
        self.on_saved()


class PaymentEditDialog(tk.Toplevel):
    def __init__(
        self,
        master: tk.Misc,
        repository: MemberRepository,
        member_id: str,
        payment_id: str,
        on_saved: Callable[[], None],
    ):
        super().__init__(master)
        self.repository = repository
        self.member_id = member_id
        self.payment_id = payment_id
        self.on_saved = on_saved
        self.data = repository.get_contributions(member_id)
        self.payment = next(
            (
                payment
                for payment in self.data.payments
                if str(payment.get("payment_id", "")) == payment_id
            ),
            None,
        )
        if self.payment is None:
            raise RepositoryError("Zahlung nicht gefunden.")
        self.claim_allocations, self.donation_allocations = self._current_allocations()
        self.allocation_table: _AllocationTable | None = None

        self.title("Zahlung bearbeiten")
        self.transient(master.winfo_toplevel())
        self.geometry("880x560")
        self.minsize(720, 460)
        self.protocol("WM_DELETE_WINDOW", self.destroy)
        self.bind("<Escape>", lambda _event: self.destroy())
        self._build_ui()
        self.after_idle(self._activate)

    def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]:
        claim_totals: dict[str, Decimal] = {}
        donation_totals: dict[str, Decimal] = {}
        for allocation in self.data.allocations:
            if str(allocation.get("payment_id", "")) != self.payment_id:
                continue
            amount = decimal_value(allocation.get("amount", "0"))
            donation_id = str(allocation.get("donation_id", ""))
            if donation_id:
                donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount
                continue
            claim_id = str(allocation.get("claim_id", ""))
            if claim_id:
                claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount
        return (
            {claim_id: money_text(value) for claim_id, value in claim_totals.items()},
            {donation_id: money_text(value) for donation_id, value in donation_totals.items()},
        )

    def _build_ui(self) -> None:
        self.columnconfigure(0, weight=1)
        self.rowconfigure(1, weight=1)
        form = ttk.Frame(self, padding=16)
        form.grid(row=0, column=0, sticky="ew")
        form.columnconfigure(1, weight=1)
        self.variables = {
            "date": tk.StringVar(
                value=format_date_for_display(str(self.payment.get("date", "")))
            ),
            "amount": tk.StringVar(value=str(self.payment.get("amount", ""))),
            "gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))),
            "reference": tk.StringVar(value=str(self.payment.get("reference", ""))),
        }
        fields = (
            (f"Zahlungsdatum ({date_input_hint()})", "date"),
            ("Zahlungsbetrag", "amount"),
            ("GnuCash-ID", "gnucash"),
            ("Referenz", "reference"),
        )
        for row, (label, key) in enumerate(fields):
            ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
            ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
                row=row, column=1, sticky="ew", pady=4
            )
        self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
        self.total_var = tk.StringVar()

        allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12)
        allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
        self.allocation_table = _AllocationTable(
            allocation_frame,
            self.repository,
            self.member_id,
            claim_allocations=self.claim_allocations,
            donation_allocations=self.donation_allocations,
            on_change=self._refresh_totals,
            get_payment_amount=self._current_payment_amount,
        )

        ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
            row=2, column=0, sticky="w", padx=16, pady=(0, 8)
        )

        buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
        buttons.grid(row=3, column=0, sticky="e")
        ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
        ttk.Button(
            buttons,
            text="Zahlung speichern",
            style="Accent.TButton",
            command=self._save,
        ).pack(side="left")
        self._refresh_totals()

    def _activate(self) -> None:
        try:
            self.deiconify()
            self.lift()
            self.focus_force()
            self.grab_set()
        except tk.TclError:
            return

    def _current_payment_amount(self) -> Decimal:
        return decimal_value(self.variables["amount"].get())

    def _refresh_totals(self) -> None:
        allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
        try:
            payment_amount = self._current_payment_amount()
            free = payment_amount - allocated
            self.total_var.set(
                f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
            )
        except (ValueError, InvalidOperation):
            self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")

    def _save(self) -> None:
        try:
            self.repository.update_payment(
                self.member_id,
                self.payment_id,
                payment_date=self.variables["date"].get(),
                amount=self.variables["amount"].get(),
                allocations=self.claim_allocations,
                donation_allocations=self.donation_allocations,
                gnucash_transaction_id=self.variables["gnucash"].get(),
                reference=self.variables["reference"].get(),
            )
        except RepositoryError as exc:
            messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
            return
        self.destroy()
        self.on_saved()
