#!/usr/bin/env python
"""Convert CAMT.053 XML to QIF

Currently tested banks are Citadele and Luminor. The QIFs are produced with the
intent to import them to Banktivity.

This is configured by a configuration file ~/.xml2qifrc which contains a
section per rule. Each rule must have some input and output keys.
Input keys:
- match (required): a regex to match one of the text fields. The following
  fields are attempted to match in this order, if present: debitor, creditor,
  unstructured information.
- amount (optional): if present, then must be equal.
- type (optional): if present, then must be equal. Currently supported types
  are:
  - loan: domain is "Loans, Deposits, & Syndications"
  - cash_from_card: domain is "Payments", family is "Customer Card
    Transaction", & subfamily is "Cash Withdrawal"
  - card_fee: domain is "Payments", family is "Customer Card Transaction", &
    subfamily is "Fees"
  - bank_service: domain is "Payments" and either family is "Other" &
    subfamily is "Other", or family is "Miscellaneous Debit Operations" &
    subfamily is "Charges".
  - deposit: type is deposit.
Output keys, all of which are required:
- payee
- category

A [DEFAULT] section with keys is rejected: configparser would inject the keys
into every rule, silently changing rules that do not spell those keys out
themselves. An empty [DEFAULT] header is harmless and accepted.

When no rule matches a transaction, the script asks Codex (requires the codex
CLI on PATH) to either suggest a new rule or edit an existing one (broadening a
near-match rather than adding a near-duplicate). The prompt sent to the OpenAI
API includes the transaction XML and the entire ~/.xml2qifrc contents. Codex
runs as a read-only sandboxed agent that may additionally read any local file
while answering, and whatever it reads also reaches the OpenAI API. The
suggested new or edited rule is written to ~/.xml2qifrc only after interactive
confirmation; an edit shows a before/after of the affected section. The Codex
call itself is deliberately not gated by its own confirmation, even though
counterparty-written statement text both triggers and steers it; that egress is
an accepted risk for this tool. Do not add a pre-run prompt.

TODO(laurynas): in the above, make match optional for the cases where type
determines everything.

TODO(laurynas): transactions import to Banktivity in Cleared status,
regardless of whether QIF "Cleared status" is present and with what value.
Banktivity QIF exports omit it altogether.

TODO(laurynas): transfers between accounts do not work, but could be cleared
up with import rules
"""

import argparse
import os
import re
import sys
import xml.etree.ElementTree as ET
from datetime import datetime
from enum import Enum, auto

# Helper modules private to this script live beside it (imported via
# sys.path[0], which resolves the stowed symlink, so script and modules always
# come from the same checkout); modules shared across scripts go in
# ~/usr/lib/python.
from xml2qif_rules import (
    MissingRuleError,
    RuleConfigError,
    RuleSuggestionError,
    find_transaction_rule,
    load_rule_config,
    replace_control_characters,
)
from xml2qif_suggest import suggest_and_confirm_rule

CONFIG_FN = "~/.xml2qifrc"

NS = {"ns": "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"}
# XPaths inside a document:
XPATH_TRANSACTIONS = "ns:BkToCstmrStmt/ns:Stmt/ns:Ntry"


def add_from_string(mapping):
    """Add a from_string method to an enum class that maps the CAMT.053 strings
    in XML to enum values"""

    def decorator(enum_class):
        # Construct the reverse mapping once and store it as a class attribute
        reverse_mapping = {
            str_val: getattr(enum_class, enum_key)
            for str_val, enum_key in mapping.items()
        }
        setattr(enum_class, "_str_to_enum", reverse_mapping)

        @classmethod
        def from_string(cls, string):
            try:
                return cls._str_to_enum[string]  # pylint: disable=E1101
            except KeyError:
                # repr-quote: the code comes from untrusted XML and may be
                # None for an empty element
                raise ValueError(
                    f"Unrecognized {cls.__name__} code {string!r}"
                ) from None

        enum_class.from_string = from_string
        return enum_class

    return decorator


@add_from_string({"CRDT": "DEPOSIT", "DBIT": "WITHDRAWAL"})
class TransactionType(Enum):
    """A CAMT.053 transaction type"""

    DEPOSIT = auto()
    WITHDRAWAL = auto()


@add_from_string(
    {
        "PMNT": "PAYMENTS",
        "LDAS": "LOANS_DEPOSITS_SYNDICATIONS",
        "XTND": "EXTENDED_DOMAIN",
    }
)
class TransactionDomain(Enum):
    """A CAMT.053 transaction domain"""

    PAYMENTS = auto()
    LOANS_DEPOSITS_SYNDICATIONS = auto()
    EXTENDED_DOMAIN = auto()


@add_from_string(
    {
        "CCRD": "CUSTOMER_CARD_TRANSACTION",
        "RCDT": "RECEIVED_CREDIT_TRANSFER",
        "ICDT": "ISSUED_CREDIT_TRANSFER",
        "MDOP": "MISC_DEBIT_OPS",
        "MCOP": "MISC_CREDIT_OPS",
        "NTAV": "NOT_AVAILABLE",
        "OTHR": "OTHER",
    }
)
class PaymentsTransactionFamily(Enum):
    """A CAMT.053 transaction family in the Payments domain"""

    CUSTOMER_CARD_TRANSACTION = auto()
    RECEIVED_CREDIT_TRANSFER = auto()
    ISSUED_CREDIT_TRANSFER = auto()
    MISC_DEBIT_OPS = auto()
    MISC_CREDIT_OPS = auto()
    NOT_AVAILABLE = auto()
    OTHER = auto()


@add_from_string({"FTLN": "FIXED_TERM_LOANS", "MDOP": "MISC_DEBIT_OPS"})
class LDASTransactionFamily(Enum):
    """A CAMT.053 transaction family in the Loans, Deposits & Syndications
    domain"""

    FIXED_TERM_LOANS = auto()
    MISC_DEBIT_OPS = auto()


@add_from_string({"NTAV": "NOT_AVAILABLE"})
class XTNDTransactionFamily(Enum):
    """A CAMT.053 transaction family in the Extended Domain"""

    NOT_AVAILABLE = auto()


@add_from_string({"NTAV": "NOT_AVAILABLE"})
class XTNDTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in the Extended Domain"""

    NOT_AVAILABLE = auto()


@add_from_string(
    {
        "POSD": "POS_PAYMENT_DEBIT_CARD",
        "CWDL": "CASH_WITHDRAWAL",
        "FEES": "FEES",
        "DAJT": "CREDIT_ADJUSTMENT",
        "CAJT": "DEBIT_ADJUSTMENT",
        "NTAV": "NOT_AVAILABLE",
    }
)
class CCRDTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in a Customer Card Transaction (CCRD)
    family."""

    POS_PAYMENT_DEBIT_CARD = auto()
    CASH_WITHDRAWAL = auto()
    FEES = auto()
    CREDIT_ADJUSTMENT = auto()
    DEBIT_ADJUSTMENT = auto()
    NOT_AVAILABLE = auto()


@add_from_string(
    {
        "ESCT": "SEPA_CREDIT_TRANSFER",
        "BOOK": "INTERNAL_BOOK_TRANSFER",
        "SALA": "PAYROLL_SALARY_PAYMENT",
        "DMCT": "DOMESTIC_CREDIT_TRANSFER",
    }
)
class ICDTTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in an Issued Credit Transfer (ICDT)
    family."""

    SEPA_CREDIT_TRANSFER = auto()
    INTERNAL_BOOK_TRANSFER = auto()
    PAYROLL_SALARY_PAYMENT = auto()
    DOMESTIC_CREDIT_TRANSFER = auto()


@add_from_string({"OTHR": "OTHER"})
class OtherTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in an Other family"""

    OTHER = auto()


@add_from_string({"CHRG": "CHARGES", "INTR": "INTEREST"})
class MiscOpsTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in a Miscellaneous Debit/Credit
    Operations family"""

    CHARGES = auto()
    INTEREST = auto()


@add_from_string({"RIMB": "REIMBURSEMENT"})
class FixedTermLoanTransactionSubfamily(Enum):
    """A CAMT.053 transaction subfamily in a Fixed Term Loans family"""

    REIMBURSEMENT = auto()  # Absolutely incorrect yet Luminor returns this


class Transaction:
    """Fields common to both XML and QIF transactions."""

    def __init__(self, date, transaction_type, amount):
        self._date = date
        self._type = transaction_type
        self._amount = amount

    def __str__(self):
        return (
            f"Transaction(date: {self._date}, type: {self._type}, "
            f"amount: {self._amount})"
        )

    @property
    def date(self):
        """Get the transaction date."""
        return self._date

    @property
    def transaction_type(self):
        """Get the transaction type."""
        return self._type

    @property
    def amount(self):
        """Get the transaction amount."""
        return self._amount


class XMLTransaction(Transaction):
    """A transaction from XML file."""

    _US_DATE_PREFIX = re.compile("^[0-9][0-9]/[0-9][0-9]/[0-9][0-9][0-9][0-9] .*$")

    _XPATH_UNSTRUCTURED = ".//ns:NtryDtls/ns:TxDtls/ns:RmtInf/ns:Ustrd"
    _XPATH_VALUE_DATE = ".//ns:ValDt/ns:Dt"
    _XPATH_BOOKING_DATE = ".//ns:BookgDt/ns:Dt"
    _XPATH_AMOUNT = ".//ns:Amt"
    _XPATH_CREDITOR = ".//ns:NtryDtls/ns:TxDtls/ns:RltdPties/ns:Cdtr/ns:Nm"
    _XPATH_DEBITOR = ".//ns:NtryDtls/ns:TxDtls/ns:RltdPties/ns:Dbtr/ns:Nm"
    _XPATH_TYPE_INDICATOR = ".//ns:CdtDbtInd"
    _XPATH_DOMAIN = ".//ns:BkTxCd/ns:Domn/ns:Cd"
    _XPATH_FAMILY = ".//ns:BkTxCd/ns:Domn/ns:Fmly/ns:Cd"
    _XPATH_SUBFAMILY = ".//ns:BkTxCd/ns:Domn/ns:Fmly/ns:SubFmlyCd"

    def __init__(self, xml_obj):
        self._xml_obj = xml_obj
        self._unstructured_info = self._get_unstructured_info(xml_obj)
        super().__init__(
            self._get_transaction_date(xml_obj),
            self._get_transaction_type(xml_obj),
            self._get_transaction_amount(xml_obj),
        )
        self._creditor = self._get_creditor(xml_obj)
        self._debitor = self._get_debitor(xml_obj)
        self._domain, self._family, self._subfamily = self._parse_txn_codes(xml_obj)

    def __str__(self):
        return (
            f"XMLTransaction({super().__str__()}, creditor: {self._creditor}, "
            f"debitor: {self._debitor}, "
            f"unstructured_info: {self._unstructured_info}, "
            f"domain: {self._domain}, "
            f"family: {self._family}, subfamily: {self._subfamily})"
        )

    @property
    def xml_obj(self):
        """Get the original XML transaction object."""
        return self._xml_obj

    @property
    def creditor(self):
        """Get the transaction creditor."""
        return self._creditor

    @property
    def debitor(self):
        """Get the transaction debitor."""
        return self._debitor

    @property
    def unstructured_info(self):
        """Get the transaction unstructured information."""
        return self._unstructured_info

    def get_rule_type(self):
        """Get the ad-hoc rule type from the transaction parameters."""
        if self.transaction_type == TransactionType.DEPOSIT:
            return "deposit"
        if self._domain == TransactionDomain.LOANS_DEPOSITS_SYNDICATIONS:
            return "loan"
        if self._domain == TransactionDomain.PAYMENTS:
            if self._family == PaymentsTransactionFamily.CUSTOMER_CARD_TRANSACTION:
                if self._subfamily == CCRDTransactionSubfamily.CASH_WITHDRAWAL:
                    return "cash_from_card"
                if self._subfamily == CCRDTransactionSubfamily.FEES:
                    return "card_fee"
            if (
                self._family == PaymentsTransactionFamily.OTHER
                and self._subfamily == OtherTransactionSubfamily.OTHER
            ) or (
                self._family == PaymentsTransactionFamily.MISC_DEBIT_OPS
                and self._subfamily == MiscOpsTransactionSubfamily.CHARGES
            ):
                return "bank_service"
        return ""

    def get_rule_fields(self, rules, rule_type):
        """Find a matching rule and return its fields."""
        rewrite_rule = find_transaction_rule(rules, self, rule_type)
        if rewrite_rule is None:
            raise MissingRuleError(self, rule_type)

        return rewrite_rule["payee"], rewrite_rule["category"]

    @staticmethod
    def _node_text(node):
        """Return the node's stripped text, or None for an absent node or one
        without non-whitespace text: .text is None for an empty element, and
        a blank value is as missing as an absent node and must fail loud or
        fall through, not leak into the QIF output."""
        if node is None or node.text is None:
            return None
        return node.text.strip() or None

    @classmethod
    def _get_unstructured_info(cls, xml_obj):
        """Get the unstructured information or empty string from the XML."""
        # _node_text strips and maps an absent/empty/blank node to None; rule
        # matching needs a string, and an unstripped blank would defeat the
        # start-anchored match
        return cls._node_text(xml_obj.find(cls._XPATH_UNSTRUCTURED, NS)) or ""

    def _get_transaction_date(self, xml_obj):
        """Get the transaction date from the XML."""
        value_date = self._node_text(xml_obj.find(self._XPATH_VALUE_DATE, NS))
        if value_date:
            return value_date

        if self._unstructured_info and self._US_DATE_PREFIX.match(
            self._unstructured_info
        ):
            date_substr = self._unstructured_info[:10]
            date_obj = datetime.strptime(date_substr, "%d/%m/%Y")
            return date_obj.strftime("%Y-%m-%d")

        booking_date = self._node_text(xml_obj.find(self._XPATH_BOOKING_DATE, NS))
        if booking_date:
            return booking_date

        raise ValueError(
            "Don't know how to get the transaction date from the transaction"
        )

    @classmethod
    def _get_transaction_amount(cls, xml_obj):
        """Get the transaction amount from the XML."""
        amount = cls._node_text(xml_obj.find(cls._XPATH_AMOUNT, NS))
        if amount is None:
            raise ValueError("Unable to find amount in the transaction")
        return amount

    @classmethod
    def _get_creditor(cls, xml_obj):
        """Get the creditor or empty string from the transaction"""
        # _node_text strips and maps an absent/empty/blank node to None; an
        # unstripped blank is truthy and would be tried against rules instead
        # of falling through to the next field
        return cls._node_text(xml_obj.find(cls._XPATH_CREDITOR, NS)) or ""

    @classmethod
    def _get_debitor(cls, xml_obj):
        """Get the debitor or empty string from the transaction"""
        # _node_text strips and maps an absent/empty/blank node to None; an
        # unstripped blank is truthy and would be tried against rules instead
        # of falling through to the next field
        return cls._node_text(xml_obj.find(cls._XPATH_DEBITOR, NS)) or ""

    @classmethod
    def _get_transaction_type(cls, xml_obj):
        """Get the transaction type from the transaction"""
        type_indicator_obj = xml_obj.find(cls._XPATH_TYPE_INDICATOR, NS)
        if type_indicator_obj is None:
            raise ValueError("Unable to find transaction type indicator")
        type_indicator_str = type_indicator_obj.text
        # pylint: disable=E1101
        return TransactionType.from_string(type_indicator_str)
        # pylint: enable=E1101

    @classmethod
    def _get_transaction_domain(cls, xml_obj):
        """Get the transaction domain from the transaction"""
        domain_obj = xml_obj.find(cls._XPATH_DOMAIN, NS)
        if domain_obj is None:
            raise ValueError("Unable to find transaction domain code")
        domain_str = domain_obj.text
        # pylint: disable=E1101
        return TransactionDomain.from_string(domain_str)
        # pylint: enable=E1101

    @classmethod
    def _get_transaction_family_string(cls, xml_obj):
        """Get the transaction family string."""
        family_obj = xml_obj.find(cls._XPATH_FAMILY, NS)
        if family_obj is None:
            raise ValueError("Unable to find transaction family code")
        return family_obj.text

    @classmethod
    def _get_transaction_subfamily_string(cls, xml_obj):
        """Get the transaction subfamily string."""
        subfamily_obj = xml_obj.find(cls._XPATH_SUBFAMILY, NS)
        if subfamily_obj is None:
            raise ValueError("Unable to find transaction subfamily code")
        return subfamily_obj.text

    @classmethod
    def _parse_txn_codes(cls, xml_obj):  # pylint: disable=too-many-branches
        """Parse the transaction codes to return domain, family, & subfamily"""
        domain = cls._get_transaction_domain(xml_obj)
        family_str = cls._get_transaction_family_string(xml_obj)
        subfamily_str = cls._get_transaction_subfamily_string(xml_obj)
        # pylint: disable=E1101
        if domain == TransactionDomain.PAYMENTS:
            family = PaymentsTransactionFamily.from_string(family_str)
            if family == PaymentsTransactionFamily.CUSTOMER_CARD_TRANSACTION:
                subfamily = CCRDTransactionSubfamily.from_string(subfamily_str)
            elif family == PaymentsTransactionFamily.ISSUED_CREDIT_TRANSFER:
                subfamily = ICDTTransactionSubfamily.from_string(subfamily_str)
            elif family in (
                PaymentsTransactionFamily.MISC_DEBIT_OPS,
                PaymentsTransactionFamily.MISC_CREDIT_OPS,
            ):
                subfamily = MiscOpsTransactionSubfamily.from_string(subfamily_str)
            elif family == PaymentsTransactionFamily.OTHER:
                subfamily = OtherTransactionSubfamily.from_string(subfamily_str)
            else:
                subfamily = ""
        elif domain == TransactionDomain.LOANS_DEPOSITS_SYNDICATIONS:
            family = LDASTransactionFamily.from_string(family_str)
            if family == LDASTransactionFamily.MISC_DEBIT_OPS:
                subfamily = MiscOpsTransactionSubfamily.from_string(subfamily_str)
            elif family == LDASTransactionFamily.FIXED_TERM_LOANS:
                subfamily = FixedTermLoanTransactionSubfamily.from_string(subfamily_str)
            else:
                subfamily = ""
        elif domain == TransactionDomain.EXTENDED_DOMAIN:
            family = XTNDTransactionFamily.from_string(family_str)
            if family == XTNDTransactionFamily.NOT_AVAILABLE:
                subfamily = XTNDTransactionSubfamily.from_string(subfamily_str)
            else:
                subfamily = ""
        else:
            family = ""
            subfamily = ""
        # pylint: enable=E1101
        return domain, family, subfamily


class QIFTransaction(Transaction):
    """A QIF transaction."""

    def __init__(self, xml_txn, rules):
        super().__init__(xml_txn.date, xml_txn.transaction_type, xml_txn.amount)
        self._rule_type = xml_txn.get_rule_type()
        self._payee, self._category = xml_txn.get_rule_fields(rules, self._rule_type)

    def __str__(self):
        return (
            f"QIFTransaction({super().__str__()}, "
            f"rule_type: {self._rule_type}, payee: {self._payee}, "
            f"category: {self._category})"
        )

    def write(self, f):
        """Serialize this transaction to QIF."""
        f.write(f"D{self.date}\n")
        if self.transaction_type == TransactionType.DEPOSIT:
            f.write(f"T{self.amount}\n")
        elif self.transaction_type == TransactionType.WITHDRAWAL:
            f.write(f"T-{self.amount}\n")
        else:
            raise ValueError(f"Unknown transaction type {self.transaction_type}")
        f.write(f"P{self._payee}\n")
        f.write(f"L{self._category}\n")
        f.write("^\n")


def process_transactions(root, rules, config_path, output_file):
    """Convert all transactions in root and write QIF records to output_file.

    On MissingRuleError, interactively suggests a rule via Codex, inserts it
    into config_path after user confirmation, reloads the rules via
    load_rule_config, and retries the failing transaction; all subsequent
    transactions in the same run use the reloaded config.
    """
    for xml_obj in root.findall(XPATH_TRANSACTIONS, NS):
        xml_transaction = XMLTransaction(xml_obj)
        print(
            "Before: "
            f"{replace_control_characters(str(xml_transaction), single_line=True)}"
        )

        try:
            qif_transaction = QIFTransaction(xml_transaction, rules)
        except MissingRuleError as exc:
            try:
                suggest_and_confirm_rule(
                    config_path, exc.xml_transaction, exc.rule_type
                )
            except (RuleSuggestionError, RuleConfigError, OSError) as sugg_exc:
                # The missing rule is the user's action item; any suggestion
                # flow failure, including a config read/write one, is only a
                # diagnostic and must not displace it
                print(sugg_exc)
                raise exc from sugg_exc
            # Reload so the in-memory rule order matches the rewritten file
            rules = load_rule_config(config_path)
            # A second miss means the just-added rule still does not match
            # (e.g. an amount filter rejects this transaction). Re-raise it
            # with a distinct prefix rather than re-entering the suggestion
            # flow, which would loop on the same miss; a bare MissingRuleError
            # would read as the initial prompt and invite that loop.
            try:
                qif_transaction = QIFTransaction(xml_transaction, rules)
            except MissingRuleError as retry_exc:
                raise ValueError(
                    f"Added rule still did not match: {retry_exc}"
                ) from retry_exc

        print(
            "After: "
            f"{replace_control_characters(str(qif_transaction), single_line=True)}"
        )

        qif_transaction.write(output_file)


def main():
    """Program entry point."""
    arg_parser = argparse.ArgumentParser(
        description="Convert bank statement in XML format (camt.053) to QIF for "
        "importing to Banktivity."
    )

    arg_parser.add_argument("input", type=str, help="The input file")
    arg_parser.add_argument("output", type=str, help="The output file")

    args = arg_parser.parse_args()

    config_path = os.path.expanduser(CONFIG_FN)
    try:
        rewrite_rules = load_rule_config(config_path)
    except FileNotFoundError:
        sys.exit(f"Config file not found: {config_path}")
    except (OSError, RuleConfigError) as exc:
        sys.exit(str(exc))

    print(f"Rules loaded from {CONFIG_FN}: {len(rewrite_rules)}")

    print(f"Processing {args.input} to {args.output}")

    try:
        tree = ET.parse(args.input)
    except OSError as exc:
        sys.exit(str(exc))
    # Unlike OSError, ParseError does not name the file itself
    except ET.ParseError as exc:
        sys.exit(f"{args.input}: {exc}")

    xml_root = tree.getroot()

    # The output is deliberately written incrementally, not via an atomic
    # write-then-rename: an aborted run (declined suggestion, Codex failure,
    # Ctrl-C) leaves a partial QIF behind, but the abort itself prints a clear
    # error in this interactive flow, and the partial file lets the user
    # inspect how far conversion got. Do not "fix" this.
    try:
        with open(args.output, "w", encoding="utf-8") as output_file:
            output_file.write("!Type:Bank\n")
            process_transactions(xml_root, rewrite_rules, config_path, output_file)
    # ValueError subsumes the custom rule errors (all ValueError subclasses)
    # and the plain raises for malformed XML content, such as unrecognized
    # CAMT codes or a missing amount
    except (ValueError, OSError) as exc:
        sys.exit(str(exc))


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)
