import logging
from copy import deepcopy
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import List, Mapping, Optional, Sequence

from common import utc_now
from international_payroll.modules.remittances.internal import (
    data_store,
    payroll_interface,
    stateless,
)
from international_payroll.modules.remittances.internal.beans import (
    AggregateLiabilityCalculationBean,
    CompanyLiabilityTypeBean,
    CompanyLiabilityTypeSet,
    CompanyLiabilityVersionBean,
    CompanyLiabilityVersionKey,
    ComputedLiabilityCalculationsBean,
    LiabilityCalculationAmount,
    RemittanceBatchAttemptBean,
    RemittanceBatchPaymentReciept,
    RemittanceDebitConfig,
    RemittanceFFRequestBatch,
    RemittanceFFRequestBatchStatus,
    RemittanceIntentBean,
    RemittanceIntentFFRequestBean,
    RemittanceIntentStatusBean,
    RemittanceLiabilitiyDebitMismatch,
    RemittanceOrderBean,
    RemittancePaymentActivity,
    RemittancePaymentAttemptBean,
    RemittancePaymentReceipt,
    RemittancePaymentReceiptKey,
    RemittancePayrollObjectSearchKey,
    RemittancePayrollObjectSnapshot,
    RemittanceVersionBean,
    RemittanceVersionKey,
    RoleLiabilityCalculationBean,
)
from international_payroll.modules.remittances.internal.constants import (
    BLOCKED_COMPANY_ID_AND_PAYROLL_OBJECT_ID_PAIRS,
    ID_PENDING_SAVE,
)
from international_payroll.modules.remittances.internal.countries.factory import (
    canPersistLiabilityTypeSetForCountryCode,
    canPersistLiabilityVersionsForCountryCode,
    canPersistRemittanceOrdersForCountryCode,
    getAllCountryInterfaces,
    hasActiveExternalFulfillmentForCountryCode,
    isFulfillRemittancesEnabledForCountryCode,
)
from international_payroll.modules.remittances.internal.enums import (
    DoNotRemitReasons,
    IntentStatus,
    LiabilityPayrollObjectTypes,
    MoneyLocation,
    RemittancePaymentActivityStatus,
)
from international_payroll.modules.remittances.internal.exceptions import (
    LiabilitySetNotFoundException,
    LiabilityVersionNotFoundException,
)
from international_payroll.modules.remittances.internal.intent_status_utils import (
    IntentStatusUtil,
)
from international_payroll.modules.remittances.internal.liability_country_interface import (
    LiabilitySetParams,
    getDefaultLiabilityTypeBeans,
)
from international_payroll.modules.remittances.internal.payroll_interface import (
    PayrollObjectInfo,
    getPayrollObjectInfo,
)
from international_payroll_beans.beans import (
    BoolWithReason,
    CompanyPayrollEntity,
    ReconProcessId,
)
from international_payroll_sdk.contract import getCompanyPayrollEntity
from international_tax_engine.contract import getToday
from mongoengine import NotUniqueError

logger = logging.getLogger(__name__)

"""
This should be allowed to import
   a. data_store
   b. modules/<other-module>/external/sdk

   Under the contract framework define a contract function using the read -> compute -> write pattern
   the framework will provide a way to invoke the contract function.

def contractFunctionRunner(klass: ContractFunction[T], args: T):
    data1 = klass.read(arg)
    data2 = klass.compute(data1)
    data3 = klass.write(data2)
    return data3

class ContractFunction(Generic[T, U, V]):

    def read(self) -> T:
        x = data_store.xyz()
        y = data_store.cvc()
        return

    def compute(self, T) -> U:
        a = stateless.fn1(T.fn1data)
        b = stateless.fn2(T.fn2data)
        return stateless.fn3(a, b)

    def write(self, U) -> V:
        data_store.writeThis(U.thisData)
        gp_other_module.sdk.updateSomething(U.otherModuleData)
        pass


"""


def getOrNonePaymentReceiptForAttempt(
    latestAttempt: RemittancePaymentAttemptBean,
) -> Optional[RemittancePaymentReceipt]:
    if not latestAttempt.id:
        raise Exception("Missing attempt.id")
    return data_store.RemittancePaymentReceiptDataStore.getOrNone(
        key=RemittancePaymentReceiptKey(
            companyId=latestAttempt.companyId,
            paymentAttemptId=latestAttempt.id,
        )
    )


def createBatchPaymentReciept(
    batchAttempt: RemittanceBatchAttemptBean, paymentOrderId: str
) -> RemittanceBatchPaymentReciept:
    return data_store.createBatchPaymentReciept(batchAttempt, paymentOrderId)


def createPaymentReceipt(attempt: RemittancePaymentAttemptBean, paymentOrderId: str) -> RemittancePaymentReceipt:
    if not attempt.id:
        raise Exception("Missing attempt.id")
    bean = RemittancePaymentReceipt(
        id=ID_PENDING_SAVE,
        companyId=attempt.companyId,
        paymentOrderId=paymentOrderId,
        paymentAttemptId=attempt.id,
    )
    return data_store.RemittancePaymentReceiptDataStore.create(bean)


def getOrNoneLatestPaymentAttemptForIntent(intent: RemittanceIntentBean) -> Optional[RemittancePaymentAttemptBean]:
    return data_store.getLatestPaymentAttemptForIntent(intent)


def createPaymentAttempt(attempt: RemittancePaymentAttemptBean) -> RemittancePaymentAttemptBean:
    return data_store.createPaymentAttempt(attempt)


def getOrNonePaymentAttempt(paymentAttemptId: str) -> Optional[RemittancePaymentAttemptBean]:
    return data_store.getOrNonePaymentAttempt(paymentAttemptId)


def canCancelRemittanceVersion(remittanceIntents: Sequence[RemittanceIntentBean]) -> BoolWithReason:
    for intent in remittanceIntents:
        statusBean = data_store.getLatestIntentStatus(intent)

        currStatus = statusBean.status
        if not IntentStatusUtil.isTransitionValid(currStatus, IntentStatus.CANCELLED):
            return BoolWithReason(False, "Invalid state transition")

    return BoolWithReason(True, "")


def getOrNoneLatestFFRequest(intent: RemittanceIntentBean) -> Optional[RemittanceIntentFFRequestBean]:
    return data_store.getLatestFFRequestForIntent(intent)


def getLatestBatchStatus(batch: RemittanceFFRequestBatch) -> RemittanceFFRequestBatchStatus:
    return data_store.getLatestBatchStatus(batch)


def updateBatchStatus(batch: RemittanceFFRequestBatch, newStatus: IntentStatus) -> BoolWithReason:
    oldStatusBean = data_store.getLatestBatchStatus(batch)
    oldStatus = oldStatusBean.status
    counter = oldStatusBean.counter + 1

    if oldStatus == newStatus:
        return BoolWithReason(False, f"Batch status is already {newStatus}")

    if not IntentStatusUtil.isTransitionValid(oldStatus, newStatus):
        raise Exception(f"Invalid state transition from {oldStatus} to {newStatus}")

    data_store.writeBatchStatus(batch, newStatus, counter)

    requests = getFFRequestsForBatchId(batch.id)
    intents = [request.intent for request in requests]
    for intent in intents:
        if not (didUpdateIntent := updateIntentStatus(intent, newStatus)):
            return BoolWithReason(False, f"Failed to update intent {intent.id} to {newStatus} {didUpdateIntent.reason}")

    return BoolWithReason(True, "")


def forceCompleteEmptyBatch(batch: RemittanceFFRequestBatch, newStatus: IntentStatus) -> BoolWithReason:
    if getFFRequestsForBatchId(batch.id):
        raise Exception(f"Batch {batch.id} is not empty of intents")

    oldStatusBean = data_store.getLatestBatchStatus(batch)
    oldStatus = oldStatusBean.status
    counter = oldStatusBean.counter + 1

    if oldStatus == newStatus:
        return BoolWithReason(False, f"Batch status is already {newStatus}")

    data_store.writeBatchStatus(batch, newStatus, counter)
    return BoolWithReason(True, "")


def updateIntentStatus(intent: RemittanceIntentBean, newStatus: IntentStatus, reason: str = "") -> BoolWithReason:
    oldStatusBean = data_store.getLatestIntentStatus(intent)
    oldStatus = oldStatusBean.status
    counter = oldStatusBean.counter + 1

    if oldStatus == newStatus:
        return BoolWithReason(False, f"Intent status is already {newStatus}")

    if not IntentStatusUtil.isTransitionValid(oldStatus, newStatus):
        raise Exception(f"Invalid state transition from {oldStatus} to {newStatus}")

    try:
        data_store.writeIntentStatus(intent, newStatus, counter, reason)
    except NotUniqueError:
        raise Exception(f"Version {counter} is too low for state transition")

    return BoolWithReason(True, "")


def canUpdateBatchStatus(batch: RemittanceFFRequestBatch, newStatus: IntentStatus) -> BoolWithReason:
    statusBean = data_store.getLatestBatchStatus(batch)
    oldStatus = statusBean.status

    if not IntentStatusUtil.isTransitionValid(oldStatus, newStatus):
        return BoolWithReason(False, f"Invalid state transition from {oldStatus} to {newStatus}")

    return BoolWithReason(True, "")


def canUpdateIntentStatus(intent: RemittanceIntentBean, newStatus: IntentStatus) -> BoolWithReason:
    statusBean = data_store.getLatestIntentStatus(intent)
    oldStatus = statusBean.status

    if not IntentStatusUtil.isTransitionValid(oldStatus, newStatus):
        return BoolWithReason(False, f"Invalid state transition from {oldStatus} to {newStatus}")

    return BoolWithReason(True, "")


def canCreateRemittanceIntent(
    remittanceVersion: RemittanceVersionBean, remittanceOrder: RemittanceOrderBean
) -> BoolWithReason:
    if remittanceVersion.doNotRemit:
        return BoolWithReason(False, "Do not remit flag is True. Skipping")
    if not remittanceOrder.id:
        raise Exception("Remittance order does not have an id")
    if hasRemittanceIntentsForOrderId(remittanceOrder.id):
        return BoolWithReason(False, "Remittance intents already created")
    return BoolWithReason(True)


def hasRemittanceIntentsForOrderId(remittanceOrderId: str) -> bool:
    return data_store.hasRemittanceIntentsForOrderId(remittanceOrderId)


def getRemittanceIntentsForRemittanceVersion(
    remittanceVersion: RemittanceVersionBean,
) -> Sequence[RemittanceIntentBean]:
    orders = data_store.getRemittanceOrdersForRemittanceVersion(remittanceVersion)
    intents: list[RemittanceIntentBean] = []
    for order in orders:
        intents.extend(data_store.getRemittanceIntentsForRemittanceOrder(order))
    return intents


def createRemittanceIntentsForRemittanceOrder(
    remittanceVersion: RemittanceVersionBean, remittanceOrder: RemittanceOrderBean
) -> Sequence[RemittanceIntentBean]:
    intents = []
    can, reason = canCreateRemittanceIntent(remittanceVersion, remittanceOrder)
    if not can:
        return []

    intentBeans = stateless.computeRemittanceIntents(remittanceOrder)

    prevIntent = None
    for intentBean in intentBeans:
        deps: list[RemittanceIntentBean] = []
        if prevIntent is not None:
            deps = [prevIntent]

        intent = data_store.createRemittanceIntent(intentBean, deps)
        data_store.writeIntentStatus(intent=intent, newStatus=IntentStatusUtil.getJustCreatedStatus(), counter=0)
        intents.append(intent)
        prevIntent = intent

    return intents


def getAllIntentIds() -> Sequence[str]:
    return data_store.getAllIntentIds()


def getLatestIntentStatus(intent: RemittanceIntentBean) -> RemittanceIntentStatusBean:
    return data_store.getLatestIntentStatus(intent)


def getLatestIntentStatusesForIntentIds(
    companyId: str, intentIds: Sequence[str]
) -> Sequence[RemittanceIntentStatusBean]:
    return data_store.getLatestIntentStatusesForIntentIds(companyId, intentIds)


def getOrNoneRemittanceIntentForId(intentId: str) -> Optional[RemittanceIntentBean]:
    return data_store.getOrNoneRemittanceIntentForId(intentId)


def getFFRequestsForBatchId(batchId: str) -> Sequence[RemittanceIntentFFRequestBean]:
    return data_store.getFFRequestsForBatch(batchId)


def getRemittanceIntentsForIds(intentIds: Sequence[str]) -> Sequence[RemittanceIntentBean]:
    return data_store.getRemittanceIntentsForIds(intentIds)


def getRemittanceIntentsForRemittanceOrder(remittanceOrder: RemittanceOrderBean) -> Sequence[RemittanceIntentBean]:
    return data_store.getRemittanceIntentsForRemittanceOrder(remittanceOrder)


def getRemittanceOrdersForRemittanceVersion(remittanceVersion: RemittanceVersionBean) -> Sequence[RemittanceOrderBean]:
    return data_store.getRemittanceOrdersForRemittanceVersion(remittanceVersion)


def computeRoleLiabilityCalculationsForPayRun(
    companyId: str, runId: str, liabilitySet: CompanyLiabilityTypeSet, useSnapshottedPREs: bool = True
) -> List[RoleLiabilityCalculationBean]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, runId, LiabilityPayrollObjectTypes.PAYRUN)
    return computeRoleLiabilityCalculationsForPayrollObject(payrollObject, liabilitySet, useSnapshottedPREs)


def computeRoleLiabilityCalculationsForReconProcess(
    companyId: str, processId: ReconProcessId, liabilitySet: CompanyLiabilityTypeSet, useSnapshottedPREs: bool = True
) -> List[RoleLiabilityCalculationBean]:
    payrollObject = payroll_interface.getPayrollObjectInfo(
        companyId, processId, LiabilityPayrollObjectTypes.RECON_PROCESS
    )
    return computeRoleLiabilityCalculationsForPayrollObject(payrollObject, liabilitySet, useSnapshottedPREs)


def computeRoleLiabilityCalculationsForPayrollObject(
    payrollObject: PayrollObjectInfo, liabilitySet: CompanyLiabilityTypeSet, useSnapshotted: bool = True
) -> List[RoleLiabilityCalculationBean]:
    liabilityCalculations: List[RoleLiabilityCalculationBean] = []

    for payrollEntryObject in payrollObject.getPayrollEntryObjects(useSnapshotted):
        liabilityAmounts = stateless.computeRoleLiabilityCalculationsForPayrollEntryObject(
            payrollObject, payrollEntryObject, liabilitySet
        )
        liabilityCalculations.extend(liabilityAmounts)
    return liabilityCalculations


def getAllRemittanceVersionIdsByDueDate(
    startDateTime: datetime, endDateTime: Optional[datetime] = None
) -> Sequence[str]:
    return data_store.getAllRemittanceVersionIdsByDueDate(startDateTime, endDateTime)


def getAllLiabilityCodesForRemittanceOrderCreation(liabilityVersion: CompanyLiabilityVersionBean) -> Sequence[str]:
    """we want to create a remittance version for:
        1. every liability code in the liability version
        2. every liability code in the previous remittance version

    This is important because we need to "overwrite" any old remittance versions if a liability code is removed
    from the liability set.
    """
    liabilityCodes = set()
    for liability in liabilityVersion.liabilitySet.companyLiabilities:
        if not liability.doNotRemit:
            liabilityCodes.add(liability.liabilityCode)

    existingRemittanceVersions = data_store.getLatestRemittanceVersions(
        liabilityVersion.companyId, liabilityVersion.payrollObjectId, liabilityVersion.payrollObjectType
    )
    for remittanceVersion in existingRemittanceVersions:
        liabilityCodes.add(remittanceVersion.liabilityCode)

    return list(liabilityCodes)


def createCompanyLiabilityVersionForPayrollObject(
    payrollObject: PayrollObjectInfo,
    reasonCode: str,
) -> CompanyLiabilityVersionBean:
    liabilitySet = payrollObject.getOrNoneLiabilitySet()
    if (not liabilitySet) or (not liabilitySet.id):
        raise ValueError(
            f"liabilitySet must have an id for {payrollObject.getCompanyId()} {payrollObject.getPayrollObjectId()}"
        )

    # todo: get rid of version and id here. maybe via a "creation bean"
    bean = CompanyLiabilityVersionBean(
        id=ID_PENDING_SAVE,
        companyId=payrollObject.getCompanyId(),
        companyPayrollEntityId=payrollObject.getCompanyPayrollEntityId(),
        payrollObjectId=payrollObject.getPayrollObjectId(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        countryCode=payrollObject.getCountryCode(),
        reasonCode=reasonCode,
        checkDate=payrollObject.getCheckDate(),
        liabilitySet=liabilitySet,
    )
    return data_store.CompanyLiabilityVersionDataStore.create(bean)


def createBatchAttempt(batchAttempt: RemittanceBatchAttemptBean) -> RemittanceBatchAttemptBean:
    return data_store.createBatchAttempt(batchAttempt)


def createLiabilityCalculations(
    liabilityVersion: CompanyLiabilityVersionBean, liabilityCalculations: ComputedLiabilityCalculationsBean
) -> CompanyLiabilityVersionBean:
    # TODO: handle what happens if one or all of these fail?
    for aggregateLiabilityCalculation in liabilityCalculations.aggregateLiabilityCalculations:
        data_store.createAggregateLiabilityCalculation(liabilityVersion, aggregateLiabilityCalculation)
    for liabilityCalculation in liabilityCalculations.roleLiabilityCalculations:
        data_store.createRoleLiabilityCalculation(liabilityVersion, liabilityCalculation)
    return liabilityVersion


def canCreateCompanyLiabilityVersion(
    countryCode: str,
    companyId: str,
    objectId: str,
    objectType: LiabilityPayrollObjectTypes,
    shouldOverwriteExisting: bool = False,
    shouldOverwriteInProgress: bool = False,
) -> BoolWithReason:
    if not canPersistLiabilityVersionsForCountryCode(countryCode):
        return BoolWithReason(False, f"Liability version creation is disabled for country {countryCode}")

    if (companyId, objectId) in BLOCKED_COMPANY_ID_AND_PAYROLL_OBJECT_ID_PAIRS:
        return BoolWithReason(
            False, "Company and payroll object id pair is in BLOCKED_COMPANY_ID_AND_PAYROLL_OBJECT_ID_PAIRS"
        )

    if not shouldOverwriteExisting:
        key = CompanyLiabilityVersionKey(companyId=companyId, payrollObjectId=objectId, payrollObjectType=objectType)
        if data_store.CompanyLiabilityVersionDataStore.getOrNoneLatestVersion(key):
            return BoolWithReason(False, "CompanyLiabilityVersion already exists")

    if not shouldOverwriteInProgress:
        intentStatuses = data_store.getRemittanceIntentStatusesForPayrollObject(companyId, objectId, objectType)
        if any(IntentStatusUtil.isInProgress(status.status) for status in intentStatuses):
            return BoolWithReason(False, "One or more intents in progress")
        if any(IntentStatusUtil.isCompleted(status.status) for status in intentStatuses):
            return BoolWithReason(False, "One or more intents is already completed")

    return BoolWithReason(True, "")


def canCreateRemittanceOrders(
    countryCode: str,
    companyId: str,
    objectId: str,
    objectType: LiabilityPayrollObjectTypes,
    shouldOverwrite: bool = False,
) -> BoolWithReason:
    if not canPersistRemittanceOrdersForCountryCode(countryCode):
        return BoolWithReason(False, f"Order creation is disabled for country {countryCode}")

    existingRemittanceVersions = data_store.getLatestRemittanceVersions(companyId, objectId, objectType)
    if existingRemittanceVersions and not shouldOverwrite:
        return BoolWithReason(False, "RemittanceVersions already exists")

    intentStatuses = data_store.getRemittanceIntentStatusesForPayrollObject(companyId, objectId, objectType)
    if any(IntentStatusUtil.isInProgress(status.status) for status in intentStatuses):
        return BoolWithReason(False, "One or more intents in progress")
    if any(IntentStatusUtil.isCompleted(status.status) for status in intentStatuses):
        return BoolWithReason(False, "One or more intents is already completed")

    return BoolWithReason(True, "")


def canCreateRemittanceVersion(countryCode: str, companyId: str, liabilityCode: str) -> BoolWithReason:
    if not canPersistRemittanceOrdersForCountryCode(countryCode, liabilityCode):
        return BoolWithReason(False, f"Order creation is disabled for {countryCode=} {liabilityCode=}")

    return BoolWithReason(True, "")


def initializeDefaultLiabilityForCompany(
    companyId: str,
    companyPayrollEntityId: str,
    countryCode: str,
    effectiveDateTime: datetime,
    canOverrideFeatureFlag: bool = False,
) -> Optional[CompanyLiabilityTypeSet]:
    # we shouldn't block execution of international payroll if this function fails.
    try:
        if not canPersistLiabilityTypeSetForCountryCode(countryCode) and not canOverrideFeatureFlag:
            # Do not start persisting default liabilities for a company until this feature is enabled.
            return None

        # READ
        cpe = getCompanyPayrollEntity(companyPayrollEntityId)
        liabilitySetParams = LiabilitySetParams(isEOR=cpe.isEOR)
        newLiabilityTypes = getDefaultLiabilityTypeBeans(countryCode, liabilitySetParams)
        if not newLiabilityTypes:
            return None

        # COMPUTE
        existingLiabilityTypeSet = data_store.getLiabilitySetOn(companyId, companyPayrollEntityId, effectiveDateTime)
        if existingLiabilityTypeSet is not None:
            newLiabilityTypes = stateless.mergeDefaultLiabilitiesWithExistingLiabilities(
                newLiabilityTypes, existingLiabilityTypeSet.companyLiabilities
            )

        # WRITE
        return data_store.createCompanyLiabilityTypeSet(
            companyId, companyPayrollEntityId, countryCode, effectiveDateTime, newLiabilityTypes
        )
    except:
        logger.exception(
            "Failed to initialize default liability types for company %s, companyPayrollEntityId %s, countryCode %s",
            companyId,
            companyPayrollEntityId,
            countryCode,
        )
        return None


def getOrNoneLatestCompanyLiabilityVersion(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes
) -> Optional[CompanyLiabilityVersionBean]:
    key = CompanyLiabilityVersionKey(companyId, payrollObjectId, payrollObjectType)
    return data_store.CompanyLiabilityVersionDataStore.getOrNoneLatestVersion(key)


def getRoleLiabilityCalculationsForCompanyLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
) -> Sequence[RoleLiabilityCalculationBean]:
    return data_store.getRoleLiabilityCalculationsForCompanyLiabilityVersion(liabilityVersion)


def getAllLiabilityVersionsForPayrollObjectIds(
    payrollObjectIds: Sequence[str],
    payrollObjectType: str,
) -> Sequence[CompanyLiabilityVersionBean]:
    return data_store.getAllLiabilityVersionsForPayrollObjectIds(
        payrollObjectIds=payrollObjectIds, payrollObjectType=payrollObjectType
    )


def getLatestLiabilityVersionsForCompanyPayrollEntityIdAndCheckDate(
    companyPayrollEntityIds: Sequence[str],
    startDate: date,
    endDate: date,
) -> Sequence[CompanyLiabilityVersionBean]:
    return data_store.getLatestLiabilityVersionsForCompanyPayrollEntityIdAndCheckDate(
        companyPayrollEntityIds=companyPayrollEntityIds,
        startDate=startDate,
        endDate=endDate,
    )


def getLiabilitySetOn(
    companyId: str, companyPayrollEntityId: str, effectiveDateTime: datetime
) -> Optional[CompanyLiabilityTypeSet]:
    return data_store.getLiabilitySetOn(companyId, companyPayrollEntityId, effectiveDateTime)


def haveDefaultLiabilityTypesChangedForCpe(cpe: CompanyPayrollEntity) -> bool:
    liabilitySetParams = LiabilitySetParams(isEOR=cpe.isEOR)
    defaultLiabilityTypes = getDefaultLiabilityTypeBeans(cpe.countryCode, liabilitySetParams)
    currentLiabilitySet = data_store.getLiabilitySetOn(
        companyId=cpe.companyId, companyPayrollEntityId=cpe.id, effectiveDateTime=utc_now()
    )
    if currentLiabilitySet is None:
        logger.exception(
            f"haveDefaultLiabilityTypesChangedForCpe Failed to find the current liability set for {cpe.id=}"
        )
        return True

    reasons = stateless.getReasonsForLiabilityTypesMismatch(
        defaultLiabilityTypes, currentLiabilitySet.companyLiabilities
    )
    return bool(reasons)


def getAggregateLiabilityCalculationsForCompanyLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
) -> List[AggregateLiabilityCalculationBean]:
    return data_store.getAggregateLiabilityCalculationsForCompanyLiabilityVersion(liabilityVersion)


def getOrCreateRemittanceIntentFFRequest(intent: RemittanceIntentBean) -> RemittanceIntentFFRequestBean:
    # Conditions to create a new request:
    #   (No existing request) XOR (The existing batch is retryable meaning the request needs to be re-queued)
    # ELSE return the existing request
    existingRequest = getOrNoneLatestFFRequest(intent)
    if not existingRequest:
        return _createRemittanceIntentFFRequest(intent)

    if not existingRequest.id:
        raise ValueError("Existing request does not have an id")

    batch = data_store.getOrNoneAssignedBatchForRequestId(ffRequestId=existingRequest.id)
    if not batch:
        return existingRequest

    # assign the intent to the next batch
    batchStatus = data_store.getLatestBatchStatus(batch)
    if IntentStatusUtil.isAutomaticRetryAllowed(batchStatus.status):
        return _createRemittanceIntentFFRequest(intent)

    return existingRequest


def _createRemittanceIntentFFRequest(intent: RemittanceIntentBean) -> RemittanceIntentFFRequestBean:
    ffRequest = data_store.createRemittanceIntentFFRequest(intent)
    return ffRequest


def createRemittanceVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
    liabilityCode: str,
    dueDate: datetime,
    fulfillAfterDate: datetime,
    reasonCode: str,
) -> RemittanceVersionBean:
    liabilityType = stateless.getLiabilityTypeForLiabilityCode(liabilityCode, liabilityVersion.liabilitySet)
    return data_store.RemittanceVersionDataStore.create(
        RemittanceVersionBean(
            id=ID_PENDING_SAVE,
            companyId=liabilityVersion.companyId,
            liabilityCode=liabilityCode,
            payrollObjectId=liabilityVersion.payrollObjectId,
            payrollObjectType=liabilityVersion.payrollObjectType,
            liabilityVersionId=liabilityVersion.id,
            dueDate=dueDate,
            fulfillAfterDate=fulfillAfterDate,
            reasonCode=reasonCode,
            doNotRemit=liabilityType.doNotRemit,
        )
    )


def getLatestRemittanceVersionsForLiabilityCode(
    companyId: str,
    liabilityCode: str,
) -> Sequence[RemittanceVersionBean]:
    return data_store.getLatestRemittanceVersionsForLiabilityCode(companyId, liabilityCode)


def createRemittanceVersionCorrectionToManualOrderMoneyPath(
    liabilityVersion: CompanyLiabilityVersionBean,
    remittanceVersion: RemittanceVersionBean,
    newMoneyPath: Sequence[MoneyLocation],
    reason: str,
    totalDebitAmountForCompany: Optional[Decimal] = None,
    totalDebitAmountAcrossAllCompanies: Optional[Decimal] = None,
    batchedPaymentAmountAcrossCompanies: Optional[Decimal] = None,
    dryRun: bool = False,
) -> BoolWithReason:
    # !!! WARNING !!! this is for https://rippling.atlassian.net/browse/GP-26989 and will not work yet for
    # other scenarios.
    if not (
        remittanceVersion.companyId == liabilityVersion.companyId
        and remittanceVersion.payrollObjectId == liabilityVersion.payrollObjectId
        and remittanceVersion.payrollObjectType == liabilityVersion.payrollObjectType
    ):
        return BoolWithReason(False, "Remittance version does not match liability version")

    # just create a new remittance version copy with the old one but replace the order money graph
    oldOrders = getRemittanceOrdersForRemittanceVersion(remittanceVersion) if remittanceVersion else []
    manualOrders = [o for o in oldOrders if o.moneyPath and MoneyLocation.MANUAL in o.moneyPath]
    if not manualOrders:
        return BoolWithReason(False, "No manual order to correct in this remittance version")
    if len(manualOrders) > 1:
        return BoolWithReason(False, "More than one manual order to correct in this remittance version")
    manualOrder = manualOrders[0]

    newOrders: list[RemittanceOrderBean] = []
    newOrder = deepcopy(manualOrder)
    newOrder.moneyPath = list(str(p) for p in newMoneyPath)
    newOrders.append(newOrder)
    if (
        totalDebitAmountAcrossAllCompanies is not None
        and totalDebitAmountForCompany is not None
        and batchedPaymentAmountAcrossCompanies is not None
    ):
        companyContributionAmount = (
            batchedPaymentAmountAcrossCompanies * totalDebitAmountForCompany / totalDebitAmountAcrossAllCompanies
        )

        newOrder.liabilityAmount = min(
            newOrder.liabilityAmount,
            companyContributionAmount * (manualOrder.liabilityAmount / totalDebitAmountForCompany),
        ).quantize(Decimal("0.01"), rounding="ROUND_UP")

        remainderOrder = deepcopy(manualOrder)
        remainderOrder.liabilityAmount = max(Decimal(0), manualOrder.liabilityAmount - newOrder.liabilityAmount)
        if remainderOrder.liabilityAmount > Decimal(0):
            newOrders.append(remainderOrder)

    if dryRun:
        return BoolWithReason(
            True,
            f"Would have created {len(newOrders)} new orders with amounts {[o.liabilityAmount for o in newOrders]}",
        )

    dueDate = max([x.dueDate for x in newOrders], default=utc_now())
    fulfillAfterDate = utc_now()  # <-- this is important to fulfill the new order immediately
    remittanceVersion = createRemittanceVersion(
        liabilityVersion=liabilityVersion,
        liabilityCode=remittanceVersion.liabilityCode,
        dueDate=dueDate,
        fulfillAfterDate=fulfillAfterDate,
        reasonCode=reason,
    )
    for newOrder in newOrders:
        createRemittanceOrder(remittanceVersion, newOrder)
    createRemittanceIntentsForRemittanceVersion(remittanceVersion)
    return BoolWithReason(True, f"Created new remittance version {remittanceVersion.id} with {len(newOrders)} orders")


def createRemittanceOrder(remittanceVersion: RemittanceVersionBean, remittanceOrder: RemittanceOrderBean) -> None:
    return data_store.createRemittanceOrder(
        remittanceVersion=remittanceVersion,
        remittanceOrder=remittanceOrder,
    )


def copyRemittanceOrder(remittanceVersion: RemittanceVersionBean, remittanceOrder: RemittanceOrderBean) -> None:
    if not remittanceOrder.id:
        raise Exception("Missing remittanceOrder.id")
    if not remittanceVersion.id:
        raise Exception("Missing remittanceVersion.id")
    if remittanceVersion.id == remittanceOrder.remittanceVersionId:
        return  # nothing to do
    data_store.copyRemittanceOrderToNewRemittanceVersion(
        remittanceOrder.companyId, remittanceOrder.id, remittanceVersion.id
    )
    return


def doesLiabilityAmountMatchOrderAmounts(
    liabilityVersion: CompanyLiabilityVersionBean,
    codeToOrders: Mapping[str, Sequence[RemittanceOrderBean]],
) -> BoolWithReason:
    for code, orders in codeToOrders.items():
        orderTotal = sum((o.liabilityAmount for o in orders), start=Decimal(0))
        try:
            # todo: we don't support getOrNone today
            liabilityTotal = readOnlyGetLiabilityAmountForLiabilityVersionForLiabilityCode(
                liabilityVersion, code
            ).totalAmount
        except:
            liabilityTotal = Decimal(0)

        if orderTotal != liabilityTotal:
            return BoolWithReason(False, f"orderTotal {orderTotal} != liabilityTotal {liabilityTotal} for code {code}")

    return BoolWithReason(True)


def writeRemittanceVersionCorrections(
    liabilityVersion: CompanyLiabilityVersionBean,
    liabilityCode: str,
    ordersToCopy: Sequence[RemittanceOrderBean],
    ordersToCreate: Sequence[RemittanceOrderBean],
    reason: str,
) -> None:
    # validate
    for order in ordersToCopy:
        if order.liabilityCode != liabilityCode:
            raise Exception(f"order.liabilityCode {order.liabilityCode} != code {liabilityCode}")
        if not order.id:
            raise Exception("missing order.id in order to copy")
        if not order.remittanceVersionId:
            raise Exception("missing order.remittanceVersionIdin order to copy")
    for order in ordersToCreate:
        if order.liabilityCode != liabilityCode:
            raise Exception(f"order.liabilityCode {order.liabilityCode} != code {liabilityCode}")
        if order.id:
            raise Exception(f"found order.id in order to create {order.id}")
        if order.remittanceVersionId:
            raise Exception(f"found order.remittanceVersionId in order to create {order.remittanceVersionId}")

    # create the new remittance version
    allOrders = list(ordersToCopy) + list(ordersToCreate)
    dueDate = max([x.dueDate for x in allOrders], default=utc_now())
    fulfillAfterDate = min([x.fulfillAfterDate for x in allOrders], default=utc_now())
    remittanceVersion = createRemittanceVersion(
        liabilityVersion=liabilityVersion,
        liabilityCode=liabilityCode,
        dueDate=dueDate,
        fulfillAfterDate=fulfillAfterDate,
        reasonCode=reason,
    )
    for orderToCopy in ordersToCopy:
        copyRemittanceOrder(remittanceVersion, orderToCopy)
    for orderToCreate in ordersToCreate:
        createRemittanceOrder(remittanceVersion, orderToCreate)
    createRemittanceIntentsForRemittanceVersion(remittanceVersion)


def getLatestRemittanceVersions(
    companyId: str,
    objectId: str,
    objectType: LiabilityPayrollObjectTypes,
) -> Sequence[RemittanceVersionBean]:
    return data_store.getLatestRemittanceVersions(
        companyId=companyId,
        objectId=objectId,
        objectType=objectType,
    )


def readOnlyGetLiabilityCaclulationsForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
) -> ComputedLiabilityCalculationsBean:
    roleLiabilityCalculations = data_store.getRoleLiabilityCalculationsForCompanyLiabilityVersion(liabilityVersion)
    aggregateLiabilityCalculations = data_store.getAggregateLiabilityCalculationsForCompanyLiabilityVersion(
        liabilityVersion
    )
    return ComputedLiabilityCalculationsBean(
        liabilitySet=liabilityVersion.liabilitySet,
        roleLiabilityCalculations=roleLiabilityCalculations,
        aggregateLiabilityCalculations=aggregateLiabilityCalculations,
    )


def readOnlyGetLiabilityAmountForLiabilityVersionForLiabilityCode(
    liabilityVersion: CompanyLiabilityVersionBean, liabilityCode: str
) -> LiabilityCalculationAmount:
    liabilityCalcs = readOnlyGetLiabilityCaclulationsForLiabilityVersion(liabilityVersion)
    return stateless.getLiabilityAmountFromAggregateLiabilityCalculationsForLiabilityCode(
        liabilityCalcs.aggregateLiabilityCalculations, liabilityCode, liabilityVersion.payrollObjectType
    )


# todo (srgarg) deprecate this function once CA stops using it
def readOnlyGetOrRaiseLiabilityCalculationsForReconProcess(
    companyId: str, processId: ReconProcessId
) -> ComputedLiabilityCalculationsBean:
    payrollObject = payroll_interface.getPayrollObjectInfo(
        companyId, processId, LiabilityPayrollObjectTypes.RECON_PROCESS
    )
    return readOnlyGetOrRaiseLiabilityCalculationsForPayrollObject(payrollObject)


# todo (srgarg) deprecate this function once CA stops using it
def readOnlyGetOrRaiseLiabilityCalculationsForRun(companyId: str, runId: str) -> ComputedLiabilityCalculationsBean:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, runId, LiabilityPayrollObjectTypes.PAYRUN)
    return readOnlyGetOrRaiseLiabilityCalculationsForPayrollObject(payrollObject)


def readOnlyGetOrRaiseLiabilityCalculationsForPayrollObject(
    payrollObject: PayrollObjectInfo,
) -> ComputedLiabilityCalculationsBean:
    companyId, payrollObjectId, payrollObjectType = (
        payrollObject.getCompanyId(),
        payrollObject.getPayrollObjectId(),
        payrollObject.getPayrollObjectType(),
    )

    key = CompanyLiabilityVersionKey(companyId, payrollObjectId, payrollObjectType)
    liabilityVersion = data_store.CompanyLiabilityVersionDataStore.getOrNoneLatestVersion(key)
    if not liabilityVersion:
        raise Exception(f"Liability version not found for company {companyId} payrollObjectId {payrollObjectId}")

    return readOnlyGetLiabilityCaclulationsForLiabilityVersion(liabilityVersion)


def readOnlyComputeLiabilityCalculationsForPayrollObjectId(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes
) -> ComputedLiabilityCalculationsBean:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
    return readOnlyComputeLiabilityCalculationsForPayrollObject(payrollObject)


def readOnlyComputeLiabilityCalculationsForPayrollObject(
    payrollObject: PayrollObjectInfo,
    useSnapshottedPREs: bool = True,
    # if provided, this liabilitySet will be used instead of fetching one
    liabilitySetOverride: Optional[CompanyLiabilityTypeSet] = None,
) -> ComputedLiabilityCalculationsBean:
    if not liabilitySetOverride:
        liabilitySet = payrollObject.getOrNoneLiabilitySet()
        if not liabilitySet:
            raise Exception(
                f"Liability set not found for company {payrollObject.getCompanyId()} payroll entity {payrollObject.getCompanyPayrollEntityId()} "
            )
    else:
        liabilitySet = liabilitySetOverride

    roleLiabilityCalcs = computeRoleLiabilityCalculationsForPayrollObject(
        payrollObject, liabilitySet, useSnapshottedPREs
    )
    erTaxCodeCredits = payrollObject.getEmployerTaxCodeCredits()
    erLegalDeductionCodeCredits = payrollObject.getEmployerLegalDeductionCodeCredits()

    aggLiabilityCalcs: list[AggregateLiabilityCalculationBean] = []
    aggLiabilityCalcs.extend(
        stateless.computeAggregateLiabilityCalculations(
            roleLiabilityCalcs, erTaxCodeCredits, erLegalDeductionCodeCredits, payrollObject.getPayrollObjectType()
        )
    )

    aggLiabilityCalcs.extend(stateless.computeAggregateLiabilityCalculationsWithoutRole(payrollObject, liabilitySet))

    return ComputedLiabilityCalculationsBean(liabilitySet, roleLiabilityCalcs, aggLiabilityCalcs)


def readOnlyGetOrRaiseLiabilityAmountForPayrollObjectForLiabilityCode(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes, liabilityCode: str
) -> LiabilityCalculationAmount:
    key = CompanyLiabilityVersionKey(companyId, payrollObjectId, payrollObjectType)
    liabilityVersion = data_store.CompanyLiabilityVersionDataStore.getOrNoneLatestVersion(key)
    if not liabilityVersion:
        raise LiabilityVersionNotFoundException(
            f"Liability version not found for company {companyId} payrollObjectId {payrollObjectId}"
        )
    return readOnlyGetLiabilityAmountForLiabilityVersionForLiabilityCode(liabilityVersion, liabilityCode)


# todo (srgarg) deprecate this function once CA stops using it
def readOnlyGetOrRaiseLiabilityAmountForRunForLiabilityCode(
    companyId: str, runId: str, liabilityCode: str
) -> LiabilityCalculationAmount:
    return readOnlyGetOrRaiseLiabilityAmountForPayrollObjectForLiabilityCode(
        companyId, runId, LiabilityPayrollObjectTypes.PAYRUN, liabilityCode
    )


# todo (srgarg) deprecate this function once CA stops using it
def readOnlyGetOrRaiseLiabilityAmountForReconProcessForLiabilityCode(
    companyId: str, processId: ReconProcessId, liabilityCode: str
) -> LiabilityCalculationAmount:
    return readOnlyGetOrRaiseLiabilityAmountForPayrollObjectForLiabilityCode(
        companyId, processId, LiabilityPayrollObjectTypes.RECON_PROCESS, liabilityCode
    )


def getLiabilityVersionsForCompanyId(
    companyId: str, createdAt: Optional[datetime] = None
) -> Sequence[CompanyLiabilityVersionBean]:
    return data_store.getLiabilityVersionsForCompanyId(companyId, createdAt)


def getLiabilityVersionsForCompanyPayrolEntityId(
    companyId: str, createdAt: Optional[datetime] = None
) -> Sequence[CompanyLiabilityVersionBean]:
    return data_store.getLiabilityVersionsForCompanyPayrollEntityId(companyId, createdAt)


def getOrNoneRemittanceVersionForId(remittanceVersionId: str) -> Optional[RemittanceVersionBean]:
    return data_store.RemittanceVersionDataStore.getOrNoneForId(remittanceVersionId)


def getOrRaiseRemittanceVersionForId(remittanceVersionId: str) -> RemittanceVersionBean:
    return data_store.RemittanceVersionDataStore.getOrRaiseForId(remittanceVersionId)


def getRemittanceIntentsForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
) -> Sequence[RemittanceIntentBean]:
    return data_store.getRemittanceIntentsForPayrollObject(
        liabilityVersion.companyId,
        liabilityVersion.payrollObjectId,
        liabilityVersion.payrollObjectType,
    )


def getRemittanceOrdersForLiabilityVersion(liabilityVersion) -> Sequence[RemittanceOrderBean]:
    versions = getRemittanceVersionsForLiabilityVersion(liabilityVersion)
    return data_store.getRemittanceOrdersForRemittanceVersions(liabilityVersion.companyId, versions)


def getRemittanceVersionsForLiabilityVersion(liabilityVersion) -> Sequence[RemittanceVersionBean]:
    return getLatestRemittanceVersions(
        liabilityVersion.companyId,
        liabilityVersion.payrollObjectId,
        liabilityVersion.payrollObjectType,
    )


def isIntentLatestVersion(intent: RemittanceIntentBean) -> bool:
    return isOrderLatestVersion(intent.remittanceOrder)


def isOrderLatestVersion(order: RemittanceOrderBean) -> bool:
    # todo: no better way to trace the parent version today? there is no
    currentVersionId = order.remittanceVersionId
    if not currentVersionId:
        raise ValueError(f"Remittance order {order.id} has no remittance version id")
    return isLatestRemittanceVersionId(currentVersionId)


def isLatestRemittanceVersionId(remittanceVersionId: str) -> bool:
    # get all the remittance versions with this index
    original = data_store.RemittanceVersionDataStore.getOrNoneForId(remittanceVersionId)
    if original is None:
        raise ValueError(f"Remittance version {remittanceVersionId} does not exist")

    # get all the remittance versions with this index
    latest = data_store.RemittanceVersionDataStore.getOrNoneLatestVersion(
        RemittanceVersionKey(
            companyId=original.companyId,
            payrollObjectId=original.payrollObjectId,
            payrollObjectType=original.payrollObjectType,
            liabilityCode=original.liabilityCode,
        )
    )
    if latest is None:
        raise ValueError(f"Latest emittance version {remittanceVersionId} did not exist")

    return latest.id == remittanceVersionId


def saveRemittanceDebitConfig(debitConfig: RemittanceDebitConfig) -> RemittanceDebitConfig:
    return data_store.saveRemittanceDebitConfig(debitConfig)


def updateLiabilitySetDoNotRemitSettingsForLiabilityCodes(
    liabilitySet: CompanyLiabilityTypeSet,
    defaultLiabilities: Sequence[CompanyLiabilityTypeBean],
    liabilityCodesWithACycle: Sequence[str],
    effectiveDateTime: Optional[datetime] = None,
) -> CompanyLiabilityTypeSet:
    if effectiveDateTime is None:
        effectiveDateTime = utc_now()

    didChange = False
    liabilityCodesWithACycleSet = set(liabilityCodesWithACycle)
    newLiabilityTypes = []
    for oldLiability in liabilitySet.companyLiabilities:
        newLiability = deepcopy(oldLiability)
        newLiabilityTypes.append(newLiability)
        if newLiability.liabilityCode in liabilityCodesWithACycleSet:
            newLiability.doNotRemit = True
            newLiability.doNotRemitReason = DoNotRemitReasons.DISABLED_FOR_DEBIT
        elif newLiability.doNotRemitReason == DoNotRemitReasons.DISABLED_FOR_DEBIT:
            # set it back to the default
            defaultLiability = next(
                (
                    liability
                    for liability in defaultLiabilities
                    if liability.liabilityCode == newLiability.liabilityCode
                ),
                None,
            )
            if defaultLiability is not None:
                newLiability.doNotRemit = defaultLiability.doNotRemit
                newLiability.doNotRemitReason = defaultLiability.doNotRemitReason

        if newLiability.doNotRemit != oldLiability.doNotRemit:
            didChange = True

    if not didChange:
        return liabilitySet

    return data_store.createCompanyLiabilityTypeSet(
        liabilitySet.companyId,
        liabilitySet.companyPayrollEntityId,
        liabilitySet.countryCode,
        effectiveDateTime,
        newLiabilityTypes,
    )


def verifyLiabilityCalculationsAgainstDebitInfo(
    payrollObject: PayrollObjectInfo,
    liabilityCalcs: ComputedLiabilityCalculationsBean,
    verifyStrictEquality: bool = False,
) -> Optional[RemittanceLiabilitiyDebitMismatch]:
    """
    Verify that the liability calculations for a given PAID payrollObject match the existing debit info
    """
    debitInfo = payrollObject.getOrNoneDebitInfo()
    payoutSettings = payrollObject.getOrNoneRunPayoutSettings()
    employeePayoutCurrencyBreakdown = debitInfo.getEmployeePayoutCurrencyBreakdown() if debitInfo else {}
    return stateless.verifyLiabilityCalculations(
        countryCode=payrollObject.getCountryCode(),
        companyId=payrollObject.getCompanyId(),
        payrollObjectId=payrollObject.getPayrollObjectId(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        liabilityCalculations=liabilityCalcs,
        employeePayoutCurrencyBreakdown=employeePayoutCurrencyBreakdown,
        payoutSettings=payoutSettings,
        debitConfig=payrollObject.getOrNoneLatestRemittanceDebitConfig(),
        amountDebitedFromOtherSources=payrollObject.getAmountDebitedFromOtherSourcesByCurrencyCode(),
        verifyStrictEquality=verifyStrictEquality,
    )


# todo (srgarg) deprecate this function once CA stops using it
def verifyLiabilityCalculationsAgainstDebitInfoForPayrollObject(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    liabilityCalcs: ComputedLiabilityCalculationsBean,
) -> Optional[RemittanceLiabilitiyDebitMismatch]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
    return verifyLiabilityCalculationsAgainstDebitInfo(payrollObject, liabilityCalcs)


def isIntentCompletedByInternally(intent: RemittanceIntentBean) -> bool:
    statusBean = getLatestIntentStatus(intent)
    return IntentStatusUtil.isCompletedInternally(statusBean.status)


def isIntentCompleted(intent: RemittanceIntentBean) -> bool:
    statusBean = getLatestIntentStatus(intent)
    return IntentStatusUtil.isCompleted(statusBean.status)


def isIntentInProgress(intent: RemittanceIntentBean) -> bool:
    statusBean = getLatestIntentStatus(intent)
    return IntentStatusUtil.isInProgress(statusBean.status)


def isIntentInRetryableStatus(intent: RemittanceIntentBean, isAutoRetry: bool) -> bool:
    statusBean = getLatestIntentStatus(intent)
    if isAutoRetry and IntentStatusUtil.isAutomaticRetryAllowed(statusBean.status):
        return True
    if not isAutoRetry and IntentStatusUtil.isManualRetryAllowed(statusBean.status):
        return True
    return False


def getPaymentAttemptIdsForRemittanceIntent(intent: RemittanceIntentBean) -> Sequence[str]:
    if not intent.id:
        raise ValueError("intent.id is required")
    return getPaymentAttemptIdsForRemittanceIntentId(intent.id)


def getPaymentAttemptIdsForRemittanceIntentId(intentId: str) -> Sequence[str]:
    return data_store.getPaymentAttemptIdsForRemittanceIntentId(intentId)


def allIntentsQueuedForExternalFulfillment(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes, liabilityCode: str
) -> bool:
    intents = data_store.getRemittanceIntentsForPayrollObject(companyId, payrollObjectId, payrollObjectType)
    intents = [i for i in intents if i.remittanceOrder.liabilityCode == liabilityCode]
    if not intents:
        return False

    intentIds = [i.id for i in intents if i.id is not None]
    statuses = data_store.getLatestIntentStatusesForIntentIds(companyId, intentIds)
    if not statuses:
        return False

    allCompletedExternal = all(IntentStatusUtil.isBeingHandledExternally(s.status) for s in statuses)
    anyCompletedExternal = any(IntentStatusUtil.isBeingHandledExternally(s.status) for s in statuses)
    if anyCompletedExternal and not allCompletedExternal:
        raise ValueError(
            f"anyCompletedExternal but not allCompletedExternal for companyId {companyId} payrollObjectId {payrollObjectId} and liabilityCode {liabilityCode}"
        )

    return allCompletedExternal


def writeRemittancePayrollObjectSnapshot(
    payrollObject: PayrollObjectInfo,
    # if provided, this liabilitySetId will be used instead of the latest liabilitySet
    liabilitySetIdOverride: Optional[str] = None,
) -> Optional[RemittancePayrollObjectSnapshot]:
    if liabilitySetIdOverride:
        liabilitySet = data_store.getLiabilitySetForId(liabilitySetIdOverride)
    else:
        # always use the latest liability set
        liabilitySet = getLiabilitySetOn(
            payrollObject.getCompanyId(), payrollObject.getCompanyPayrollEntityId(), utc_now()
        )

    companyPayrollEntityId = payrollObject.getCompanyPayrollEntityId()
    if not liabilitySet or not liabilitySet.id:
        if canPersistLiabilityTypeSetForCompanyPayrollEntityId(companyPayrollEntityId):
            raise LiabilitySetNotFoundException(
                f"No liabilitySet found for companyId {payrollObject.getCompanyId()} companyPayrollEntityId {companyPayrollEntityId}"
            )
        else:
            return None

    return data_store.writeRemittancePayrollObjectSnapshot(
        companyId=payrollObject.getCompanyId(),
        companyPayrollEntityId=companyPayrollEntityId,
        payrollObjectId=payrollObject.getPayrollObjectId(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        liabilitySetId=liabilitySet.id,
        effectiveDateTime=utc_now(),
    )


def canPersistLiabilityTypeSetForCompanyPayrollEntityId(companyPayrollEntityId: str) -> bool:
    cpe = getCompanyPayrollEntity(companyPayrollEntityId)
    return canPersistLiabilityTypeSetForCountryCode(cpe.countryCode)


def isOrderInProgressOrComplete(order: RemittanceOrderBean) -> bool:
    intents = getRemittanceIntentsForRemittanceOrder(order)
    intentStatuses = getLatestIntentStatusesForIntentIds(order.companyId, [i.id for i in intents])
    return any(
        IntentStatusUtil.isInProgress(intentStatus.status) or IntentStatusUtil.isCompleted(intentStatus.status)
        for intentStatus in intentStatuses
    )


def isOrderComplete(order: RemittanceOrderBean) -> bool:
    intents = getRemittanceIntentsForRemittanceOrder(order)
    intentStatuses = getLatestIntentStatusesForIntentIds(order.companyId, [i.id for i in intents])
    return all(IntentStatusUtil.isCompleted(intentStatus.status) for intentStatus in intentStatuses)


def getOrNoneRemittancePaymentActivityShard(
    companyId: str, groupKey: str, groupItem: str, status: RemittancePaymentActivityStatus
) -> Optional[RemittancePaymentActivity]:
    return data_store.getOrNoneRemittancePaymentActivityGroup(companyId, groupKey, groupItem, status)


def canCreatePaymentActivity(paymentActivity: RemittancePaymentActivity) -> BoolWithReason:
    shard = getOrNoneRemittancePaymentActivityShard(
        paymentActivity.companyId, paymentActivity.groupKey, paymentActivity.groupItem, paymentActivity.status
    )
    if shard:
        return BoolWithReason(False, "PaymentActivity Shard already exists")

    return BoolWithReason(True, "")


def createRemittancePaymentActivity(paymentActivity: RemittancePaymentActivity) -> Optional[RemittancePaymentActivity]:
    can, reason = canCreatePaymentActivity(paymentActivity)
    if not can:
        return None
    return data_store.createRemittancePaymentActivity(paymentActivity)


def getOrNoneRemittancePaymentActivity(paymentActivityId: str) -> Optional[RemittancePaymentActivity]:
    return data_store.getOrNoneRemittancePaymentActivity(paymentActivityId)


def getOrNoneLatestRemittancePaymenentActivityForOrder(
    companyId: str, remittanceOrderId: str
) -> Optional[RemittancePaymentActivity]:
    return data_store.getOrNoneLatestRemittancePaymenentActivityForOrder(companyId, remittanceOrderId)


def getAllLatestRemittancePaymentActivityForCpe(
    cpe: CompanyPayrollEntity,
) -> list[RemittancePaymentActivity]:
    return data_store.getAllLatestRemittancePaymentActivityForCpe(cpe)


def getAllLatestRemittancePaymentActivityForCompany(
    companyId: str,
) -> list[RemittancePaymentActivity]:
    return data_store.getAllLatestRemittancePaymentActivityForCompany(companyId)


def getAllRemittancePaymentActivityForCompanyByGroupKey(companyId: str) -> dict[str, list[RemittancePaymentActivity]]:
    return data_store.getAllRemittancePaymentActivityForCompanyByGroupKey(companyId)


def createRemittanceIntentsForRemittanceVersion(
    remittanceVersion: RemittanceVersionBean,
) -> Sequence[RemittanceIntentBean]:
    remittanceIntents: list[RemittanceIntentBean] = []
    remittanceOrders = getRemittanceOrdersForRemittanceVersion(remittanceVersion)
    for remittanceOrder in remittanceOrders:
        remittanceIntents.extend(createRemittanceIntentsForRemittanceOrder(remittanceVersion, remittanceOrder))

    # immediately archive the intent if another system is still fulfilling it.
    # this will prevent the intent from being flagged as unfulfilled.
    for intent in remittanceIntents:
        tryToSubmitIntentToExternalSystem(intent)

    return remittanceIntents


def tryToSubmitIntentToExternalSystem(intent: RemittanceIntentBean) -> BoolWithReason:
    # when we are shifting traffic to the new stack, if the intent is not expected to be completed by this system,
    # then we should not mark it as completed
    canMark = canSubmitIntentToExternalSystem(intent)
    if not canMark.val:
        return canMark
    updateIntentStatus(intent, IntentStatusUtil.getSubmittedExternalRemittanceStatus())
    return BoolWithReason(True, "")


def canSubmitIntentToExternalSystem(intent: RemittanceIntentBean) -> BoolWithReason:
    status = getLatestIntentStatus(intent)
    if not IntentStatusUtil.isTransitionValid(status.status, IntentStatusUtil.getSubmittedExternalRemittanceStatus()):
        return BoolWithReason(False, f"Intent is already in status {status.status}")

    # todo: remove payroll interface from contract
    countryCode = getPayrollObjectInfo(
        intent.companyId, intent.remittanceOrder.payrollObjectId, intent.remittanceOrder.payrollObjectType
    ).getCountryCode()
    if isFulfillRemittancesEnabledForCountryCode(
        countryCode, intent.companyId, liabilityCode=intent.remittanceOrder.liabilityCode
    ):
        return BoolWithReason(False, "Intent is expected to be fulfilled by this system")

    cpe = getCompanyPayrollEntity(intent.remittanceOrder.companyPayrollEntityId)
    if not hasActiveExternalFulfillmentForCountryCode(
        cpe.countryCode, liabilityCode=intent.remittanceOrder.liabilityCode
    ):
        return BoolWithReason(False, f"{cpe.countryCode} does not have active external fulfilment")

    return BoolWithReason(True, "")


def getAllInProgressRemittanceVersionIdsAcrossCompanies() -> Sequence[str]:
    summaries = data_store.RemittanceLiabilitySummaryDataStore.getAllInProgressItemsAcrossCompanies()
    keys = [
        RemittancePayrollObjectSearchKey(payrollObjectId=s.payrollObjectId, payrollObjectType=s.payrollObjectType)
        for s in summaries
    ]
    return data_store.getLatestRemittanceVersionIdsInBulk(keys)


def getRecentlyUpdatedRemittanceVersionIdsInTimeRange(liabilityCode: str, startDate: datetime) -> Sequence[str]:
    summaries = data_store.RemittanceLiabilitySummaryDataStore.getRecentlyUpdatedRemittanceVersionIdsInTimeRange(
        liabilityCode, startDate
    )
    keys = [
        RemittancePayrollObjectSearchKey(payrollObjectId=s.payrollObjectId, payrollObjectType=s.payrollObjectType)
        for s in summaries
    ]
    return data_store.getLatestRemittanceVersionIdsInBulk(keys)


def updateRemittancePayrollObjectSnapshotToLatestLiabilitySet(payrollObject: PayrollObjectInfo) -> None:
    latestLiabilitySet = data_store.getLiabilitySetOn(
        payrollObject.getCompanyId(), payrollObject.getCompanyPayrollEntityId(), utc_now()
    )

    if not latestLiabilitySet or (not latestLiabilitySet.id):
        logger.error(
            "Could not find a liability set for companyPayrollEntity %s", payrollObject.getCompanyPayrollEntityId()
        )
        return

    data_store.writeRemittancePayrollObjectSnapshot(
        companyId=payrollObject.getCompanyId(),
        companyPayrollEntityId=payrollObject.getCompanyPayrollEntityId(),
        payrollObjectId=payrollObject.getPayrollObjectId(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        liabilitySetId=latestLiabilitySet.id,
        effectiveDateTime=utc_now(),
    )


def getCompanyLiabilityVersionsInTimeRange(
    companyPayrollEntityId: str, startDate: date, endDate: date
) -> Sequence[CompanyLiabilityVersionBean]:
    lvs = data_store.getLiabilityVersionsForCompanyPayrollEntityId(companyPayrollEntityId)
    return [lv for lv in lvs if startDate <= lv.checkDate <= endDate]


def assignFFRequestToBatch(
    ffRequest: RemittanceIntentFFRequestBean, bean: RemittanceFFRequestBatch
) -> RemittanceFFRequestBatch:
    # if the ff request is already assigned to a batch, return that batch
    if not ffRequest.id:
        raise ValueError("ffRequest.id is required")

    batch = data_store.getOrCreateNextBatchForGroupKey(bean)
    data_store.assignFFRequestToBatch(ffRequest, batch)
    return batch


def getOrNoneRemittanceIntentForFFRequest(ffRequest: RemittanceIntentFFRequestBean) -> Optional[RemittanceIntentBean]:
    return data_store.getOrNoneRemittanceIntentForFFRequest(ffRequest)


def getOrNoneRemittanceFFRequestBatch(batchId: str) -> Optional[RemittanceFFRequestBatch]:
    return data_store.getOrNoneRemittanceFFRequestBatch(batchId)


def getCountryCodeFromIntentId(intentId: str) -> str:
    return data_store.getCountryCodeFromIntentId(intentId)


def getAllFFRequestBatchIdsReadyForFulfillment() -> Sequence[str]:
    countryCodeAndTodayDates: list[tuple[str, date]] = []
    for impl in getAllCountryInterfaces():
        if impl.isFulfillRemittancesEnabled():
            countryCode = impl.getCountryCode()
            today = getToday(countryCode)
            countryCodeAndTodayDates.append((countryCode, today))

    batchIds: list[str] = []
    for countryCode, today in countryCodeAndTodayDates:
        startDate = today - timedelta(days=5)
        endDate = today
        batchIds.extend(data_store.getFFRequestBatchIdsReadyForFulfillment(countryCode, startDate, endDate))
    return batchIds


def getCheckDateFromIntentId(intentId: str) -> date:
    return data_store.getCheckDateFromIntentId(intentId)


def getCompanyPayrollEntityIdFromIntentId(intentId: str) -> str:
    return data_store.getCompanyPayrollEntityIdFromIntentId(intentId)
