import re
import subprocess
from dataclasses import dataclass, field
from datetime import date, datetime
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
from pandas import DataFrame
from yfinance import Ticker

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())
    payee: str = field(init=True, default=str())
    note: 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) -> list[Payment]:
        initial_principal = self.principal
        payments: list[Payment] = list()
        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
            beginning_principal = self.principal

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

            principal_pmt = self.payment - interest_pmt

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

            ending_principal = self.principal - principal_pmt

            payments.append(
                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)

        self.principal = initial_principal
        return payments

    def opening_entry(self, cash: list[str], liability: list[str]) -> Entry:
        postings = [
            Posting(account=cash, amount=self.principal, comment=""),
            Posting(account=liability, amount=self.principal * -1, comment=""),
        ]
        return Entry(
            edate=self.begin,
            code="",
            status=Mark.UNMARKED,
            description=f"Opening Loan: {self.name}",
            comment="Opening Loan",
            postings=postings,
        )

    def get_entries(
        self,
        cash: list[str],
        expense: list[str],
        liability: list[str],
    ) -> list[Entry]:
        entries: list[Entry] = list()

        entries.append(self.opening_entry(cash=cash, liability=liability))

        payments = self.payment_schedule()
        for payment in payments:
            postings: list = list()

            if payment.amount.amount == Decimal(0):
                continue

            postings.append(
                Posting(
                    account=cash,
                    amount=payment.amount * -1,
                    comment="",
                )
            )
            postings.append(
                Posting(
                    account=expense,
                    amount=payment.interest,
                    comment="",
                )
            )
            postings.append(
                Posting(
                    account=liability,
                    amount=payment.principal,
                    comment="",
                )
            )

            entries.append(
                Entry(
                    edate=payment.term,
                    status=Mark.UNMARKED,
                    code="",
                    description=self.name,
                    comment="",
                    postings=postings,
                )
            )

        # if self.early_payments:
        # entries.extend(self.early_payments)

        return sorted(entries, key=lambda entry: entry.edate)


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)

    payee = description
    note = ""
    temp = description.split("|")
    if len(temp) == 2:
        payee = temp[0]
        note = temp[1]

    return Entry(
        edate=edate,
        status=status,
        code=code,
        description=description,
        payee=payee,
        note=note,
        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 get_path_text(path: Path) -> str:
    return path.read_text()


def parse_journal(path: Path | None = None, print_str: str | None = None) -> list[Entry]:
    journal: list = list()
    if path:
        journal = get_path_text(path).split("\n")
    elif print_str:
        journal = print_str.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


def get_commodities() -> list:
    symbols = subprocess.run(
        ["hledger", "commodities"],
        capture_output=True,
        text=True,
    )

    results = symbols.stdout.split("\n")

    return [result for result in results if result not in ["", "$"]]


def get_time_span() -> tuple[int, int]:
    transactions_span = subprocess.run(
        ["hledger", "stats"],
        capture_output=True,
        text=True,
    )

    output = transactions_span.stdout.split("\n")

    dates_match = re.match(r".*(\d{4})-\d{2}-\d{2} to (\d{4})-\d{2}-\d{2}.*", output[2])

    if not dates_match:
        return datetime.now().year, datetime.now().year

    date_from = dates_match.group(1)
    date_to = dates_match.group(2)

    return int(date_from), int(date_to)


def get_p_directives(df: DataFrame, commodity: str, price_type: str = "High") -> dict[int, list[str]]:
    split_col = "Stock Splits"
    df = df.sort_index(ascending=False)
    df[split_col] = df[split_col].replace(0.00, 1.00)
    df[split_col] = df[split_col].cumprod()

    df["normalized_price"] = df[price_type] * df[split_col]

    p_directives: dict[int, list[str]] = dict()
    for row in df.iterrows():
        year = row[0].date().year  # type: ignore
        pdate = row[0].date().strftime("%Y-%m-%d")  # type: ignore
        data = row[1].to_dict()
        price = data["normalized_price"]

        if year not in p_directives:
            p_directives[year] = list()

        p_directives[year].append(f"P {pdate} {commodity} ${price}")

    return p_directives


def get_prices() -> dict[int, list[str]]:
    commodities = get_commodities()
    start, end = get_time_span()

    p_directives: dict[int, list[str]] = dict()
    for commodity in commodities:
        if not commodity:
            continue

        ticker = Ticker(commodity)

        ticker_directives = get_p_directives(
            ticker.history(start=datetime(start, 1, 1), end=datetime.now().date()),
            commodity,
        )

        for key, value in ticker_directives.items():
            if key not in p_directives:
                p_directives[key] = list()

            p_directives[key].extend(value)

    return p_directives


def write_prices(path_base: Path = Path("~/Documents/Money/ledger/").expanduser()) -> None:
    path_prices = path_base.joinpath("prices")

    if not path_prices.exists():
        path_prices.mkdir(parents=True)

    p_directives = get_prices()

    for key, value in p_directives.items():
        path_year = path_prices.joinpath(str(key))

        print(f"writing {path_year}, lines: {len(value)}")

        if not path_year.exists():
            path_year.mkdir(parents=True)

        path_journal = path_year.joinpath("prices.journal")
        path_journal.unlink()
        path_journal.write_text("\n".join(value))
