import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core";
import { Decimal } from "decimal.js";
import type { Transaction } from "../generated/kysely-tailordb";
import {
  AccountInactiveError,
  AccountNotFoundError,
  ApBusinessPartnerNotFoundError,
  ApCurrencyNotFoundError,
  ApDocumentLineNotFoundError,
  ApDueScheduleInvalidError,
  ApInvalidAmountError,
  ApLineAmountMismatchError,
  ApLineSourceInconsistentError,
  ApLineTotalMismatchError,
  ApMinimumLinesNotMetError,
  CompanyNotFoundError,
} from "../lib/errors.generated";
import type {
  BusinessPartnerQueries,
  CoaManagementQueries,
  OrganizationQueries,
  PrimitivesQueries,
} from "../module";

export type AccountPayableDocumentType = "INVOICE" | "CREDIT_MEMO";

export type AccountPayableLineSourceType = "PURCHASE_ORDER";

export interface AccountPayableDocumentLineInput {
  netAmount: string;
  taxAmount?: string | null;
  grossAmount: string;
  distributions: AccountPayableDistributionLineInput[];
  description?: string | null;
  sourceType?: AccountPayableLineSourceType | null;
  purchaseOrderLineId?: string | null;
  quantity?: string | null;
  unitPrice?: string | null;
  unitId?: string | null;
}

export interface AccountPayableDistributionLineInput {
  accountId: string;
  amount: string;
  description?: string | null;
}

export interface AccountPayableDueScheduleLineInput {
  dueDate: Date;
  amount: string;
}

export interface CreateAccountPayableDocumentHeaderInput {
  companyId: string;
  businessPartnerId: string;
  currencyId: string;
  payableControlAccountId: string;
  documentType: AccountPayableDocumentType;
  externalDocumentNumber?: string | null;
  documentDate: Date;
  postingDate: Date;
  totalAmount: string;
  description?: string | null;
}

export interface CreateAccountPayableDocumentInput {
  header: CreateAccountPayableDocumentHeaderInput;
  lines: AccountPayableDocumentLineInput[];
  dueSchedule?: AccountPayableDueScheduleLineInput[];
}

function validatePositiveAmount(identifier: string, amount: string) {
  if (new Decimal(amount).lte(0)) return err(new ApInvalidAmountError(identifier));
  return ok({});
}

export interface AccountPayableLineSourceShape {
  netAmount: string;
  sourceType?: string | null;
  purchaseOrderLineId?: string | null;
  quantity?: string | null;
  unitPrice?: string | null;
  unitId?: string | null;
}

export function validateLineSource(identifier: string, line: AccountPayableLineSourceShape) {
  const isPurchaseOrder = line.sourceType === "PURCHASE_ORDER";
  const hasPurchaseOrderLine = line.purchaseOrderLineId != null;
  const hasQuantity = line.quantity != null;
  const hasUnitPrice = line.unitPrice != null;
  const hasUnit = line.unitId != null;

  if (!isPurchaseOrder) {
    if (hasPurchaseOrderLine || hasQuantity || hasUnitPrice || hasUnit) {
      return err(new ApLineSourceInconsistentError(identifier));
    }
    return ok({});
  }

  if (!hasPurchaseOrderLine || !hasQuantity || !hasUnitPrice || !hasUnit) {
    return err(new ApLineSourceInconsistentError(identifier));
  }
  const quantity = new Decimal(line.quantity as string);
  const unitPrice = new Decimal(line.unitPrice as string);
  if (quantity.lte(0) || unitPrice.lte(0)) return err(new ApInvalidAmountError(identifier));
  if (!new Decimal(line.netAmount).eq(quantity.times(unitPrice))) {
    return err(new ApLineAmountMismatchError(identifier));
  }
  return ok({});
}

function validateLineTotal(
  identifier: string,
  totalAmount: string,
  lines: { grossAmount: string }[],
) {
  const lineTotal = lines.reduce((sum, line) => sum.plus(line.grossAmount), new Decimal(0));
  if (!lineTotal.eq(new Decimal(totalAmount))) {
    return err(new ApLineTotalMismatchError(identifier));
  }
  return ok({});
}

function validateDistributionTotal(
  identifier: string,
  totalAmount: string,
  distributions: { amount: string }[],
) {
  if (distributions.length === 0) return err(new ApMinimumLinesNotMetError(identifier));
  const distributionTotal = distributions.reduce(
    (sum, distribution) => sum.plus(distribution.amount),
    new Decimal(0),
  );
  if (!distributionTotal.eq(new Decimal(totalAmount))) {
    return err(new ApLineTotalMismatchError(identifier));
  }
  return ok({});
}

export function validateDueSchedule(
  identifier: string,
  totalAmount: string,
  dueSchedule: { amount: string }[],
  options: { requireLines: boolean },
) {
  if (dueSchedule.length === 0) {
    return options.requireLines ? err(new ApDueScheduleInvalidError(identifier)) : ok({});
  }

  const dueTotal = dueSchedule.reduce((sum, line) => {
    const amount = new Decimal(line.amount);
    if (amount.lte(0)) return sum.plus(NaN);
    return sum.plus(amount);
  }, new Decimal(0));

  if (!dueTotal.isFinite() || !dueTotal.eq(new Decimal(totalAmount))) {
    return err(new ApDueScheduleInvalidError(identifier));
  }
  return ok({});
}

export async function run<HCF extends object = object, LCF extends object = object>(
  db: Transaction,
  input: Omit<CreateAccountPayableDocumentInput, "header" | "lines"> & {
    header: CreateAccountPayableDocumentHeaderInput & HCF;
    lines: (AccountPayableDocumentLineInput & LCF)[];
  },
  ctx: CommandContext,
  organizationQueries: Pick<OrganizationQueries, "getCompany">,
  businessPartnerQueries: Pick<BusinessPartnerQueries, "getPartner">,
  primitivesQueries: Pick<PrimitivesQueries, "getCurrency">,
  coaManagementQueries: Pick<CoaManagementQueries, "listAccounts">,
) {
  const {
    companyId,
    businessPartnerId,
    currencyId,
    payableControlAccountId,
    documentType,
    externalDocumentNumber,
    documentDate,
    postingDate,
    totalAmount,
    description,
    ...headerCustomFields
  } = input.header;

  const { company } = (await organizationQueries.getCompany(db, { id: companyId }, ctx)).value;
  if (!company) return err(new CompanyNotFoundError(companyId));

  const { partner } = (await businessPartnerQueries.getPartner(db, { id: businessPartnerId }, ctx))
    .value;
  if (!partner) return err(new ApBusinessPartnerNotFoundError(businessPartnerId));

  const { currency } = (await primitivesQueries.getCurrency(db, { id: currencyId }, ctx)).value;
  if (!currency) return err(new ApCurrencyNotFoundError(currencyId));

  if (input.lines.length === 0) return err(new ApMinimumLinesNotMetError("AP document"));

  const totalResult = validatePositiveAmount("AP document", totalAmount);
  if (!totalResult.ok) return totalResult;

  for (const [index, line] of input.lines.entries()) {
    const identifier = String(index + 1);
    const netAmountResult = validatePositiveAmount(identifier, line.netAmount);
    if (!netAmountResult.ok) return netAmountResult;

    const grossAmountResult = validatePositiveAmount(identifier, line.grossAmount);
    if (!grossAmountResult.ok) return grossAmountResult;

    const lineSourceResult = validateLineSource(identifier, line);
    if (!lineSourceResult.ok) return lineSourceResult;
  }

  const lineTotalResult = validateLineTotal("AP document", totalAmount, input.lines);
  if (!lineTotalResult.ok) return lineTotalResult;

  const dueScheduleResult = validateDueSchedule(
    "AP document",
    totalAmount,
    input.dueSchedule ?? [],
    { requireLines: false },
  );
  if (!dueScheduleResult.ok) return dueScheduleResult;

  const accountIds = [
    ...new Set([
      payableControlAccountId,
      ...input.lines.flatMap((line) =>
        line.distributions.map((distribution) => distribution.accountId),
      ),
    ]),
  ];
  const accounts = (
    await coaManagementQueries.listAccounts(
      db,
      { companyId, accountIds, limit: accountIds.length },
      ctx,
    )
  ).value.items;
  const accountById = new Map(accounts.map((account) => [account.id, account]));
  for (const accountId of accountIds) {
    const account = accountById.get(accountId);
    if (!account) return err(new AccountNotFoundError(accountId));
    if (account.status !== "ACTIVE") return err(new AccountInactiveError(accountId));
  }

  for (const [index, line] of input.lines.entries()) {
    const distributionTotalResult = validateDistributionTotal(
      String(index + 1),
      line.grossAmount,
      line.distributions,
    );
    if (!distributionTotalResult.ok) return distributionTotalResult;

    for (const distribution of line.distributions) {
      const amountResult = validatePositiveAmount(distribution.accountId, distribution.amount);
      if (!amountResult.ok) return amountResult;
    }
  }

  const document = await db
    .insertInto("AccountPayableDocument")
    .values({
      ...(headerCustomFields as Record<string, unknown>),
      companyId,
      businessPartnerId,
      currencyId,
      payableControlAccountId,
      documentType,
      correctionOfId: null,
      externalDocumentNumber: externalDocumentNumber ?? null,
      documentDate,
      postingDate,
      totalAmount,
      description: description ?? null,
      status: "DRAFT",
      registeredAt: null,
      postedAt: null,
      cancelledAt: null,
    })
    .returningAll()
    .executeTakeFirstOrThrow();

  const lines = await db
    .insertInto("AccountPayableDocumentLine")
    .values(
      input.lines.map((line) => {
        const {
          netAmount,
          taxAmount,
          grossAmount,
          distributions: _distributions,
          description,
          sourceType,
          purchaseOrderLineId,
          quantity,
          unitPrice,
          unitId,
          ...lineCustomFields
        } = line;
        return {
          ...(lineCustomFields as Record<string, unknown>),
          accountPayableDocumentId: document.id,
          netAmount,
          taxAmount: taxAmount ?? null,
          grossAmount,
          correctionOfLineId: null,
          correctionType: null,
          description: description ?? null,
          sourceType: sourceType ?? null,
          purchaseOrderLineId: purchaseOrderLineId ?? null,
          quantity: quantity ?? null,
          unitPrice: unitPrice ?? null,
          unitId: unitId ?? null,
        };
      }),
    )
    .returningAll()
    .execute();

  const distributions = [];
  for (const [index, line] of input.lines.entries()) {
    const insertedLine = lines[index];
    if (!insertedLine) return err(new ApDocumentLineNotFoundError(String(index + 1)));
    distributions.push(
      ...line.distributions.map((distribution) => ({
        accountPayableDocumentLineId: insertedLine.id,
        accountId: distribution.accountId,
        amount: distribution.amount,
        correctionOfDistributionLineId: null,
        description: distribution.description ?? null,
      })),
    );
  }

  await db.insertInto("AccountPayableDistributionLine").values(distributions).execute();

  if (input.dueSchedule && input.dueSchedule.length > 0) {
    await db
      .insertInto("AccountPayableDueScheduleLine")
      .values(
        input.dueSchedule.map((line) => ({
          accountPayableDocumentId: document.id,
          dueDate: line.dueDate,
          amount: line.amount,
        })),
      )
      .execute();
  }

  return ok({ accountPayableDocument: document });
}
