# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10

#  Copyright (c) 2023 imacat.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
"""The line item sub-forms for the journal entry management.

"""
import datetime as dt
from decimal import Decimal

import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional

from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
    IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk


class OriginalLineItemExists:
    """The validator to check if the original line item exists."""

    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
        if field.data is None:
            return
        if db.session.get(JournalEntryLineItem, field.data) is None:
            raise ValidationError(lazy_gettext(
                "The original line item does not exist."))


class OriginalLineItemOppositeDebitCredit:
    """The validator to check if the original line item is on the opposite
    debit or credit."""

    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
        if field.data is None:
            return
        original_line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem, field.data)
        if original_line_item is None:
            return
        if isinstance(form, CreditLineItemForm) \
                and original_line_item.is_debit:
            return
        if isinstance(form, DebitLineItemForm) \
                and not original_line_item.is_debit:
            return
        raise ValidationError(lazy_gettext(
            "The original line item is on the same debit or credit."))


class OriginalLineItemNeedOffset:
    """The validator to check if the original line item needs offset."""

    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
        if field.data is None:
            return
        original_line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem, field.data)
        if original_line_item is None:
            return
        if not original_line_item.account.is_need_offset:
            raise ValidationError(lazy_gettext(
                "The original line item does not need offset."))


class OriginalLineItemNotOffset:
    """The validator to check if the original line item is not itself an
    offset item."""

    def __call__(self, form: FlaskForm, field: IntegerField) -> None:
        if field.data is None:
            return
        original_line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem, field.data)
        if original_line_item is None:
            return
        if original_line_item.original_line_item_id is not None:
            raise ValidationError(lazy_gettext(
                "The original line item cannot be an offset item."))


class SameAccountAsOriginalLineItem:
    """The validator to check if the account is the same as the
    original line item."""

    def __call__(self, form: FlaskForm, field: StringField) -> None:
        assert isinstance(form, LineItemForm)
        if field.data is None or form.original_line_item_id.data is None:
            return
        original_line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem,
                             form.original_line_item_id.data)
        if original_line_item is None:
            return
        if field.data != original_line_item.account_code:
            raise ValidationError(lazy_gettext(
                "The account must be the same as the original line item."))


class KeepAccountWhenHavingOffset:
    """The validator to check if the account is the same when having offset."""

    def __call__(self, form: FlaskForm, field: StringField) -> None:
        assert isinstance(form, LineItemForm)
        if field.data is None or form.id.data is None:
            return
        line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem, form.id.data)
        if line_item is None or len(line_item.offsets) == 0:
            return
        if field.data != line_item.account_code:
            raise ValidationError(lazy_gettext(
                "The account must not be changed when there is offset."))


class NotStartPayableFromDebit:
    """The validator to check that a payable line item does not start from
    debit."""

    def __call__(self, form: FlaskForm, field: StringField) -> None:
        assert isinstance(form, DebitLineItemForm)
        if field.data is None \
                or field.data[0] != "2" \
                or form.original_line_item_id.data is not None:
            return
        account: Account | None = Account.find_by_code(field.data)
        if account is not None and account.is_need_offset:
            raise ValidationError(lazy_gettext(
                "A payable line item cannot start from debit."))


class NotStartReceivableFromCredit:
    """The validator to check that a receivable line item does not start
    from credit."""

    def __call__(self, form: FlaskForm, field: StringField) -> None:
        assert isinstance(form, CreditLineItemForm)
        if field.data is None \
                or field.data[0] != "1" \
                or form.original_line_item_id.data is not None:
            return
        account: Account | None = Account.find_by_code(field.data)
        if account is not None and account.is_need_offset:
            raise ValidationError(lazy_gettext(
                "A receivable line item cannot start from credit."))


class PositiveAmount:
    """The validator to check if the amount is positive."""

    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
        if field.data is None:
            return
        if field.data <= 0:
            raise ValidationError(lazy_gettext(
                "Please fill in a positive amount."))


class NotExceedingOriginalLineItemNetBalance:
    """The validator to check if the amount exceeds the net balance of the
    original line item."""

    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
        assert isinstance(form, LineItemForm)
        if field.data is None or form.original_line_item_id.data is None:
            return
        original_line_item: JournalEntryLineItem | None \
            = db.session.get(JournalEntryLineItem,
                             form.original_line_item_id.data)
        if original_line_item is None:
            return
        is_debit: bool = isinstance(form, DebitLineItemForm)
        existing_line_item_id: set[int] = set()
        if form.journal_entry_form.obj is not None:
            existing_line_item_id \
                = {x.id for x in form.journal_entry_form.obj.line_items}
        offset_total_func: sa.Function = sa.func.sum(sa.case(
            (JournalEntryLineItem.is_debit == is_debit,
             JournalEntryLineItem.amount),
            else_=-JournalEntryLineItem.amount))
        offset_total_but_form: Decimal | None = db.session.scalar(
            sa.select(offset_total_func)
            .filter(JournalEntryLineItem.original_line_item_id
                    == original_line_item.id,
                    JournalEntryLineItem.id.not_in(existing_line_item_id)))
        if offset_total_but_form is None:
            offset_total_but_form = Decimal("0")
        offset_total_on_form: Decimal = sum(
            [x.amount.data for x in form.journal_entry_form.line_items
             if x.original_line_item_id.data == original_line_item.id
             and x.amount != field and x.amount.data is not None])
        net_balance: Decimal = original_line_item.amount \
            - offset_total_but_form - offset_total_on_form
        if field.data > net_balance:
            raise ValidationError(lazy_gettext(
                "The amount must not exceed the net balance %(balance)s of the"
                " original line item.", balance=format_amount(net_balance)))


class NotLessThanOffsetTotal:
    """The validator to check if the amount is less than the offset total."""

    def __call__(self, form: FlaskForm, field: DecimalField) -> None:
        assert isinstance(form, LineItemForm)
        if field.data is None or form.id.data is None:
            return
        is_debit: bool = isinstance(form, DebitLineItemForm)
        select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
            (JournalEntryLineItem.is_debit != is_debit,
             JournalEntryLineItem.amount),
            else_=-JournalEntryLineItem.amount)))\
            .filter(JournalEntryLineItem.original_line_item_id == form.id.data)
        offset_total: Decimal | None = db.session.scalar(select_offset_total)
        if offset_total is not None and field.data < offset_total:
            raise ValidationError(lazy_gettext(
                "The amount must not be less than the offset total %(total)s.",
                total=format_amount(offset_total)))


class LineItemForm(FlaskForm):
    """The base form to create or edit a line item."""
    id = IntegerField()
    """The existing line item ID."""
    no = IntegerField()
    """The order in the currency."""
    original_line_item_id = IntegerField()
    """The Id of the original line item."""
    account_code = StringField()
    """The account code."""
    description = StringField()
    """The description."""
    amount = DecimalField()
    """The amount."""

    def __init__(self, *args, **kwargs):
        """Constructs a base line item form.

        :param args: The arguments.
        :param kwargs: The keyword arguments.
        """
        super().__init__(*args, **kwargs)
        from .journal_entry import JournalEntryForm
        self.journal_entry_form: JournalEntryForm | None = None
        """The source journal entry form."""

    @property
    def account_title(self) -> str:
        """Returns the title of the account.

        :return: The title of the account.
        """
        if self.account_code.data is None:
            return ""
        account: Account | None = Account.find_by_code(self.account_code.data)
        if account is None:
            return ""
        return account.title

    @property
    def account_text(self) -> str:
        """Returns the text representation of the account.

        :return: The text representation of the account.
        """
        if self.account_code.data is None:
            return ""
        account: Account | None = Account.find_by_code(self.account_code.data)
        if account is None:
            return ""
        return str(account)

    @property
    def __original_line_item(self) -> JournalEntryLineItem | None:
        """Returns the original line item.

        :return: The original line item.
        """
        if not hasattr(self, "____original_line_item"):
            def get_line_item() -> JournalEntryLineItem | None:
                if self.original_line_item_id.data is None:
                    return None
                return db.session.get(JournalEntryLineItem,
                                      self.original_line_item_id.data)
            setattr(self, "____original_line_item", get_line_item())
        return getattr(self, "____original_line_item")

    @property
    def original_line_item_date(self) -> dt.date | None:
        """Returns the text representation of the original line item.

        :return: The text representation of the original line item.
        """
        return None if self.__original_line_item is None \
            else self.__original_line_item.journal_entry.date

    @property
    def original_line_item_text(self) -> str | None:
        """Returns the text representation of the original line item.

        :return: The text representation of the original line item.
        """
        return None if self.__original_line_item is None \
            else str(self.__original_line_item)

    @property
    def is_need_offset(self) -> bool:
        """Returns whether the line item needs offset.

        :return: True if the line item needs offset, or False otherwise.
        """
        if self.account_code.data is None:
            return False
        if self.account_code.data[0] == "1":
            if isinstance(self, CreditLineItemForm):
                return False
        elif self.account_code.data[0] == "2":
            if isinstance(self, DebitLineItemForm):
                return False
        else:
            return False
        account: Account | None = Account.find_by_code(self.account_code.data)
        return account is not None and account.is_need_offset

    @property
    def offsets(self) -> list[JournalEntryLineItem]:
        """Returns the offsets.

        :return: The offsets.
        """
        if not hasattr(self, "__offsets"):
            def get_offsets() -> list[JournalEntryLineItem]:
                if not self.is_need_offset or self.id.data is None:
                    return []
                return JournalEntryLineItem.query.join(JournalEntry)\
                    .filter(JournalEntryLineItem.original_line_item_id
                            == self.id.data)\
                    .order_by(JournalEntry.date, JournalEntry.no,
                              JournalEntryLineItem.no)\
                    .options(selectinload(JournalEntryLineItem.journal_entry),
                             selectinload(JournalEntryLineItem.account)).all()
            setattr(self, "__offsets", get_offsets())
        return getattr(self, "__offsets")

    @property
    def offset_total(self) -> Decimal | None:
        """Returns the total amount of the offsets.

        :return: The total amount of the offsets.
        """
        if not hasattr(self, "__offset_total"):
            def get_offset_total():
                if not self.is_need_offset or self.id.data is None:
                    return None
                is_debit: bool = isinstance(self, DebitLineItemForm)
                return sum([x.amount if x.is_debit != is_debit else -x.amount
                            for x in self.offsets])
            setattr(self, "__offset_total", get_offset_total())
        return getattr(self, "__offset_total")

    @property
    def net_balance(self) -> Decimal | None:
        """Returns the net balance.

        :return: The net balance.
        """
        if not self.is_need_offset or self.id.data is None \
                or self.amount.data is None:
            return None
        return self.amount.data - self.offset_total

    @property
    def all_errors(self) -> list[str | LazyString]:
        """Returns all the errors of the form.

        :return: All the errors of the form.
        """
        all_errors: list[str | LazyString] = []
        for key in self.errors:
            if key != "csrf_token":
                all_errors.extend(self.errors[key])
        return all_errors


class DebitLineItemForm(LineItemForm):
    """The form to create or edit a debit line item."""
    id = IntegerField()
    """The existing line item ID."""
    no = IntegerField()
    """The order in the currency."""
    original_line_item_id = IntegerField(
        validators=[Optional(),
                    OriginalLineItemExists(),
                    OriginalLineItemOppositeDebitCredit(),
                    OriginalLineItemNeedOffset(),
                    OriginalLineItemNotOffset()])
    """The ID of the original line item."""
    account_code = StringField(
        filters=[strip_text],
        validators=[
            ACCOUNT_REQUIRED,
            AccountExists(),
            IsDebitAccount(lazy_gettext(
                "This account is not for debit line items.")),
            SameAccountAsOriginalLineItem(),
            KeepAccountWhenHavingOffset(),
            NotStartPayableFromDebit()])
    """The account code."""
    description = StringField(filters=[strip_text])
    """The description."""
    amount = DecimalField(
        validators=[PositiveAmount(),
                    NotExceedingOriginalLineItemNetBalance(),
                    NotLessThanOffsetTotal()])
    """The amount."""

    def populate_obj(self, obj: JournalEntryLineItem) -> None:
        """Populates the form data into a line item object.

        :param obj: The line item object.
        :return: None.
        """
        is_new: bool = obj.id is None
        if is_new:
            obj.id = new_id(JournalEntryLineItem)
        obj.original_line_item_id = self.original_line_item_id.data
        obj.account_id = Account.find_by_code(self.account_code.data).id
        obj.description = self.description.data
        obj.is_debit = True
        obj.amount = self.amount.data
        if is_new:
            current_user_pk: int = get_current_user_pk()
            obj.created_by_id = current_user_pk
            obj.updated_by_id = current_user_pk


class CreditLineItemForm(LineItemForm):
    """The form to create or edit a credit line item."""
    id = IntegerField()
    """The existing line item ID."""
    no = IntegerField()
    """The order in the currency."""
    original_line_item_id = IntegerField(
        validators=[Optional(),
                    OriginalLineItemExists(),
                    OriginalLineItemOppositeDebitCredit(),
                    OriginalLineItemNeedOffset(),
                    OriginalLineItemNotOffset()])
    """The ID of the original line item."""
    account_code = StringField(
        filters=[strip_text],
        validators=[
            ACCOUNT_REQUIRED,
            AccountExists(),
            IsCreditAccount(lazy_gettext(
                "This account is not for credit line items.")),
            SameAccountAsOriginalLineItem(),
            KeepAccountWhenHavingOffset(),
            NotStartReceivableFromCredit()])
    """The account code."""
    description = StringField(filters=[strip_text])
    """The description."""
    amount = DecimalField(
        validators=[PositiveAmount(),
                    NotExceedingOriginalLineItemNetBalance(),
                    NotLessThanOffsetTotal()])
    """The amount."""

    def populate_obj(self, obj: JournalEntryLineItem) -> None:
        """Populates the form data into a line item object.

        :param obj: The line item object.
        :return: None.
        """
        is_new: bool = obj.id is None
        if is_new:
            obj.id = new_id(JournalEntryLineItem)
        obj.original_line_item_id = self.original_line_item_id.data
        obj.account_id = Account.find_by_code(self.account_code.data).id
        obj.description = self.description.data
        obj.is_debit = False
        obj.amount = self.amount.data
        if is_new:
            current_user_pk: int = get_current_user_pk()
            obj.created_by_id = current_user_pk
            obj.updated_by_id = current_user_pk
