import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core";
import { Decimal } from "decimal.js";
import type { Transaction, Updateable } from "../generated/kysely-tailordb";
import {
  AccountCompanyMismatchError,
  AccountInactiveError,
  AccountNotFoundError,
  ApCurrencyNotFoundError,
  ApDocumentLineNotFoundError,
  ApDocumentNotFoundError,
  ApInvalidAmountError,
  ApInvalidDocumentStatusError,
  ApLineEditConflictError,
  ApLineTotalMismatchError,
  ApMinimumLinesNotMetError,
} from "../lib/errors.generated";
import type { CoaManagementQueries, PrimitivesQueries } from "../module";
import {
  type AccountPayableDistributionLineInput,
  type AccountPayableDocumentLineInput,
  type AccountPayableDocumentType,
  type AccountPayableDueScheduleLineInput,
  validateDueSchedule,
  validateLineSource,
} from "./createAccountPayableDocument";

export type AccountPayableDocumentHeaderPatch = {
  businessPartnerId?: string;
  currencyId?: string;
  payableControlAccountId?: string;
  documentType?: AccountPayableDocumentType;
  externalDocumentNumber?: string | null;
  documentDate?: Date;
  postingDate?: Date;
  totalAmount?: string;
  description?: string | null;
};

export type AccountPayableDocumentLinePatch = Partial<
  Omit<AccountPayableDocumentLineInput, "distributions">
>;
export type AccountPayableDistributionLinePatch = Partial<AccountPayableDistributionLineInput>;

export interface AccountPayableDocumentLineEdit {
  lineId: string;
  patch?: AccountPayableDocumentLinePatch;
  addDistributions?: AccountPayableDistributionLineInput[];
  updateDistributions?: AccountPayableDistributionLineEdit[];
  removeDistributionLineIds?: string[];
}

export interface AccountPayableDistributionLineEdit {
  distributionLineId: string;
  patch: AccountPayableDistributionLinePatch;
}

export interface UpdateAccountPayableDocumentInput {
  id: string;
  headerPatch?: AccountPayableDocumentHeaderPatch;
  addLines?: AccountPayableDocumentLineInput[];
  updateLines?: AccountPayableDocumentLineEdit[];
  removeLineIds?: string[];
  dueSchedule?: AccountPayableDueScheduleLineInput[];
}

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

function validateLineTotal(
  identifier: string,
  totalAmount: string,
  lines: { grossAmount: string }[],
) {
  if (lines.length === 0) return err(new ApMinimumLinesNotMetError(identifier));
  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 async function run<HCF extends object = object, LCF extends object = object>(
  db: Transaction,
  input: Omit<UpdateAccountPayableDocumentInput, "headerPatch" | "addLines" | "updateLines"> & {
    headerPatch?: AccountPayableDocumentHeaderPatch & Partial<HCF>;
    addLines?: (AccountPayableDocumentLineInput & LCF)[];
    updateLines?: (Omit<AccountPayableDocumentLineEdit, "patch"> & {
      patch?: AccountPayableDocumentLinePatch & Partial<LCF>;
    })[];
  },
  ctx: CommandContext,
  primitivesQueries: Pick<PrimitivesQueries, "getCurrency">,
  coaManagementQueries: Pick<CoaManagementQueries, "getAccount" | "listAccounts">,
) {
  const { id, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input;

  const document = await db
    .selectFrom("AccountPayableDocument")
    .selectAll()
    .where("id", "=", id)
    .forUpdate()
    .executeTakeFirst();
  if (!document) return err(new ApDocumentNotFoundError(id));
  if (document.status !== "DRAFT") return err(new ApInvalidDocumentStatusError(id));

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

  if (headerPatch?.payableControlAccountId !== undefined) {
    const account = (
      await coaManagementQueries.getAccount(db, { id: headerPatch.payableControlAccountId }, ctx)
    ).value.account;
    if (!account) {
      return err(new AccountNotFoundError(headerPatch.payableControlAccountId));
    }
    if (account.companyId !== document.companyId) {
      return err(new AccountCompanyMismatchError(headerPatch.payableControlAccountId));
    }
    if (account.status !== "ACTIVE") {
      return err(new AccountInactiveError(headerPatch.payableControlAccountId));
    }
  }

  const currentLines = await db
    .selectFrom("AccountPayableDocumentLine")
    .selectAll()
    .where("accountPayableDocumentId", "=", id)
    .execute();
  const currentLineById = new Map(currentLines.map((line) => [line.id, line]));

  for (const { lineId } of updateLines) {
    if (!currentLineById.has(lineId)) return err(new ApDocumentLineNotFoundError(lineId));
  }
  for (const lineId of removeLineIds) {
    if (!currentLineById.has(lineId)) return err(new ApDocumentLineNotFoundError(lineId));
  }

  const currentDistributions = await db
    .selectFrom("AccountPayableDistributionLine as distribution")
    .innerJoin(
      "AccountPayableDocumentLine as line",
      "line.id",
      "distribution.accountPayableDocumentLineId",
    )
    .selectAll("distribution")
    .where("line.accountPayableDocumentId", "=", id)
    .execute();
  const currentDistributionById = new Map(
    currentDistributions.map((distribution) => [distribution.id, distribution]),
  );

  const distributionUpdates: AccountPayableDistributionLineEdit[] = [];
  const distributionIdsToRemove: string[] = [];
  const distributionsToAddToExistingLines: (AccountPayableDistributionLineInput & {
    accountPayableDocumentLineId: string;
  })[] = [];

  for (const edit of updateLines) {
    for (const distributionUpdate of edit.updateDistributions ?? []) {
      const distribution = currentDistributionById.get(distributionUpdate.distributionLineId);
      if (!distribution || distribution.accountPayableDocumentLineId !== edit.lineId) {
        return err(new ApDocumentLineNotFoundError(distributionUpdate.distributionLineId));
      }
      distributionUpdates.push(distributionUpdate);
    }

    for (const distributionLineId of edit.removeDistributionLineIds ?? []) {
      const distribution = currentDistributionById.get(distributionLineId);
      if (!distribution || distribution.accountPayableDocumentLineId !== edit.lineId) {
        return err(new ApDocumentLineNotFoundError(distributionLineId));
      }
      distributionIdsToRemove.push(distributionLineId);
    }

    distributionsToAddToExistingLines.push(
      ...(edit.addDistributions ?? []).map((distribution) => ({
        ...distribution,
        accountPayableDocumentLineId: edit.lineId,
      })),
    );
  }

  const removeLineIdSet = new Set(removeLineIds);
  for (const { lineId } of updateLines) {
    if (removeLineIdSet.has(lineId)) return err(new ApLineEditConflictError(lineId));
  }

  const updateLineById = new Map(updateLines.map((edit) => [edit.lineId, edit.patch]));
  const effectiveLines = [
    ...currentLines
      .filter((line) => !removeLineIdSet.has(line.id))
      .map((line) => {
        const patch = updateLineById.get(line.id);
        return patch ? { ...line, ...patch } : line;
      }),
    ...addLines,
  ];

  const effectiveTotal = headerPatch?.totalAmount ?? document.totalAmount;
  const totalResult = validatePositiveAmount(id, effectiveTotal);
  if (!totalResult.ok) return totalResult;

  for (const [index, line] of effectiveLines.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(id, effectiveTotal, effectiveLines);
  if (!lineTotalResult.ok) return lineTotalResult;

  const removeDistributionLineIdSet = new Set(distributionIdsToRemove);
  const updateDistributionById = new Map(
    distributionUpdates.map((edit) => [edit.distributionLineId, edit.patch]),
  );
  const effectiveDistributions = currentDistributions
    .filter(
      (distribution) =>
        !removeLineIdSet.has(distribution.accountPayableDocumentLineId) &&
        !removeDistributionLineIdSet.has(distribution.id),
    )
    .map((distribution) => {
      const patch = updateDistributionById.get(distribution.id);
      return patch ? { ...distribution, ...patch } : distribution;
    });
  const addedDistributions = [
    ...addLines.flatMap((line) => line.distributions),
    ...distributionsToAddToExistingLines,
  ];

  for (const distribution of [...effectiveDistributions, ...addedDistributions]) {
    const amountResult = validatePositiveAmount(distribution.accountId, distribution.amount);
    if (!amountResult.ok) return amountResult;
  }

  const accountIds = [
    ...new Set(
      [...effectiveDistributions, ...addedDistributions].map(
        (distribution) => distribution.accountId,
      ),
    ),
  ];
  const accounts = (
    await coaManagementQueries.listAccounts(
      db,
      { companyId: document.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));
  }

  const distributionTotalResult = validateDistributionTotal(id, effectiveTotal, [
    ...effectiveDistributions,
    ...addedDistributions,
  ]);
  if (!distributionTotalResult.ok) return distributionTotalResult;

  const dueScheduleResult = validateDueSchedule(id, effectiveTotal, input.dueSchedule ?? [], {
    requireLines: false,
  });
  if (!dueScheduleResult.ok) return dueScheduleResult;

  const effectiveDistributionsByLineId = new Map<string, { amount: string }[]>();
  for (const distribution of [...effectiveDistributions, ...distributionsToAddToExistingLines]) {
    const distributions = effectiveDistributionsByLineId.get(
      distribution.accountPayableDocumentLineId,
    );
    if (distributions) distributions.push(distribution);
    else
      effectiveDistributionsByLineId.set(distribution.accountPayableDocumentLineId, [distribution]);
  }

  for (const [index, line] of effectiveLines.entries()) {
    const distributions =
      "id" in line ? (effectiveDistributionsByLineId.get(line.id) ?? []) : line.distributions;
    const lineDistributionTotalResult = validateDistributionTotal(
      String(index + 1),
      line.grossAmount,
      distributions,
    );
    if (!lineDistributionTotalResult.ok) return lineDistributionTotalResult;
  }

  const {
    businessPartnerId,
    currencyId,
    payableControlAccountId,
    documentType,
    externalDocumentNumber,
    documentDate,
    postingDate,
    totalAmount,
    description,
    ...headerCustomFields
  } = headerPatch ?? {};

  const updateData: Updateable<"AccountPayableDocument"> = {
    ...(headerCustomFields as Updateable<"AccountPayableDocument">),
  };
  if (businessPartnerId !== undefined) updateData.businessPartnerId = businessPartnerId;
  if (currencyId !== undefined) updateData.currencyId = currencyId;
  if (payableControlAccountId !== undefined) {
    updateData.payableControlAccountId = payableControlAccountId;
  }
  if (documentType !== undefined) updateData.documentType = documentType;
  if (externalDocumentNumber !== undefined) {
    updateData.externalDocumentNumber = externalDocumentNumber;
  }
  if (documentDate !== undefined) updateData.documentDate = documentDate;
  if (postingDate !== undefined) updateData.postingDate = postingDate;
  if (totalAmount !== undefined) updateData.totalAmount = totalAmount;
  if (description !== undefined) updateData.description = description;

  const updatedDocument =
    Object.keys(updateData).length === 0
      ? document
      : await db
          .updateTable("AccountPayableDocument")
          .set(updateData)
          .where("id", "=", id)
          .returningAll()
          .executeTakeFirstOrThrow();

  for (const { lineId, patch } of updateLines) {
    if (!patch || Object.keys(patch).length === 0) continue;
    await db
      .updateTable("AccountPayableDocumentLine")
      .set(patch as Updateable<"AccountPayableDocumentLine">)
      .where("id", "=", lineId)
      .execute();
  }

  if (removeLineIds.length > 0) {
    await db
      .deleteFrom("AccountPayableDistributionLine")
      .where("accountPayableDocumentLineId", "in", removeLineIds)
      .execute();
    await db.deleteFrom("AccountPayableDocumentLine").where("id", "in", removeLineIds).execute();
  }

  let insertedLines: typeof currentLines = [];
  if (addLines.length > 0) {
    insertedLines = await db
      .insertInto("AccountPayableDocumentLine")
      .values(
        addLines.map((line) => {
          const {
            netAmount,
            taxAmount,
            grossAmount,
            distributions: _distributions,
            description,
            sourceType,
            purchaseOrderLineId,
            quantity,
            unitPrice,
            unitId,
            ...lineCustomFields
          } = line;
          return {
            ...(lineCustomFields as Record<string, unknown>),
            accountPayableDocumentId: 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();
  }

  for (const { distributionLineId, patch } of distributionUpdates) {
    if (Object.keys(patch).length === 0) continue;
    const updateValues: Updateable<"AccountPayableDistributionLine"> = {
      ...(patch as Updateable<"AccountPayableDistributionLine">),
    };
    await db
      .updateTable("AccountPayableDistributionLine")
      .set(updateValues)
      .where("id", "=", distributionLineId)
      .execute();
  }

  if (distributionIdsToRemove.length > 0) {
    await db
      .deleteFrom("AccountPayableDistributionLine")
      .where("id", "in", distributionIdsToRemove)
      .execute();
  }

  const distributionsToInsert = [
    ...insertedLines.flatMap((line, index) => {
      const inputLine = addLines[index];
      if (!inputLine) return [];
      return inputLine.distributions.map((distribution) => ({
        accountPayableDocumentLineId: line.id,
        accountId: distribution.accountId,
        amount: distribution.amount,
        correctionOfDistributionLineId: null,
        description: distribution.description ?? null,
      }));
    }),
    ...distributionsToAddToExistingLines.map((distribution) => ({
      accountPayableDocumentLineId: distribution.accountPayableDocumentLineId,
      accountId: distribution.accountId,
      amount: distribution.amount,
      correctionOfDistributionLineId: null,
      description: distribution.description ?? null,
    })),
  ];
  if (distributionsToInsert.length > 0) {
    await db.insertInto("AccountPayableDistributionLine").values(distributionsToInsert).execute();
  }

  if (input.dueSchedule !== undefined) {
    await db
      .deleteFrom("AccountPayableDueScheduleLine")
      .where("accountPayableDocumentId", "=", id)
      .execute();

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

  return ok({ accountPayableDocument: updatedDocument });
}
