import re
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from enum import Enum
from functools import lru_cache
from pathlib import Path

from moneyed import Currency, Money, list_all_currencies, zero

DATE_FORMAT = "%Y-%m-%d"

RE_HEADER = re.compile(r"^(\d{4}).(\d{2}).(\d{2}) *([!*]|) *(\(.*\)|) *(.*)$")
RE_HEADER_COMMENT = re.compile(r"^(\d{4}).(\d{2}).(\d{2}) *([!*]|) *(\(.*\)|) *(.*) *; *(.*)")

RE_POSTING_SYMBOL_COMMENT = re.compile(r"^[ \t]*([\w\d:. ]*)  (.)([\d.-]*) *; *(.*)$")
RE_POSTING_SYMBOL = re.compile(r"^[ \t]*([\w\d:. ]*)  (.)([\d.-]*)$")
RE_POSTING_NO_SYMBOL_COMMENT = re.compile(r"^[ \t]*([\w\d:. ]*)  ([\d.-]*) *(\w{3}) *; *(.*)$")
RE_POSTING_NO_SYMBOL = re.compile(r"^[ \t]*([\w\d:. ]*)  ([\d.-]*) *(\w{3})$")
RE_POSTING_NO_AMOUNT = re.compile(r"^[ \t]*([\w\d:. ]*)  $")
RE_POSTING_NO_AMOUNT_COMMENT = re.compile(r"^[ \t]*([\w\d:. ]*)  ;(.*)$")


class Mark(Enum):
    UNMARKED = ""
    PENDING = "!"
    CLEARED = "*"


@dataclass
class Posting:
    account: list[str] = field(init=True)
    amount: Money | None = field(init=True)
    comment: str = field(init=True)

    @property
    def to_str(self):
        results: list = list()

        results.append(f"  {str(':'.join(self.account)).strip()}")

        if self.amount:
            symbol = get_symbol_from_currency(self.amount.currency)

            results.append("  ")

            if len(symbol) == 1:
                results.append(f"{symbol}{str(self.amount.amount)}")
            else:
                results.append(f"{str(self.amount.amount)} {symbol}")

        if self.comment:
            if self.amount:
                results.append(f" ; {self.comment.strip()}")
            else:
                results.append(f"  ; {self.comment.strip()}")
        return "".join(results)


@dataclass
class Entry:
    edate: date = field(init=True)
    status: Mark = field(init=True, default=Mark.UNMARKED)
    code: str = field(init=True, default=str())
    description: str = field(init=True, default=str())
    comment: str = field(init=True, default=str())
    postings: list[Posting] = field(init=True, default_factory=list)

    def balance_postings(self):
        # balance postings

        postings_amounts_none = sum([1 for posting in self.postings if posting.amount is None])

        if postings_amounts_none > 1:
            raise ValueError("Only one posting in an entry can have a blank amount")

        currency: None | Currency = None
        for posting in self.postings:
            if posting.amount:
                currency = posting.amount.currency

        if not currency:
            return

        total = currency.zero
        for posting in self.postings:
            total += posting.amount

        for posting in self.postings:
            if posting.amount is None:
                posting.amount = -1 * total

    @property
    def to_str(self):
        self.balance_postings()
        result: list[str] = list()

        header = f"{self.edate.strftime(format=DATE_FORMAT)}"

        if self.status.value:
            header += f" {self.status.value}"

        if self.code:
            header += f" ({self.code})"

        header += f" {self.description.strip()}"

        if self.comment:
            header += f" ; {self.comment.strip()}"

        result.append(header)

        for post in self.postings:
            result.append(post.to_str)

        return "\n".join(result)


def add_month(current_date: date) -> date:
    year = current_date.year
    month = current_date.month
    day = current_date.day

    month = month + 1
    if month > 12:
        year += 1
        month = 1

    return date(year, month, day)


@dataclass
class Payment:
    beginning_principal: Money
    term: date
    interest: Money
    principal: Money
    ending_principal: Money

    @property
    def amount(self):
        return self.interest + self.principal

    def __repr__(self):
        return (
            "<Payment("
            f"term={self.term.strftime(format=DATE_FORMAT)}, "
            f"begin={self.beginning_principal}, "
            f"pmt_i={self.interest}, "
            f"pmt_p={self.principal}, "
            f"total={self.amount}, "
            f"end={self.ending_principal}"
            ")>"
        )


@dataclass
class Loan:
    name: str
    begin: date
    term_months: int
    annual_interest: Decimal
    principal: Money
    early_payments: list[Entry] = field(init=False, default_factory=list)

    def __post_init__(self):
        self.interest = self.annual_interest / 12
        one_plus_term = (1 + self.interest) ** (self.term_months)
        self.payment = self.principal * self.interest * one_plus_term / (one_plus_term - 1)

    def payment_schedule(self) -> dict[date, Payment]:
        payments: dict[date, Payment] = dict()
        current: date = self.begin
        early_payments: dict = dict()
        if self.early_payments:
            early_payments = {entry.edate.strftime("%Y-%m"): entry for entry in self.early_payments}

        while self.principal > self.principal.currency.zero:
            interest_pmt = self.principal * self.interest
            principal_pmt = self.payment - interest_pmt

            if principal_pmt > self.principal:
                principal_pmt = self.principal

            beginning_principal = self.principal
            ending_principal = self.principal - principal_pmt

            if early_payments:
                if current.strftime(format="%Y-%m") in early_payments.keys():
                    self.principal -= abs(early_payments[current.strftime(format="%Y-%m")].entry.postings[0].amount)

            payments[current] = Payment(
                beginning_principal=beginning_principal.round(2),
                term=current,
                interest=interest_pmt.round(2),
                principal=principal_pmt.round(2),
                ending_principal=ending_principal.round(2),
            )

            self.principal -= principal_pmt
            current = add_month(current)

        return payments


def parse_header_line(line: str) -> Entry:
    header = RE_HEADER_COMMENT.match(line)

    if header:
        comment = header.group(7)
    else:
        header = RE_HEADER.match(line)
        comment = ""

    if not header:
        raise ValueError(f"line ({line}) does not match regex for header")

    year = int(header.group(1))
    month = int(header.group(2))
    day = int(header.group(3))
    edate = date(year, month, day)

    match header.group(4):
        case "!":
            status = Mark.PENDING
        case "*":
            status = Mark.CLEARED
        case _:
            status = Mark.UNMARKED

    code = header.group(5).replace("(", "").replace(")", "")
    description = header.group(6)

    return Entry(
        edate=edate,
        status=status,
        code=code,
        description=description,
        comment=comment,
    )


@lru_cache(maxsize=3)
def get_currency_from_symbol(symbol: str) -> Currency:
    currencies = {str(cur.zero)[: str(cur.zero).find("0")]: cur for cur in list_all_currencies()}
    return currencies[symbol]


def get_symbol_from_currency(currency: Currency) -> str:
    return str(currency.zero)[: str(currency.zero).find("0")]


def parse_posting_no_symbol_comment(match: re.Match) -> dict:
    account = match.group(1).split(":")
    currency = match.group(3)
    amount = Money(match.group(2), currency)
    comment = match.group(4)

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_symbol_comment(match: re.Match) -> dict:
    account = match.group(1).split(":")
    currency = get_currency_from_symbol(match.group(2))
    amount = Money(match.group(3), currency)
    comment = match.group(4)

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_no_symbol(match: re.Match) -> dict:
    account = match.group(1).split(":")
    currency = match.group(3)
    amount = Money(match.group(2), currency)
    comment = ""

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_symbol(match: re.Match) -> dict:
    account = match.group(1).split(":")
    currency = get_currency_from_symbol(match.group(2))
    amount = Money(match.group(3), currency)
    comment = ""

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_no_amount(match: re.Match) -> dict:
    account = match.group(1).split(":")
    amount = Money(zero, "USD")
    comment = ""

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_no_amount_comment(match: re.Match) -> dict:
    account = match.group(1).split(":")
    amount = Money(zero, "USD")
    comment = match.group(2)

    return {"account": account, "amount": amount, "comment": comment}


def parse_posting_line(line: str) -> Posting:
    posting_no_symbol = RE_POSTING_NO_SYMBOL.match(line)
    posting_no_symbol_comment = RE_POSTING_NO_SYMBOL_COMMENT.match(line)
    posting_symbol = RE_POSTING_SYMBOL.match(line)
    posting_symbol_comment = RE_POSTING_SYMBOL_COMMENT.match(line)
    posting_no_amount = RE_POSTING_NO_AMOUNT.match(line)
    posting_no_amount_comment = RE_POSTING_NO_AMOUNT_COMMENT.match(line)

    kwargs = dict()
    if posting_no_symbol:
        kwargs = parse_posting_no_symbol(posting_no_symbol)

    if posting_no_symbol_comment:
        kwargs = parse_posting_no_symbol_comment(posting_no_symbol_comment)

    if posting_symbol:
        kwargs = parse_posting_symbol(posting_symbol)

    if posting_symbol_comment:
        kwargs = parse_posting_symbol_comment(posting_symbol_comment)

    if posting_no_amount:
        kwargs = parse_posting_no_amount(posting_no_amount)

    if posting_no_amount_comment:
        kwargs = parse_posting_no_amount_comment(posting_no_amount_comment)

    return Posting(**kwargs)


def entry_from_raw(lines: list) -> Entry:
    entry = parse_header_line(lines[0])

    for line in lines[1:]:
        entry.postings.append(parse_posting_line(line))

    return entry


def parse_journal(path: Path) -> list[Entry]:
    journal = path.read_text().split("\n")
    entries: list[Entry] = list()

    raw_entry: list = list()
    for line in journal:
        if not line.strip() and raw_entry:
            entries.append(entry_from_raw(raw_entry))
            raw_entry = list()
            continue

        if line:
            raw_entry.append(line)

    return entries
