from __future__ import annotations

import tkinter as tk
from collections.abc import Callable
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from tkinter import messagebox, ttk

from ccma.config import AppConfig
from ccma.domain.contributions import (
    CLAIM_STATUS_LABELS,
    CONTRIBUTION_OVERRIDE_KIND_LABELS,
    DONATION_STATUS_LABELS,
    claim_status,
    claim_total,
    donation_allocated_total,
    donation_balance,
    donation_status,
    money_text,
    payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.donation_dialog import (
    AllocateDonationPaymentDialog,
    DonationEditDialog,
    DonationPaymentDialog,
)
from ccma.ui.file_open import open_path
from ccma.ui.gnucash_import_dialog import GnuCashImportDialog
from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog

# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
# (they tend to hold longer label text), and only the trailing description/reference
# column stretches to take up whatever space remains.
FIXED_COLUMN_WIDTH = 90
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)

CLAIM_TABLE_COLUMNS = (
    ("due", "Fällig", FIXED_COLUMN_WIDTH),
    ("amount", "Betrag", FIXED_COLUMN_WIDTH),
    ("status", "Status", STATUS_COLUMN_WIDTH),
    ("title", "Forderung", 220),
)


def _claim_sort_value(data, claim: dict, column: str) -> str:
    if column == "title":
        return str(claim.get("title", ""))
    if column == "due":
        return str(claim.get("due_date", ""))
    if column == "amount":
        return f"{claim_total(claim):012.2f}"
    if column == "status":
        status = claim_status(data, claim)
        return CLAIM_STATUS_LABELS.get(status, status.upper())
    return ""


class _RunHousekeeperDialog(tk.Toplevel):
    """Confirms a housekeeper run scoped to a single member, with the option to
    force retroactive claim creation for just this run without touching the
    application-wide default setting."""

    def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
        super().__init__(master)
        self.on_confirm = on_confirm
        self.title("Hausmeister für dieses Mitglied ausführen")
        self.transient(master.winfo_toplevel())
        self.resizable(False, False)
        frame = ttk.Frame(self, padding=18)
        frame.pack(fill="both", expand=True)
        ttk.Label(
            frame,
            text=(
                f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
                "Andere Mitglieder bleiben unberührt."
            ),
            wraplength=380,
            justify="left",
        ).pack(anchor="w", pady=(0, 12))
        self.retroactive_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(
            frame,
            text="Rückwirkende Beiträge/Forderungen erstellen",
            variable=self.retroactive_var,
        ).pack(anchor="w", pady=(0, 14))
        buttons = ttk.Frame(frame)
        buttons.pack(anchor="e")
        ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
        ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
            side="left"
        )
        self.bind("<Escape>", lambda _event: self.destroy())
        self.after_idle(self.grab_set)

    def _confirm(self) -> None:
        retroactive = self.retroactive_var.get()
        self.destroy()
        self.on_confirm(retroactive)


class MemberTab(ttk.Frame):
    def __init__(
        self,
        master: tk.Misc,
        repository: MemberRepository,
        member_id: str,
        config: AppConfig,
        on_close: Callable[[], None],
        on_changed: Callable[[], None],
        on_open_claim: Callable[[str, str], None],
        on_open_assets: Callable[[], None],
        on_open_asset: Callable[[str], None],
        on_return_asset: Callable[[str], None],
        on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
    ):
        super().__init__(master, padding=12)
        self.repository = repository
        self.member_id = member_id
        self.config = config
        self.on_close = on_close
        self.on_changed = on_changed
        self.on_open_claim = on_open_claim
        self.on_open_assets = on_open_assets
        self.on_open_asset = on_open_asset
        self.on_return_asset = on_return_asset
        self.on_run_housekeeper = on_run_housekeeper
        self.member = repository.get_member(member_id)
        self.variables: dict[str, tk.Variable] = {}
        self._field_sections: dict[str, str] = {}
        self._dirty_sections: set[str] = set()
        self._form_tabs: dict[str, str] = {}
        self._form_tab_titles: dict[str, str] = {}
        self._loading = False
        self.notes_text: tk.Text | None = None
        self._build_ui()
        self.refresh()

    def _build_ui(self) -> None:
        self.columnconfigure(0, weight=1)
        self.rowconfigure(2, weight=1)
        header = ttk.Frame(self)
        header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
        header.columnconfigure(0, weight=1)
        header.columnconfigure(1, weight=0)
        self.title_var = tk.StringVar()
        self.status_var = tk.StringVar()
        self.id_var = tk.StringVar()
        title_column = ttk.Frame(header)
        title_column.grid(row=0, column=0, sticky="ew")
        title_column.columnconfigure(0, weight=1)
        ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
            row=0, column=0, sticky="w"
        )
        ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid(
            row=1, column=0, sticky="w", pady=(3, 0)
        )
        ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
            row=2, column=0, sticky="w", pady=(3, 0)
        )
        header_actions = ttk.Frame(header)
        header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
        ttk.Button(
            header_actions,
            text="Hausmeister für dieses Mitglied",
            command=self._run_housekeeper_for_member,
        ).pack(side="left", padx=(0, 8))
        ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
        self.messages = MessageBannerList(self)
        self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
        self.messages.grid_remove()

        self.pane = ttk.Panedwindow(self, orient="horizontal")
        self.pane.grid(row=2, column=0, sticky="nsew")
        self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
        self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
        self.pane.add(self.details_pane, weight=2)
        self.pane.add(self.timeline_pane, weight=1)
        self._build_details(self.details_pane)
        self._build_timeline(self.timeline_pane)
        self._pane_position_initialized = False
        self.pane.bind("<Configure>", self._set_initial_pane_position, add="+")

    def _set_initial_pane_position(self, event: tk.Event | None = None) -> None:
        if self._pane_position_initialized:
            return
        try:
            width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
            if width > 1:
                # Chronik (timeline_pane) defaults to a third of the available width.
                self.pane.sashpos(0, max(360, round(width * 2 / 3)))
                self._pane_position_initialized = True
        except tk.TclError:
            return

    def _build_details(self, parent: ttk.Frame) -> None:
        parent.columnconfigure(0, weight=1)
        parent.rowconfigure(1, weight=1)
        ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 8)
        )
        notebook = ttk.Notebook(parent)
        self.details_notebook = notebook
        notebook.grid(row=1, column=0, sticky="nsew")
        data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
        address_tab = self._create_form_tab(notebook, "address", "Anschrift")
        banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
        actions = ttk.Frame(parent)
        actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
        actions.columnconfigure(0, weight=1)
        ttk.Button(
            actions,
            text="Mitgliedsdaten speichern",
            style="Accent.TButton",
            command=self._save,
        ).grid(row=0, column=0, sticky="e")
        contribution_tab = ttk.Frame(notebook, padding=16)
        contribution_override_tab = ttk.Frame(notebook, padding=16)
        payments_tab = ttk.Frame(notebook, padding=16)
        donations_tab = ttk.Frame(notebook, padding=16)
        assets_tab = ttk.Frame(notebook, padding=16)
        documents_tab = ttk.Frame(notebook, padding=16)
        notebook.add(contribution_tab, text="Forderungen")
        notebook.add(contribution_override_tab, text="Beitrag")
        notebook.add(payments_tab, text="Zahlungen")
        notebook.add(donations_tab, text="Spenden")
        notebook.add(assets_tab, text="Assets")
        notebook.add(documents_tab, text="Dokumente")

        fields = [
            ("UUID / Ordner-ID", "member_id"),
            ("Mitgliedsnummer", "member_number"),
            ("Vorname", "first_name"),
            ("Nachname", "last_name"),
            ("Nickname", "nickname"),
            ("E-Mail-Adresse", "email"),
            ("Telefonnummer", "phone"),
            (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
            (f"Antragsdatum ({date_input_hint()})", "application_date"),
            (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
            (f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
            (f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
        ]
        for row, (label, key) in enumerate(fields):
            variable = tk.StringVar()
            self._add_variable(key, variable, "data")
            ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
            if key == "birth_date":
                birth_row = ttk.Frame(data_tab)
                birth_row.grid(row=row, column=1, sticky="ew", pady=5)
                birth_row.columnconfigure(0, weight=1)
                ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew")
                self.age_var = tk.StringVar(value="Alter: —")
                ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid(
                    row=0, column=1, sticky="w", padx=(10, 0)
                )
                variable.trace_add(
                    "write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
                )
            else:
                entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
                ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
                    row=row, column=1, sticky="ew", pady=5
                )
        self._add_variable("status", tk.StringVar(), "data")
        ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
        ttk.Combobox(
            data_tab,
            textvariable=self.variables["status"],
            values=list(STATUS_LABELS.values()),
            state="readonly",
            width=39,
        ).grid(row=len(fields), column=1, sticky="ew", pady=5)
        ttk.Label(data_tab, text="Interne Notiz").grid(
            row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
        )
        self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
        self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
        self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
        data_tab.columnconfigure(1, weight=1)

        address_fields = (
            ("Straße und Hausnummer", "street"),
            ("Adresszusatz", "address_addition"),
            ("Postleitzahl", "postal_code"),
            ("Ort", "city"),
            ("Land", "country"),
        )
        address_tab.columnconfigure(1, weight=1)
        for row, (label, key) in enumerate(address_fields):
            self._add_variable(key, tk.StringVar(), "address")
            ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
            ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
                row=row, column=1, sticky="ew", pady=5
            )

        banking_fields = (
            ("Kontoinhaber", "account_holder"),
            ("IBAN", "iban"),
            ("BIC", "bic"),
            ("Mandatsreferenz", "mandate_reference"),
            (f"Mandat erteilt am ({date_input_hint()})", "mandate_signed_at"),
            (f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
        )
        banking_tab.columnconfigure(1, weight=1)
        self._add_variable("payment_frequency", tk.StringVar(), "banking")
        ttk.Label(banking_tab, text="Zahlweise").grid(
            row=0, column=0, sticky="w", padx=(0, 12), pady=5
        )
        ttk.Combobox(
            banking_tab,
            textvariable=self.variables["payment_frequency"],
            values=list(PAYMENT_FREQUENCY_LABELS.values()),
            state="readonly",
            width=39,
        ).grid(row=0, column=1, sticky="ew", pady=5)
        for row, (label, key) in enumerate(banking_fields, start=1):
            self._add_variable(key, tk.StringVar(), "banking")
            ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
            ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
                row=row, column=1, sticky="ew", pady=5
            )
        self._add_variable("mandate_active", tk.BooleanVar(), "banking")
        ttk.Checkbutton(
            banking_tab,
            text="SEPA-Lastschriftmandat ist aktiv",
            variable=self.variables["mandate_active"],
            style="Switch",
        ).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
        contribution_tab.columnconfigure(0, weight=1)
        contribution_tab.rowconfigure(1, weight=1)
        self.contribution_summary = tk.StringVar()
        ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 10)
        )
        self.claims = ttk.Treeview(
            contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
        )
        self.claim_sort_column = "due"
        self.claim_sort_descending = False
        for key, title, width in CLAIM_TABLE_COLUMNS:
            self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
            self.claims.column(key, width=width, anchor="w", stretch=key == "title")
        self.claims.grid(row=1, column=0, sticky="nsew")
        self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
        self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
        claim_actions = ttk.Frame(contribution_tab)
        claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
        ttk.Button(
            claim_actions,
            text="Willkommens- und Erstrechnungsmail",
            command=self._send_welcome_mail,
        ).pack(side="left")

        contribution_override_tab.columnconfigure(0, weight=1)
        contribution_override_tab.rowconfigure(1, weight=1)
        ttk.Label(
            contribution_override_tab,
            text=(
                "Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
                "den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
                "Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
            ),
            style="Mono.TLabel",
            wraplength=700,
        ).grid(row=0, column=0, sticky="w", pady=(0, 10))
        self.contribution_overrides = ttk.Treeview(
            contribution_override_tab,
            columns=("valid_from", "valid_until", "kind", "value", "reason"),
            show="headings",
            selectmode="browse",
        )
        for key, title, width in (
            ("valid_from", "Ab", FIXED_COLUMN_WIDTH),
            ("valid_until", "Bis", FIXED_COLUMN_WIDTH),
            ("kind", "Art", FIXED_COLUMN_WIDTH),
            ("value", "Wert", FIXED_COLUMN_WIDTH),
            ("reason", "Begründung", 320),
        ):
            self.contribution_overrides.heading(key, text=title)
            self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
        self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
        self.contribution_overrides.bind(
            "<Double-1>", lambda _event: self._edit_selected_contribution_override()
        )
        self.contribution_overrides.bind(
            "<Return>", lambda _event: self._edit_selected_contribution_override()
        )
        contribution_override_actions = ttk.Frame(contribution_override_tab)
        contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
        ttk.Button(
            contribution_override_actions,
            text="Abweichenden Beitrag anlegen",
            command=self._create_contribution_override,
        ).pack(side="left", padx=(0, 8))
        ttk.Button(
            contribution_override_actions,
            text="Bearbeiten",
            command=self._edit_selected_contribution_override,
        ).pack(side="left", padx=(0, 8))
        ttk.Button(
            contribution_override_actions,
            text="Löschen",
            command=self._delete_selected_contribution_override,
        ).pack(side="left")

        payments_tab.columnconfigure(0, weight=1)
        payments_tab.rowconfigure(1, weight=1)
        self.payment_summary = tk.StringVar()
        ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 10)
        )
        self.payments = ttk.Treeview(
            payments_tab,
            columns=("date", "amount", "allocated", "available", "reference"),
            show="headings",
            selectmode="browse",
        )
        for key, title, width in (
            ("date", "Datum", FIXED_COLUMN_WIDTH),
            ("amount", "Betrag", FIXED_COLUMN_WIDTH),
            ("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
            ("available", "Frei", FIXED_COLUMN_WIDTH),
            ("reference", "Referenz", 320),
        ):
            self.payments.heading(key, text=title)
            self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
        self.payments.grid(row=1, column=0, sticky="nsew")
        self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
        self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
        payment_actions = ttk.Frame(payments_tab)
        payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
        ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
            side="left"
        )
        ttk.Separator(payment_actions, orient="vertical").pack(side="left", fill="y", padx=10)
        ttk.Button(
            payment_actions, text="Zahlungen importieren", command=self._import_payments
        ).pack(side="left")

        donations_tab.columnconfigure(0, weight=1)
        donations_tab.rowconfigure(1, weight=1)
        self.donation_summary = tk.StringVar()
        ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 10)
        )
        self.donations = ttk.Treeview(
            donations_tab,
            columns=("date", "amount", "allocated", "balance", "status", "reference"),
            show="headings",
            selectmode="browse",
        )
        for key, title, width in (
            ("date", "Datum", FIXED_COLUMN_WIDTH),
            ("amount", "Betrag", FIXED_COLUMN_WIDTH),
            ("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
            ("balance", "Offen", FIXED_COLUMN_WIDTH),
            ("status", "Status", STATUS_COLUMN_WIDTH),
            ("reference", "Referenz / Zweck", 260),
        ):
            self.donations.heading(key, text=title)
            self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
        self.donations.grid(row=1, column=0, sticky="nsew")
        self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
        self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
        donation_actions = ttk.Frame(donations_tab)
        donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
        ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
            side="left", padx=(0, 8)
        )
        ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
        ttk.Button(
            donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
        ).pack(side="left", padx=(0, 8))
        ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
            side="left"
        )

        assets_tab.columnconfigure(0, weight=1)
        assets_tab.rowconfigure(1, weight=1)
        self.assets_summary = tk.StringVar()
        ttk.Label(assets_tab, textvariable=self.assets_summary, style="Mono.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 10)
        )
        self.assets_tree = ttk.Treeview(
            assets_tab,
            columns=("label", "category", "inventory_number", "status"),
            show="headings",
        )
        for key, title, width in (
            ("label", "Bezeichnung", 240),
            ("category", "Kategorie", 140),
            ("inventory_number", "Inventarnummer", 140),
            ("status", "Status", 140),
        ):
            self.assets_tree.heading(key, text=title)
            self.assets_tree.column(key, width=width, anchor="w")
        self.assets_tree.grid(row=1, column=0, sticky="nsew")
        self.assets_tree.bind("<Double-1>", lambda _event: self._open_selected_asset())
        self.assets_tree.bind("<Return>", lambda _event: self._open_selected_asset())
        asset_actions = ttk.Frame(assets_tab)
        asset_actions.grid(row=2, column=0, sticky="e", pady=(10, 0))
        ttk.Button(asset_actions, text="Inventar öffnen", command=self.on_open_assets).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack(
            side="left", padx=(0, 8)
        )
        ttk.Button(
            asset_actions,
            text="Ausgewähltes Asset zurücknehmen",
            command=self._return_selected_asset,
        ).pack(
            side="left",
        )

        documents_tab.columnconfigure(0, weight=1)
        documents_tab.rowconfigure(1, weight=1)
        document_buttons = ttk.Frame(documents_tab)
        document_buttons.grid(row=0, column=0, sticky="ew", pady=(0, 10))
        ttk.Button(
            document_buttons,
            text="Dokument aus Template",
            style="Accent.TButton",
            command=self._create_document,
        ).pack(side="left", padx=(0, 8))
        ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
        self.documents = ttk.Treeview(
            documents_tab,
            columns=("name", "type", "modified", "size"),
            show="headings",
        )
        for key, title, width in (
            ("name", "Dokument", 280),
            ("type", "Typ", 65),
            ("modified", "Geändert", 135),
            ("size", "Größe", 80),
        ):
            self.documents.heading(key, text=title)
            self.documents.column(key, width=width, anchor="w")
        self.documents.grid(row=1, column=0, sticky="nsew")
        document_scroll = ttk.Scrollbar(
            documents_tab,
            orient="vertical",
            command=self.documents.yview,
        )
        document_scroll.grid(row=1, column=1, sticky="ns")
        self.documents.configure(yscrollcommand=document_scroll.set)
        self.documents.bind("<Double-1>", lambda _event: self._open_selected_document())
        self.documents.bind("<Return>", lambda _event: self._open_selected_document())
        self.document_paths: dict[str, Path] = {}

    def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
        tab = ttk.Frame(notebook)
        tab.columnconfigure(0, weight=1)
        tab.rowconfigure(0, weight=1)
        scroller = ScrollableFrame(tab, padding=16)
        scroller.grid(row=0, column=0, sticky="nsew")
        notebook.add(tab, text=title)
        self._form_tabs[section] = str(tab)
        self._form_tab_titles[section] = title
        return scroller.content

    def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
        self.variables[key] = variable
        self._field_sections[key] = section
        variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))

    def _mark_dirty(self, section: str) -> None:
        if self._loading:
            return
        self._dirty_sections.add(section)
        self._refresh_dirty_tabs()

    def _mark_dirty_from_text(self, section: str) -> None:
        if self.notes_text is None or not self.notes_text.edit_modified():
            return
        self.notes_text.edit_modified(False)
        self._mark_dirty(section)

    def _refresh_dirty_tabs(self) -> None:
        for section, tab_id in self._form_tabs.items():
            title = self._form_tab_titles[section]
            suffix = " *" if section in self._dirty_sections else ""
            self.details_notebook.tab(tab_id, text=f"{title}{suffix}")

    def _clear_dirty(self) -> None:
        self._dirty_sections.clear()
        self._refresh_dirty_tabs()

    def _close(self) -> None:
        if self._dirty_sections and not messagebox.askokcancel(
            "Ungespeicherte Änderungen",
            "Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
            parent=self,
        ):
            return
        self.on_close()

    def _run_housekeeper_for_member(self) -> None:
        _RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)

    def _execute_housekeeper_run(self, retroactive: bool) -> None:
        try:
            findings = self.on_run_housekeeper(self.member_id, retroactive)
        except RepositoryError as exc:
            messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
            return
        member_findings = [item for item in findings if item.member_id == self.member_id]
        messagebox.showinfo(
            "Hausmeister ausgeführt",
            f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
            f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
            parent=self,
        )
        self.refresh()
        self.on_changed()

    def _build_timeline(self, parent: ttk.Frame) -> None:
        parent.columnconfigure(0, weight=1)
        parent.rowconfigure(1, weight=1)
        ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid(
            row=0, column=0, sticky="w", pady=(0, 8)
        )
        self.timeline = ttk.Treeview(
            parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview"
        )
        self.timeline.heading("time", text="Zeit")
        self.timeline.heading("summary", text="Ereignis")
        self.timeline.column("time", width=135, stretch=False)
        self.timeline.column("summary", width=320, stretch=True)
        self.timeline.grid(row=1, column=0, sticky="nsew")
        compose = ttk.Frame(parent)
        compose.grid(row=2, column=0, sticky="ew", pady=(10, 0))
        compose.columnconfigure(0, weight=1)
        self.comment_var = tk.StringVar()
        comment = ttk.Entry(compose, textvariable=self.comment_var)
        comment.grid(row=0, column=0, sticky="ew", padx=(0, 6))
        comment.bind("<Return>", lambda _event: self._add_comment())
        ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)

    def refresh(self) -> None:
        self._loading = True
        self.member = self.repository.get_member(self.member_id)
        self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}")
        self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
        self.id_var.set(f"UUID: {self.member.member_id}")
        warnings = self.repository.member_hash_warnings(self.member_id)
        self.messages.set_messages(
            [
                TabMessage(
                    "warning",
                    "WARNUNG: " + " | ".join(warnings),
                    MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
                )
            ]
            if warnings
            else []
        )
        date_fields = {
            "birth_date",
            "application_date",
            "accepted_at",
            "membership_started_at",
            "membership_ended_at",
            "mandate_signed_at",
            "mandate_revoked_at",
        }
        for key, variable in self.variables.items():
            value = getattr(self.member, key)
            if key == "status":
                variable.set(display_label(STATUS_LABELS, str(value)))
            elif key == "payment_frequency":
                variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
            else:
                variable.set(format_date_for_display(value) if key in date_fields else value)
        if self.notes_text is not None:
            self.notes_text.delete("1.0", "end")
            self.notes_text.insert("1.0", self.member.notes)
            self.notes_text.edit_modified(False)
        self._loading = False
        self._clear_dirty()
        self._refresh_events()
        self._refresh_contributions()
        self._refresh_contribution_overrides()
        self._refresh_donations()
        self._refresh_assets()
        self._refresh_documents()

    def _refresh_events(self) -> None:
        self.timeline.delete(*self.timeline.get_children())
        try:
            events = self.repository.get_events(self.member_id)
        except RepositoryError as exc:
            messagebox.showerror("Chronik beschädigt", str(exc), parent=self)
            return
        for event in reversed(events):
            self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))

    def _refresh_contributions(self) -> None:
        self.claims.delete(*self.claims.get_children())
        self.payments.delete(*self.payments.get_children())
        try:
            data = self.repository.get_contributions(self.member_id)
        except RepositoryError as exc:
            self.contribution_summary.set(f"FEHLER: {exc}")
            self.payment_summary.set(f"FEHLER: {exc}")
            return
        claims = sorted(
            data.claims,
            key=lambda claim: _claim_sort_value(data, claim, self.claim_sort_column).casefold(),
            reverse=self.claim_sort_descending,
        )
        self._update_claim_headings()
        for index, claim in enumerate(claims):
            claim_id = str(claim.get("claim_id") or f"missing-id-{index}")
            status = claim_status(data, claim)
            self.claims.insert(
                "",
                "end",
                iid=claim_id,
                values=(
                    format_date_for_display(str(claim.get("due_date", ""))),
                    money_text(claim_total(claim)),
                    CLAIM_STATUS_LABELS.get(status, status.upper()),
                    claim.get("title", "Beitrag"),
                ),
            )
        for payment in sorted(
            data.payments,
            key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
            reverse=True,
        ):
            payment_id = str(payment.get("payment_id", ""))
            amount = payment.get("amount", "0")
            allocated = payment_allocated_total(data, payment_id)
            available = max(Decimal(str(amount)) - allocated, Decimal("0"))
            self.payments.insert(
                "",
                "end",
                iid=payment_id,
                values=(
                    format_date_for_display(str(payment.get("date", ""))),
                    f"{money_text(amount)} EUR",
                    f"{money_text(allocated)} EUR",
                    f"{money_text(available)} EUR",
                    payment.get("reference", ""),
                ),
            )
        self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
        total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
        total_allocated = sum(
            (payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
            Decimal("0"),
        )
        self.payment_summary.set(
            f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
            f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
        )

    def _refresh_donations(self) -> None:
        self.donations.delete(*self.donations.get_children())
        try:
            data = self.repository.get_contributions(self.member_id)
        except RepositoryError as exc:
            self.donation_summary.set(f"FEHLER: {exc}")
            return
        for donation in sorted(
            data.donations,
            key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
            reverse=True,
        ):
            donation_id = str(donation.get("donation_id", ""))
            amount = donation.get("amount", "0")
            allocated = donation_allocated_total(data, donation_id)
            balance = donation_balance(data, donation)
            status = donation_status(data, donation)
            reference = " · ".join(
                part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
            )
            self.donations.insert(
                "",
                "end",
                iid=donation_id,
                values=(
                    format_date_for_display(str(donation.get("date", ""))),
                    f"{money_text(amount)} EUR",
                    f"{money_text(allocated)} EUR",
                    f"{money_text(balance)} EUR",
                    DONATION_STATUS_LABELS.get(status, status.upper()),
                    reference,
                ),
            )
        total_amount = sum(
            (Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
        )
        self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")

    def _refresh_contribution_overrides(self) -> None:
        self.contribution_overrides.delete(*self.contribution_overrides.get_children())
        try:
            data = self.repository.get_contributions(self.member_id)
        except RepositoryError:
            return
        for override in sorted(
            data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
        ):
            kind = str(override.get("kind", ""))
            value = str(override.get("value", ""))
            value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
            self.contribution_overrides.insert(
                "",
                "end",
                iid=str(override.get("override_id", "")),
                values=(
                    format_month_for_display(str(override.get("valid_from", ""))),
                    format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
                    CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
                    value_text,
                    override.get("reason", ""),
                ),
            )

    def _toggle_claim_sort(self, column: str) -> None:
        if self.claim_sort_column == column:
            self.claim_sort_descending = not self.claim_sort_descending
        else:
            self.claim_sort_column = column
            self.claim_sort_descending = False
        self._refresh_contributions()

    def _update_claim_headings(self) -> None:
        for key, title, _width in CLAIM_TABLE_COLUMNS:
            suffix = ""
            if key == self.claim_sort_column:
                suffix = " v" if self.claim_sort_descending else " ^"
            self.claims.heading(
                key,
                text=f"{title}{suffix}",
                command=lambda column=key: self._toggle_claim_sort(column),
            )

    def _open_selected_claim(self) -> None:
        selected = self.claims.selection()
        if selected and not selected[0].startswith("missing-id-"):
            self.on_open_claim(self.member_id, selected[0])

    def _edit_selected_payment(self) -> None:
        selected = self.payments.selection()
        if not selected:
            messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
            return
        try:
            PaymentEditDialog(
                self,
                self.repository,
                self.member_id,
                selected[0],
                self._payment_changed,
            )
        except RepositoryError as exc:
            messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)

    def _delete_selected_payment(self) -> None:
        selected = self.payments.selection()
        if not selected:
            messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
            return
        values = self.payments.item(selected[0], "values")
        label = f"{values[0]} · {values[1]}" if values else selected[0]
        if not messagebox.askyesno(
            "Zahlung wirklich löschen?",
            f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
            parent=self,
        ):
            return
        try:
            self.repository.delete_payment(self.member_id, selected[0])
        except RepositoryError as exc:
            messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
            return
        self._payment_changed()

    def _create_payment(self) -> None:
        PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)

    def _import_payments(self) -> None:
        GnuCashImportDialog(
            self, self.repository, self.member_id, self.config, self._payment_changed
        )

    def _payment_changed(self) -> None:
        self.refresh()
        self.on_changed()

    def _selected_donation(self) -> dict | None:
        selected = self.donations.selection()
        if not selected:
            return None
        try:
            _data, donation = self.repository.get_donation(self.member_id, selected[0])
        except RepositoryError:
            return None
        return donation

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

    def _edit_selected_donation(self) -> None:
        donation = self._selected_donation()
        if not donation:
            messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
            return
        DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)

    def _delete_selected_donation(self) -> None:
        donation = self._selected_donation()
        if not donation:
            messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
            return
        data = self.repository.get_contributions(self.member_id)
        allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
        detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
        if allocated:
            detail += (
                f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
                "gelöst und stehen danach wieder frei zur Verfügung."
            )
        if not messagebox.askyesno("Spende löschen", detail, parent=self):
            return
        try:
            self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
        except RepositoryError as exc:
            messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
            return
        self._donation_changed()

    def _record_donation_payment(self) -> None:
        donation = self._selected_donation()
        if not donation:
            messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
            return
        data = self.repository.get_contributions(self.member_id)
        DonationPaymentDialog(
            self,
            self.repository,
            self.member_id,
            str(donation.get("donation_id", "")),
            donation_balance(data, donation),
            self._donation_changed,
        )

    def _allocate_donation_payment(self) -> None:
        donation = self._selected_donation()
        if not donation:
            messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
            return
        data = self.repository.get_contributions(self.member_id)
        AllocateDonationPaymentDialog(
            self,
            self.repository,
            self.member_id,
            str(donation.get("donation_id", "")),
            donation_balance(data, donation),
            self._donation_changed,
        )

    def _donation_changed(self) -> None:
        self.refresh()
        self.on_changed()

    def _selected_contribution_override(self) -> dict | None:
        selected = self.contribution_overrides.selection()
        if not selected:
            return None
        try:
            _data, override = self.repository.get_contribution_override(self.member_id, selected[0])
        except RepositoryError:
            return None
        return override

    def _create_contribution_override(self) -> None:
        ContributionOverrideEditDialog(
            self, self.repository, self.member_id, self._contribution_override_changed
        )

    def _edit_selected_contribution_override(self) -> None:
        override = self._selected_contribution_override()
        if not override:
            messagebox.showinfo(
                "Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
            )
            return
        ContributionOverrideEditDialog(
            self, self.repository, self.member_id, self._contribution_override_changed, override
        )

    def _delete_selected_contribution_override(self) -> None:
        override = self._selected_contribution_override()
        if not override:
            messagebox.showinfo(
                "Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
            )
            return
        if not messagebox.askyesno(
            "Abweichenden Beitrag löschen",
            "Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
            "davon unberührt.",
            parent=self,
        ):
            return
        try:
            self.repository.delete_contribution_override(
                self.member_id, str(override.get("override_id", ""))
            )
        except RepositoryError as exc:
            messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
            return
        self._contribution_override_changed()

    def _contribution_override_changed(self) -> None:
        self.refresh()
        self.on_changed()

    def _refresh_documents(self) -> None:
        self.documents.delete(*self.documents.get_children())
        self.document_paths.clear()
        root = self.repository.members_root / self.member_id / "files"
        for index, path in enumerate(sorted(root.rglob("*"))):
            if path.is_file():
                item_id = f"document:{index}"
                self.document_paths[item_id] = path
                try:
                    stat = path.stat()
                    modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M")
                    size = _file_size(stat.st_size)
                except OSError:
                    modified = "—"
                    size = "—"
                self.documents.insert(
                    "",
                    "end",
                    iid=item_id,
                    values=(
                        path.relative_to(root),
                        path.suffix.removeprefix(".").upper() or "DATEI",
                        modified,
                        size,
                    ),
                )

    def _refresh_assets(self) -> None:
        self.assets_tree.delete(*self.assets_tree.get_children())
        held_assets = self.repository.list_member_assets(self.member_id)
        owned_assets = self.repository.list_member_owned_assets(self.member_id)
        assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
        assets = list(assets_by_id.values())
        self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
        for asset in assets:
            self.assets_tree.insert(
                "",
                "end",
                iid=asset.asset_id,
                values=(
                    asset.label,
                    asset.category,
                    asset.inventory_number,
                    (
                        "EIGENTUM · DEM VEREIN ÜBERLASSEN"
                        if asset.owner_member_id == self.member_id and asset.custody_type == "club"
                        else "EIGENTUM"
                        if asset.owner_member_id == self.member_id
                        else ASSET_STATUS_LABELS.get(asset.status, asset.status)
                    ),
                ),
            )

    def _save(self) -> None:
        warnings = self.repository.member_hash_warnings(self.member_id)
        if warnings:
            self._confirm_integrity_and_then(self._save_confirmed)
            return
        self._save_confirmed()

    def _save_confirmed(self) -> None:
        for key, variable in self.variables.items():
            raw_value = variable.get()
            value = raw_value.strip() if isinstance(raw_value, str) else raw_value
            if key == "status":
                value = storage_key(STATUS_LABELS, value)
            elif key == "payment_frequency":
                value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
            setattr(self.member, key, value)
        if self.notes_text is not None:
            self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
        try:
            self.repository.save_member(self.member)
            self.repository.refresh_member_record_hashes(self.member_id)
        except RepositoryError as exc:
            messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
            return
        self.refresh()
        self.on_changed()

    def _confirm_integrity_banner(self) -> None:
        self._confirm_integrity_and_then(self._refresh_hashes_only)

    def _refresh_hashes_only(self) -> None:
        try:
            self.repository.refresh_member_record_hashes(self.member_id)
        except RepositoryError as exc:
            messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
            return
        self.refresh()
        self.on_changed()

    def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
        warnings = self.repository.member_hash_warnings(self.member_id)
        if not warnings:
            callback()
            return
        IntegrityWarningDialog(
            self,
            title="Externe Änderungen bestätigen",
            warnings=warnings,
            on_confirm=callback,
        )

    def _add_comment(self) -> None:
        text = self.comment_var.get().strip()
        if not text:
            return
        try:
            self.repository.append_event(
                self.member_id,
                event_type="board_comment",
                summary=text,
                actor_type="user",
                actor_name="Vorstand",
            )
        except RepositoryError as exc:
            messagebox.showerror("Kommentar konnte nicht gespeichert werden", str(exc), parent=self)
            return
        self.comment_var.set("")
        self._refresh_events()

    def _open_files(self) -> None:
        path = self.repository.members_root / self.member_id / "files"
        self._open_path(path)

    def _return_selected_asset(self) -> None:
        selected = self.assets_tree.selection()
        if selected:
            self.on_return_asset(selected[0])

    def _open_selected_asset(self) -> None:
        selected = self.assets_tree.selection()
        if selected:
            self.on_open_asset(selected[0])

    def _open_selected_document(self) -> None:
        selected = self.documents.selection()
        if selected and selected[0] in self.document_paths:
            self._open_path(self.document_paths[selected[0]])

    def _open_path(self, path: Path) -> None:
        try:
            open_path(path)
        except OSError as exc:
            messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)

    def _send_welcome_mail(self) -> None:
        WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)

    def _welcome_mail_sent(self) -> None:
        self._refresh_documents()
        self._refresh_events()
        self.on_changed()

    def _create_document(self) -> None:
        DocumentTemplateDialog(
            self,
            self.repository,
            self.member_id,
            self._document_generated,
        )

    def _document_generated(self, _path: Path) -> None:
        self._refresh_documents()
        self._refresh_events()
        self.on_changed()


def _format_timestamp(event: Event) -> str:
    try:
        return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M")
    except ValueError:
        return event.timestamp[:16]


def _event_label(event: Event) -> str:
    if event.actor_type == "system":
        return f"[AUTO] {event.summary}"
    return event.summary


def _file_size(size: int) -> str:
    if size < 1024:
        return f"{size} B"
    if size < 1024 * 1024:
        return f"{size / 1024:.1f} KiB"
    return f"{size / (1024 * 1024):.1f} MiB"
