import { Fyo } from 'fyo';
import { DocValue, DocValueMap } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import {
  CurrenciesMap,
  DefaultMap,
  FiltersMap,
  FormulaMap,
  HiddenMap,
} from 'fyo/model/types';
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
import { ValidationError } from 'fyo/utils/errors';
import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { FieldTypeEnum, Schema } from 'schemas/types';
import { getIsNullOrUndef, joinMapLists, safeParseFloat } from 'utils';
import { Defaults } from '../Defaults/Defaults';
import { InvoiceItem } from '../InvoiceItem/InvoiceItem';
import { Item } from '../Item/Item';
import { Party } from '../Party/Party';
import { Payment } from '../Payment/Payment';
import { Tax } from '../Tax/Tax';
import { TaxSummary } from '../TaxSummary/TaxSummary';

export abstract class Invoice extends Transactional {
  _taxes: Record<string, Tax> = {};
  taxes?: TaxSummary[];

  items?: InvoiceItem[];
  party?: string;
  account?: string;
  currency?: string;
  netTotal?: Money;
  grandTotal?: Money;
  baseGrandTotal?: Money;
  outstandingAmount?: Money;
  exchangeRate?: number;
  setDiscountAmount?: boolean;
  discountAmount?: Money;
  discountPercent?: number;
  discountAfterTax?: boolean;
  stockNotTransferred?: number;

  submitted?: boolean;
  cancelled?: boolean;

  get isSales() {
    return this.schemaName === 'SalesInvoice';
  }

  get enableDiscounting() {
    return !!this.fyo.singles?.AccountingSettings?.enableDiscounting;
  }

  get isMultiCurrency() {
    if (!this.currency) {
      return false;
    }

    return this.fyo.singles.SystemSettings!.currency !== this.currency;
  }

  get companyCurrency() {
    return this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY;
  }

  get stockTransferSchemaName() {
    return this.isSales
      ? ModelNameEnum.Shipment
      : ModelNameEnum.PurchaseReceipt;
  }

  get hasLinkedTransfers() {
    if (!this.submitted) {
      return false;
    }

    return this.getStockTransferred() > 0;
  }

  get hasLinkedPayments() {
    if (!this.submitted) {
      return false;
    }

    return !this.baseGrandTotal?.eq(this.outstandingAmount!);
  }

  constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
    super(schema, data, fyo);
    this._setGetCurrencies();
  }

  async validate() {
    await super.validate();
    if (
      this.enableDiscounting &&
      !this.fyo.singles?.AccountingSettings?.discountAccount
    ) {
      throw new ValidationError(this.fyo.t`Discount Account is not set.`);
    }
  }

  async afterSubmit() {
    await super.afterSubmit();

    // update outstanding amounts
    await this.fyo.db.update(this.schemaName, {
      name: this.name as string,
      outstandingAmount: this.baseGrandTotal!,
    });

    const party = (await this.fyo.doc.getDoc('Party', this.party!)) as Party;
    await party.updateOutstandingAmount();
  }

  async afterCancel() {
    await super.afterCancel();
    await this._cancelPayments();
    await this._updatePartyOutStanding();
  }

  async _cancelPayments() {
    const paymentIds = await this.getPaymentIds();
    for (const paymentId of paymentIds) {
      const paymentDoc = (await this.fyo.doc.getDoc(
        'Payment',
        paymentId
      )) as Payment;
      await paymentDoc.cancel();
    }
  }

  async _updatePartyOutStanding() {
    const partyDoc = (await this.fyo.doc.getDoc(
      ModelNameEnum.Party,
      this.party!
    )) as Party;

    await partyDoc.updateOutstandingAmount();
  }

  async afterDelete() {
    await super.afterDelete();
    const paymentIds = await this.getPaymentIds();
    for (const name of paymentIds) {
      const paymentDoc = await this.fyo.doc.getDoc(ModelNameEnum.Payment, name);
      await paymentDoc.delete();
    }
  }

  async getPaymentIds() {
    const payments = (await this.fyo.db.getAll('PaymentFor', {
      fields: ['parent'],
      filters: { referenceType: this.schemaName, referenceName: this.name! },
      orderBy: 'name',
    })) as { parent: string }[];

    if (payments.length != 0) {
      return [...new Set(payments.map(({ parent }) => parent))];
    }

    return [];
  }

  async getExchangeRate() {
    if (!this.currency) {
      return 1.0;
    }

    const currency = await this.fyo.getValue(
      ModelNameEnum.SystemSettings,
      'currency'
    );
    if (this.currency === currency) {
      return 1.0;
    }
    const exchangeRate = await getExchangeRate({
      fromCurrency: this.currency!,
      toCurrency: currency as string,
    });

    return safeParseFloat(exchangeRate.toFixed(2));
  }

  async getTaxSummary() {
    const taxes: Record<
      string,
      {
        account: string;
        rate: number;
        amount: Money;
        [key: string]: DocValue;
      }
    > = {};

    type TaxDetail = { account: string; rate: number };

    for (const item of this.items ?? []) {
      if (!item.tax) {
        continue;
      }

      const tax = await this.getTax(item.tax!);
      for (const { account, rate } of (tax.details ?? []) as TaxDetail[]) {
        taxes[account] ??= {
          account,
          rate,
          amount: this.fyo.pesa(0),
        };

        let amount = item.amount!;
        if (this.enableDiscounting && !this.discountAfterTax) {
          amount = item.itemDiscountedTotal!;
        }

        const taxAmount = amount.mul(rate / 100);
        taxes[account].amount = taxes[account].amount.add(taxAmount);
      }
    }

    return Object.keys(taxes)
      .map((account) => {
        return taxes[account];
      })
      .filter((tax) => !tax.amount.isZero());
  }

  async getTax(tax: string) {
    if (!this._taxes![tax]) {
      this._taxes[tax] = await this.fyo.doc.getDoc('Tax', tax);
    }

    return this._taxes[tax];
  }

  getTotalDiscount() {
    if (!this.enableDiscounting) {
      return this.fyo.pesa(0);
    }

    const itemDiscountAmount = this.getItemDiscountAmount();
    const invoiceDiscountAmount = this.getInvoiceDiscountAmount();
    return itemDiscountAmount.add(invoiceDiscountAmount);
  }

  async getGrandTotal() {
    const totalDiscount = this.getTotalDiscount();
    return ((this.taxes ?? []) as Doc[])
      .map((doc) => doc.amount as Money)
      .reduce((a, b) => a.add(b), this.netTotal!)
      .sub(totalDiscount);
  }

  getInvoiceDiscountAmount() {
    if (!this.enableDiscounting) {
      return this.fyo.pesa(0);
    }

    if (this.setDiscountAmount) {
      return this.discountAmount ?? this.fyo.pesa(0);
    }

    let totalItemAmounts = this.fyo.pesa(0);
    for (const item of this.items ?? []) {
      if (this.discountAfterTax) {
        totalItemAmounts = totalItemAmounts.add(item.itemTaxedTotal!);
      } else {
        totalItemAmounts = totalItemAmounts.add(item.itemDiscountedTotal!);
      }
    }

    return totalItemAmounts.percent(this.discountPercent ?? 0);
  }

  getItemDiscountAmount() {
    if (!this.enableDiscounting) {
      return this.fyo.pesa(0);
    }

    if (!this?.items?.length) {
      return this.fyo.pesa(0);
    }

    let discountAmount = this.fyo.pesa(0);
    for (const item of this.items) {
      if (item.setItemDiscountAmount) {
        discountAmount = discountAmount.add(
          item.itemDiscountAmount ?? this.fyo.pesa(0)
        );
      } else if (!this.discountAfterTax) {
        discountAmount = discountAmount.add(
          (item.amount ?? this.fyo.pesa(0)).mul(
            (item.itemDiscountPercent ?? 0) / 100
          )
        );
      } else if (this.discountAfterTax) {
        discountAmount = discountAmount.add(
          (item.itemTaxedTotal ?? this.fyo.pesa(0)).mul(
            (item.itemDiscountPercent ?? 0) / 100
          )
        );
      }
    }

    return discountAmount;
  }

  formulas: FormulaMap = {
    account: {
      formula: async () => {
        return (await this.fyo.getValue(
          'Party',
          this.party!,
          'defaultAccount'
        )) as string;
      },
      dependsOn: ['party'],
    },
    currency: {
      formula: async () => {
        const currency = (await this.fyo.getValue(
          'Party',
          this.party!,
          'currency'
        )) as string;

        if (!getIsNullOrUndef(currency)) {
          return currency;
        }
        return this.fyo.singles.SystemSettings!.currency as string;
      },
      dependsOn: ['party'],
    },
    exchangeRate: {
      formula: async () => {
        if (
          this.currency ===
          (this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY)
        ) {
          return 1;
        }

        if (this.exchangeRate && this.exchangeRate !== 1) {
          return this.exchangeRate;
        }

        return await this.getExchangeRate();
      },
    },
    netTotal: { formula: async () => this.getSum('items', 'amount', false) },
    taxes: { formula: async () => await this.getTaxSummary() },
    grandTotal: { formula: async () => await this.getGrandTotal() },
    baseGrandTotal: {
      formula: async () =>
        (this.grandTotal as Money).mul(this.exchangeRate! ?? 1),
    },
    outstandingAmount: {
      formula: async () => {
        if (this.submitted) {
          return;
        }

        return this.baseGrandTotal!;
      },
    },
    stockNotTransferred: {
      formula: async () => {
        if (this.submitted) {
          return;
        }

        return this.getStockNotTransferred();
      },
      dependsOn: ['items'],
    },
  };

  getStockTransferred() {
    return (this.items ?? []).reduce(
      (acc, item) =>
        (item.quantity ?? 0) - (item.stockNotTransferred ?? 0) + acc,
      0
    );
  }

  getTotalQuantity() {
    return (this.items ?? []).reduce(
      (acc, item) => acc + (item.quantity ?? 0),
      0
    );
  }

  getStockNotTransferred() {
    return (this.items ?? []).reduce(
      (acc, item) => (item.stockNotTransferred ?? 0) + acc,
      0
    );
  }

  getItemDiscountedAmounts() {
    let itemDiscountedAmounts = this.fyo.pesa(0);
    for (const item of this.items ?? []) {
      itemDiscountedAmounts = itemDiscountedAmounts.add(
        item.itemDiscountedTotal ?? item.amount!
      );
    }
    return itemDiscountedAmounts;
  }

  hidden: HiddenMap = {
    setDiscountAmount: () => true || !this.enableDiscounting,
    discountAmount: () =>
      true || !(this.enableDiscounting && !!this.setDiscountAmount),
    discountPercent: () =>
      true || !(this.enableDiscounting && !this.setDiscountAmount),
    discountAfterTax: () => !this.enableDiscounting,
  };

  static defaults: DefaultMap = {
    numberSeries: (doc) => getNumberSeries(doc.schemaName, doc.fyo),
    terms: (doc) => {
      const defaults = doc.fyo.singles.Defaults as Defaults | undefined;
      if (doc.schemaName === ModelNameEnum.SalesInvoice) {
        return defaults?.salesInvoiceTerms ?? '';
      }

      return defaults?.purchaseInvoiceTerms ?? '';
    },
    date: () => new Date(),
  };

  static filters: FiltersMap = {
    party: (doc: Doc) => ({
      role: ['in', [doc.isSales ? 'Customer' : 'Supplier', 'Both']],
    }),
    account: (doc: Doc) => ({
      isGroup: false,
      accountType: doc.isSales ? 'Receivable' : 'Payable',
    }),
    numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }),
  };

  static createFilters: FiltersMap = {
    party: (doc: Doc) => ({
      role: doc.isSales ? 'Customer' : 'Supplier',
    }),
  };

  getCurrencies: CurrenciesMap = {
    baseGrandTotal: () => this.companyCurrency,
    outstandingAmount: () => this.companyCurrency,
  };
  _getCurrency() {
    if (this.exchangeRate === 1) {
      return this.companyCurrency;
    }

    return this.currency ?? DEFAULT_CURRENCY;
  }
  _setGetCurrencies() {
    const currencyFields = this.schema.fields.filter(
      ({ fieldtype }) => fieldtype === FieldTypeEnum.Currency
    );

    for (const { fieldname } of currencyFields) {
      this.getCurrencies[fieldname] ??= this._getCurrency.bind(this);
    }
  }

  getPayment(): Payment | null {
    if (!this.isSubmitted) {
      return null;
    }

    const outstandingAmount = this.outstandingAmount;
    if (!outstandingAmount) {
      return null;
    }

    if (this.outstandingAmount?.isZero()) {
      return null;
    }

    const accountField = this.isSales ? 'account' : 'paymentAccount';
    const data = {
      party: this.party,
      date: new Date().toISOString().slice(0, 10),
      paymentType: this.isSales ? 'Receive' : 'Pay',
      amount: this.outstandingAmount,
      [accountField]: this.account,
      for: [
        {
          referenceType: this.schemaName,
          referenceName: this.name,
          amount: this.outstandingAmount,
        },
      ],
    };

    return this.fyo.doc.getNewDoc(ModelNameEnum.Payment, data) as Payment;
  }

  async getStockTransfer(): Promise<StockTransfer | null> {
    if (!this.isSubmitted) {
      return null;
    }

    if (!this.stockNotTransferred) {
      return null;
    }

    const schemaName = this.stockTransferSchemaName;

    const defaults = (this.fyo.singles.Defaults as Defaults) ?? {};
    let terms;
    if (this.isSales) {
      terms = defaults.shipmentTerms ?? '';
    } else {
      terms = defaults.purchaseReceiptTerms ?? '';
    }

    const data = {
      party: this.party,
      date: new Date().toISOString(),
      terms,
      backReference: this.name,
    };

    const location =
      (this.fyo.singles.InventorySettings as InventorySettings)
        .defaultLocation ?? null;

    const transfer = this.fyo.doc.getNewDoc(schemaName, data) as StockTransfer;
    for (const row of this.items ?? []) {
      if (!row.item) {
        continue;
      }

      const itemDoc = (await row.loadAndGetLink('item')) as Item;
      const item = row.item;
      const quantity = row.stockNotTransferred;
      const trackItem = itemDoc.trackItem;
      let rate = row.rate as Money;

      if (this.exchangeRate && this.exchangeRate > 1) {
        rate = rate.mul(this.exchangeRate);
      }

      if (!quantity || !trackItem) {
        continue;
      }

      await transfer.append('items', {
        item,
        quantity,
        location,
        rate,
      });
    }

    if (!transfer.items?.length) {
      return null;
    }

    return transfer;
  }

  async beforeCancel(): Promise<void> {
    await super.beforeCancel();
    await this._validateStockTransferCancelled();
  }

  async beforeDelete(): Promise<void> {
    await super.beforeCancel();
    await this._validateStockTransferCancelled();
    await this._deleteCancelledStockTransfers();
  }

  async _deleteCancelledStockTransfers() {
    const schemaName = this.stockTransferSchemaName;
    const transfers = await this._getLinkedStockTransferNames(true);

    for (const { name } of transfers) {
      const st = await this.fyo.doc.getDoc(schemaName, name);
      await st.delete();
    }
  }

  async _validateStockTransferCancelled() {
    const schemaName = this.stockTransferSchemaName;
    const transfers = await this._getLinkedStockTransferNames(false);
    if (!transfers?.length) {
      return;
    }

    const names = transfers.map(({ name }) => name).join(', ');
    const label = this.fyo.schemaMap[schemaName]?.label ?? schemaName;
    throw new ValidationError(
      this.fyo.t`Cannot cancel ${this.schema.label} ${this
        .name!} because of the following ${label}: ${names}`
    );
  }

  async _getLinkedStockTransferNames(cancelled: boolean) {
    const name = this.name;
    if (!name) {
      throw new ValidationError(`Name not found for ${this.schema.label}`);
    }

    const schemaName = this.stockTransferSchemaName;
    const transfers = (await this.fyo.db.getAllRaw(schemaName, {
      fields: ['name'],
      filters: { backReference: this.name!, cancelled },
    })) as { name: string }[];
    return transfers;
  }

  async getLinkedPayments() {
    if (!this.hasLinkedPayments) {
      return [];
    }

    const paymentFors = (await this.fyo.db.getAllRaw('PaymentFor', {
      fields: ['parent', 'amount'],
      filters: { referenceName: this.name!, referenceType: this.schemaName },
    })) as { parent: string; amount: string }[];

    const payments = (await this.fyo.db.getAllRaw('Payment', {
      fields: ['name', 'date', 'submitted', 'cancelled'],
      filters: { name: ['in', paymentFors.map((p) => p.parent)] },
    })) as {
      name: string;
      date: string;
      submitted: number;
      cancelled: number;
    }[];

    return joinMapLists(payments, paymentFors, 'name', 'parent')
      .map((j) => ({
        name: j.name,
        date: new Date(j.date),
        submitted: !!j.submitted,
        cancelled: !!j.cancelled,
        amount: this.fyo.pesa(j.amount),
      }))
      .sort((a, b) => a.date.valueOf() - b.date.valueOf());
  }

  async getLinkedStockTransfers() {
    if (!this.hasLinkedTransfers) {
      return [];
    }

    const schemaName = this.stockTransferSchemaName;
    const transfers = (await this.fyo.db.getAllRaw(schemaName, {
      fields: ['name', 'date', 'submitted', 'cancelled'],
      filters: { backReference: this.name! },
    })) as {
      name: string;
      date: string;
      submitted: number;
      cancelled: number;
    }[];

    const itemSchemaName = schemaName + 'Item';
    const transferItems = (await this.fyo.db.getAllRaw(itemSchemaName, {
      fields: ['parent', 'quantity', 'location', 'amount'],
      filters: {
        parent: ['in', transfers.map((t) => t.name)],
        item: ['in', this.items!.map((i) => i.item!)],
      },
    })) as {
      parent: string;
      quantity: number;
      location: string;
      amount: string;
    }[];

    return joinMapLists(transfers, transferItems, 'name', 'parent')
      .map((j) => ({
        name: j.name,
        date: new Date(j.date),
        submitted: !!j.submitted,
        cancelled: !!j.cancelled,
        amount: this.fyo.pesa(j.amount),
        location: j.location,
        quantity: j.quantity,
      }))
      .sort((a, b) => a.date.valueOf() - b.date.valueOf());
  }

  async addItem(name: string) {
    return await addItem(name, this);
  }
}
