'''
See COPYRIGHT.md for copyright information.
'''
from __future__ import annotations
from collections import defaultdict, OrderedDict
from math import (log10, isnan, isinf, fabs, floor, pow)
import decimal
from typing import TYPE_CHECKING
from regex import compile as re_compile
import hashlib
from arelle import Locale, XbrlConst, XbrlUtil
from arelle.ModelObject import ObjectPropertyViewWrapper
from arelle.PythonUtil import flattenSequence, strTruncate
from arelle.XmlValidateConst import UNVALIDATED, VALID
from arelle.utils.Contexts import partitionModelXbrlContexts
from arelle.utils.Units import partitionModelXbrlUnits

if TYPE_CHECKING:
    from _decimal import Decimal
    from arelle.ModelInstanceObject import ModelFact
    from arelle.ModelValue import TypeXValue
else:
    ModelFact = None # circular import with ModelInstanceObject


def init(): # prevent circular imports
    global ModelFact
    if ModelFact is None:
        from arelle.ModelInstanceObject import ModelFact

class ValidateCalcsMode:
    NONE = 0                        # no calculations linkbase validation
    XBRL_v2_1_INFER_PRECISION = 1   # pre-2010 spec v2.1 mode
    XBRL_v2_1 = 2                   # post-2010 spec v2.1 mode
    XBRL_v2_1_DEDUPLICATE = 3       # post-2010 spec v2.1 with deduplication
    ROUND_TO_NEAREST = 4            # calculations 1.1 round-to-nearest mode
    TRUNCATION = 5                  # calculations 1.1 truncation mode

    @staticmethod
    def label(enum): # must be dynamic using language choice when selecting enum choice
        if enum == ValidateCalcsMode.NONE:
            return  _("No calculations linkbase checks")
        if enum == ValidateCalcsMode.XBRL_v2_1_INFER_PRECISION:
            return  _("Pre-2010 XBRL 2.1 calculations validation inferring precision")
        if enum == ValidateCalcsMode.XBRL_v2_1:
            return   _("XBRL 2.1 calculations linkbase checks")
        if enum == ValidateCalcsMode.XBRL_v2_1_DEDUPLICATE:
            return  _("XBRL v2.1 calculations linkbase with de-duplication")
        if enum == ValidateCalcsMode.ROUND_TO_NEAREST:
            return  _("Calculations 1.1 round-to-nearest mode")
        if enum == ValidateCalcsMode.TRUNCATION:
            return  _("Calculations 1.1 truncation mode")

    @staticmethod
    def menu(): # must be dynamic class method to map to current language choice
        return OrderedDict((
            (_("No calculation checks"), ValidateCalcsMode.NONE),
            # omit pre-2010 spec v2.1 mode, XBRL_v2_1_INFER_PRECISION
            (_("Calc 1.0 calculations"), ValidateCalcsMode.XBRL_v2_1),
            (_("Calc 1.0 with de-duplication"), ValidateCalcsMode.XBRL_v2_1_DEDUPLICATE),
            (_("Calc 1.1 round-to-nearest mode"), ValidateCalcsMode.ROUND_TO_NEAREST),
            (_("Calc 1.1 truncation mode"), ValidateCalcsMode.TRUNCATION)
        ))


oimXbrlxeBlockingErrorCodes = {
        "xbrlxe:nonDimensionalSegmentScenarioContent",
        "xbrlxe:nonStandardFootnoteResourceRole",
        "xbrlxe:unsupportedTuple",
        "xbrlxe:unexpectedContextContent",
        "xbrlxe:unlinkedFootnoteResource",
        "xbrlxe:unsupportedComplexTypedDimension",
        "xbrlxe:unsupportedExternalRoleRef",
        "xbrlxe:unsupportedFraction",
        "xbrlxe:unsupportedLinkbaseReference",
        "xbrlxe:unsupportedXmlBase",
        "xbrlxe:unsupportedZeroPrecisionFact"
        }
numberPattern = re_compile("[-+]?[0]*([1-9]?[0-9]*)([.])?(0*)([1-9]?[0-9]*)?([eE])?([-+]?[0-9]*)?")
ZERO = decimal.Decimal(0)
ONE = decimal.Decimal(1)
TWO = decimal.Decimal(2)
TEN = decimal.Decimal(10)
NaN = decimal.Decimal("NaN")
floatNaN = float("NaN")
floatINF = float("INF")
INCONSISTENT = "*inconsistent*" # singleton
NIL_FACT_SET = "*nilFactSet*" # singleton
# RANGE values are (lower, upper, incl Lower bound, incl upper bound)
ZERO_RANGE = (0,0,True,True)
EMPTY_SET = set()
def rangeToStr(a, b, inclA, inclB) -> str:
    return {True:"[", False: "("}[inclA] + f"{a}, {b}" + {True:"]", False: ")"}[inclB]

def validate(modelXbrl, validateCalcs) -> None:
    ValidateXbrlCalcs(modelXbrl, validateCalcs).validate()

class ValidateXbrlCalcs:
    def __init__(self, modelXbrl, validateCalcs):
        self.modelXbrl = modelXbrl
        self.inferDecimals = validateCalcs != ValidateCalcsMode.XBRL_v2_1_INFER_PRECISION
        self.deDuplicate = validateCalcs == ValidateCalcsMode.XBRL_v2_1_DEDUPLICATE
        self.xbrl21 = validateCalcs in (ValidateCalcsMode.XBRL_v2_1_INFER_PRECISION, ValidateCalcsMode.XBRL_v2_1, ValidateCalcsMode.XBRL_v2_1_DEDUPLICATE)
        self.calc11 = validateCalcs in (ValidateCalcsMode.ROUND_TO_NEAREST, ValidateCalcsMode.TRUNCATION)
        self.calc11t = validateCalcs == ValidateCalcsMode.TRUNCATION
        self.calc11suffix = "Truncation" if self.calc11t else "Rounding"
        self.mapContext = {}
        self.mapUnit = {}
        self.sumFacts = defaultdict(list)
        self.sumConceptBindKeys = defaultdict(set)
        self.itemFacts = defaultdict(list)
        self.itemConceptBindKeys = defaultdict(set)
        self.duplicateKeyFacts = {}
        self.duplicatedFacts = set()
        self.calc11KeyFacts = defaultdict(list) # calc 11 reported facts by calcKey (concept, ancestor, contextHash, unit)
        self.consistentDupFacts = set() # when deDuplicating, holds the less-precise of v-equal dups
        self.esAlFacts = defaultdict(list)
        self.esAlConceptBindKeys = defaultdict(set)
        self.conceptsInEssencesAlias = set()
        self.requiresElementFacts = defaultdict(list)
        self.conceptsInRequiresElement = set()

    def validate(self):
        # note that calc linkbase checks need to be performed even if no facts in instance (e.g., to detect duplicate relationships)
        modelXbrl = self.modelXbrl
        xbrl21 = self.xbrl21
        calc11 = self.calc11 # round or truncate
        calc11t = self.calc11t # truncate
        sumConceptItemRels = defaultdict(dict) # for calc11 dup sum-item detection

        if xbrl21:
            if not self.modelXbrl.contexts and not self.modelXbrl.facts:
                return # skip if no contexts or facts (note that in calc11 mode the dup relationships test is nonetheless required)
            if not self.inferDecimals: # infering precision is now contrary to XBRL REC section 5.2.5.2
                modelXbrl.info("xbrl.5.2.5.2:inferringPrecision","Validating calculations inferring precision.")
        elif calc11:
            oimErrs = set()
            for i in range(len(modelXbrl.errors) - 1, -1, -1):
                e = modelXbrl.errors[i]
                if e in oimXbrlxeBlockingErrorCodes:
                    del modelXbrl.errors[i] # remove the oim errors from modelXbrl.errors
                oimErrs.add(e)
            if any(e == "xbrlxe:unsupportedTuple" for e in oimErrs):
                # ignore this error and change to warning
                modelXbrl.warning("calc11e:tuplesInReportWarning","Validating of calculations ignores tuples.")
            if any(e in oimXbrlxeBlockingErrorCodes for e in oimErrs if e != "xbrlxe:unsupportedTuple"):
                modelXbrl.warning("calc11e:oimIncompatibleReportWarning","Validating of calculations is skipped due to OIM errors.")
                return;

        # identify equal contexts
        modelXbrl.profileActivity()
        for exemplar_context, *contexts in partitionModelXbrlContexts(modelXbrl).values():
            for context in contexts:
                self.mapContext[context] = exemplar_context
        modelXbrl.profileActivity("... identify equal contexts", minTimeToShow=1.0)

        # identify equal units
        for exemplar_unit, *units in partitionModelXbrlUnits(modelXbrl).values():
            for unit in units:
                self.mapUnit[unit] = exemplar_unit
        modelXbrl.profileActivity("... identify equal units", minTimeToShow=1.0)

        # identify concepts participating in essence-alias relationships
        # identify calcluation & essence-alias base sets (by key)
        if xbrl21:
            for baseSetKey in modelXbrl.baseSets.keys():
                arcrole, ELR, linkqname, arcqname = baseSetKey
                if ELR and linkqname and arcqname:
                    if arcrole in (XbrlConst.essenceAlias, XbrlConst.requiresElement):
                        conceptsSet = {XbrlConst.essenceAlias:self.conceptsInEssencesAlias,
                                       XbrlConst.requiresElement:self.conceptsInRequiresElement}[arcrole]
                        for modelRel in modelXbrl.relationshipSet(arcrole,ELR,linkqname,arcqname).modelRelationships:
                            for concept in (modelRel.fromModelObject, modelRel.toModelObject):
                                if concept is not None and concept.qname is not None:
                                    conceptsSet.add(concept)
            modelXbrl.profileActivity("... identify requires-element and esseance-aliased concepts", minTimeToShow=1.0)

        self.bindFacts(modelXbrl.facts,[modelXbrl.modelDocument.xmlRootElement])
        modelXbrl.profileActivity("... bind facts", minTimeToShow=1.0)

        allArcroles = flattenSequence(
            ({XbrlConst.summationItem, XbrlConst.essenceAlias, XbrlConst.requiresElement} if xbrl21 else EMPTY_SET) |
            ({XbrlConst.summationItem, XbrlConst.summationItem11} if calc11 else EMPTY_SET))
        summationArcroles = flattenSequence(
            ({XbrlConst.summationItem} if xbrl21 else EMPTY_SET) |
            ({XbrlConst.summationItem, XbrlConst.summationItem11} if calc11 else EMPTY_SET))

        # identify calcluation & essence-alias base sets (by key)
        for baseSetKey in modelXbrl.baseSets.keys():
            arcrole, ELR, linkqname, arcqname = baseSetKey
            if ELR and linkqname and arcqname:
                if arcrole in allArcroles:
                    relsSet = modelXbrl.relationshipSet(arcrole,ELR,linkqname,arcqname)
                    sumConceptItemRels.clear()
                    if arcrole in summationArcroles:
                        fromRelationships = relsSet.fromModelObjects()
                        for sumConcept, modelRels in fromRelationships.items():
                            sumBindingKeys = self.sumConceptBindKeys[sumConcept]
                            dupBindingKeys = set()
                            boundSumKeys = set()
                            # determine boundSums
                            for modelRel in modelRels:
                                itemConcept = modelRel.toModelObject
                                if itemConcept is not None and itemConcept.qname is not None:
                                    itemBindingKeys = self.itemConceptBindKeys[itemConcept]
                                    boundSumKeys |= sumBindingKeys & itemBindingKeys
                                    if calc11:
                                        siRels = sumConceptItemRels[sumConcept]
                                        if itemConcept in siRels:
                                            modelXbrl.error("calc11e:duplicateCalculationRelationships",
                                                _("Duplicate summation-item relationships from total concept %(sumConcept)s to contributing concept %(itemConcept)s in link role %(linkrole)s"),
                                                modelObject=(siRels[itemConcept], modelRel), linkrole=modelRel.linkrole,
                                                sumConcept=sumConcept.qname, itemConcept=itemConcept.qname)
                                        siRels[itemConcept] = modelRel
                                        if not sumConcept.isDecimal or not itemConcept.isDecimal:
                                            modelXbrl.error("calc11e:nonDecimalItemNode",
                                                _("The source and target of a Calculations v1.1 relationship MUST both be decimal concepts: %(sumConcept)s, %(itemConcept)s, link role %(linkrole)s"),
                                                modelObject=(sumConcept, itemConcept, modelRel), linkrole=modelRel.linkrole,
                                                sumConcept=sumConcept.qname, itemConcept=itemConcept.qname)

                            # add up rounded items
                            boundSums = defaultdict(decimal.Decimal) # sum of facts meeting factKey
                            boundIntervals = {} # interval sum of facts meeting factKey
                            blockedIntervals = set() # bind Keys for summations which have an inconsistency
                            boundSummationItems = defaultdict(list) # corresponding fact refs for messages
                            boundIntervalItems = defaultdict(list) # corresponding fact refs for messages
                            for modelRel in modelRels:
                                w = modelRel.weightDecimal
                                itemConcept = modelRel.toModelObject
                                if itemConcept is not None:
                                    for itemBindKey in boundSumKeys:
                                        ancestor, contextHash, unit = itemBindKey
                                        factKey = (itemConcept, ancestor, contextHash, unit)
                                        _itemFacts = self.itemFacts.get(factKey,())
                                        if xbrl21:
                                            for fact in _itemFacts:
                                                if not fact.isNil:
                                                    if fact in self.duplicatedFacts:
                                                        dupBindingKeys.add(itemBindKey)
                                                    elif fact not in self.consistentDupFacts:
                                                        roundedValue = roundFact(fact, self.inferDecimals)
                                                        boundSums[itemBindKey] += roundedValue * w
                                                        boundSummationItems[itemBindKey].append(wrappedFactWithWeight(fact,w,roundedValue))
                                        if calc11 and _itemFacts:
                                            y1, y2, iY1, iY2 = self.consistentFactValueInterval(_itemFacts, calc11t)
                                            if y1 is INCONSISTENT:
                                                blockedIntervals.add(itemBindKey)
                                            elif y1 is not NIL_FACT_SET:
                                                x1, x2, iX1, iX2 = boundIntervals.get(itemBindKey, ZERO_RANGE)
                                                y1 *= w
                                                y2 *= w
                                                if y2 < y1:
                                                    y1, y2 = y2, y1
                                                boundIntervals[itemBindKey] = (x1 + y1, x2 + y2, iX1 and iY1, iX2 and iY2)
                                                boundIntervalItems[itemBindKey].extend(_itemFacts)
                            for sumBindKey in boundSumKeys:
                                ancestor, contextHash, unit = sumBindKey
                                factKey = (sumConcept, ancestor, contextHash, unit)
                                if factKey in self.sumFacts:
                                    sumFacts = self.sumFacts[factKey]
                                    if xbrl21:
                                        for fact in sumFacts:
                                            if not fact.isNil:
                                                if fact in self.duplicatedFacts:
                                                    dupBindingKeys.add(sumBindKey)
                                                elif (sumBindKey in boundSums and sumBindKey not in dupBindingKeys
                                                      and fact not in self.consistentDupFacts
                                                      and not (len(sumFacts) > 1 and not self.deDuplicate)): # don't bind if sum duplicated without dedup option
                                                    roundedSum = roundFact(fact, self.inferDecimals)
                                                    roundedItemsSum = roundFact(fact, self.inferDecimals, vDecimal=boundSums[sumBindKey])
                                                    if roundedItemsSum  != roundFact(fact, self.inferDecimals):
                                                        d = inferredDecimals(fact)
                                                        if isnan(d) or isinf(d): d = 4
                                                        _boundSummationItems = boundSummationItems[sumBindKey]
                                                        unreportedContribingItemQnames = [] # list the missing/unreported contributors in relationship order
                                                        for modelRel in modelRels:
                                                            itemConcept = modelRel.toModelObject
                                                            if (itemConcept is not None and
                                                                (itemConcept, ancestor, contextHash, unit) not in self.itemFacts):
                                                                unreportedContribingItemQnames.append(str(itemConcept.qname))
                                                        modelXbrl.log('INCONSISTENCY', "xbrl.5.2.5.2:calcInconsistency",
                                                            _("Calculation inconsistent from %(concept)s in link role %(linkrole)s reported sum %(reportedSum)s computed sum %(computedSum)s context %(contextID)s unit %(unitID)s unreportedContributingItems %(unreportedContributors)s"),
                                                            modelObject=wrappedSummationAndItems(fact, roundedSum, _boundSummationItems),
                                                            concept=sumConcept.qname, linkrole=ELR,
                                                            linkroleDefinition=modelXbrl.roleTypeDefinition(ELR),
                                                            reportedSum=Locale.format_decimal(modelXbrl.locale, roundedSum, 1, max(d,0)),
                                                            computedSum=Locale.format_decimal(modelXbrl.locale, roundedItemsSum, 1, max(d,0)),
                                                            contextID=fact.context.id, unitID=fact.unit.id if fact.unit is not None else "(none)",
                                                            unreportedContributors=", ".join(unreportedContribingItemQnames) or "none")
                                                        del unreportedContribingItemQnames[:]
                                    if calc11:
                                        s1, s2, incls1, incls2 = self.consistentFactValueInterval(sumFacts, calc11t)
                                        if s1 is not INCONSISTENT and s1 is not NIL_FACT_SET and sumBindKey not in blockedIntervals and sumBindKey in boundIntervals:
                                            x1, x2, inclx1, inclx2 = boundIntervals[sumBindKey]
                                            a = max(s1, x1)
                                            b = min(s2, x2)
                                            inclA = incls1 | inclx1
                                            inclB = incls2 | inclx2
                                            if (a == b and not (inclA and inclB)) or (a > b):
                                                modelXbrl.log('INCONSISTENCY', "calc11e:inconsistentCalculationUsing" + self.calc11suffix,
                                                    _("Calculation inconsistent from %(concept)s in link role %(linkrole)s reported sum %(reportedSum)s computed sum %(computedSum)s context %(contextID)s unit %(unitID)s"),
                                                    modelObject=sumFacts + boundIntervalItems[sumBindKey],
                                                    concept=sumConcept.qname, linkrole=ELR,
                                                    linkroleDefinition=modelXbrl.roleTypeDefinition(ELR),
                                                    reportedSum=rangeToStr(s1,s2,incls1,incls2),
                                                    computedSum=rangeToStr(x1,x2,inclx1,inclx2),
                                                    contextID=sumFacts[0].context.id, unitID=sumFacts[0].unit.id if sumFacts[0].unit is not None else "(none)")
                            boundSummationItems.clear() # dereference facts in list
                            boundIntervalItems.clear()
                    elif arcrole == XbrlConst.essenceAlias:
                        for modelRel in relsSet.modelRelationships:
                            essenceConcept = modelRel.fromModelObject
                            aliasConcept = modelRel.toModelObject
                            essenceBindingKeys = self.esAlConceptBindKeys[essenceConcept]
                            aliasBindingKeys = self.esAlConceptBindKeys[aliasConcept]
                            for esAlBindKey in essenceBindingKeys & aliasBindingKeys:
                                ancestor, contextHash = esAlBindKey
                                essenceFactsKey = (essenceConcept, ancestor, contextHash)
                                aliasFactsKey = (aliasConcept, ancestor, contextHash)
                                if essenceFactsKey in self.esAlFacts and aliasFactsKey in self.esAlFacts:
                                    for eF in self.esAlFacts[essenceFactsKey]:
                                        for aF in self.esAlFacts[aliasFactsKey]:
                                            essenceUnit = self.mapUnit.get(eF.unit,eF.unit)
                                            aliasUnit = self.mapUnit.get(aF.unit,aF.unit)
                                            if essenceUnit != aliasUnit:
                                                modelXbrl.log('INCONSISTENCY', "xbrl.5.2.6.2.2:essenceAliasUnitsInconsistency",
                                                    _("Essence-Alias inconsistent units from %(essenceConcept)s to %(aliasConcept)s in link role %(linkrole)s context %(contextID)s"),
                                                    modelObject=(modelRel, eF, aF),
                                                    essenceConcept=essenceConcept.qname, aliasConcept=aliasConcept.qname,
                                                    linkrole=ELR,
                                                    linkroleDefinition=modelXbrl.roleTypeDefinition(ELR),
                                                    contextID=eF.context.id)
                                            if not XbrlUtil.vEqual(eF, aF):
                                                modelXbrl.log('INCONSISTENCY', "xbrl.5.2.6.2.2:essenceAliasUnitsInconsistency",
                                                    _("Essence-Alias inconsistent value from %(essenceConcept)s to %(aliasConcept)s in link role %(linkrole)s context %(contextID)s"),
                                                    modelObject=(modelRel, eF, aF),
                                                    essenceConcept=essenceConcept.qname, aliasConcept=aliasConcept.qname,
                                                    linkrole=ELR,
                                                    linkroleDefinition=modelXbrl.roleTypeDefinition(ELR),
                                                    contextID=eF.context.id)
                    elif arcrole == XbrlConst.requiresElement:
                        for modelRel in relsSet.modelRelationships:
                            sourceConcept = modelRel.fromModelObject
                            requiredConcept = modelRel.toModelObject
                            if sourceConcept in self.requiresElementFacts and \
                               not requiredConcept in self.requiresElementFacts:
                                    modelXbrl.log('INCONSISTENCY', "xbrl.5.2.6.2.4:requiresElementInconsistency",
                                        _("Requires-Element %(requiringConcept)s missing required fact for %(requiredConcept)s in link role %(linkrole)s"),
                                        modelObject=sourceConcept,
                                        requiringConcept=sourceConcept.qname, requiredConcept=requiredConcept.qname,
                                        linkrole=ELR,
                                        linkroleDefinition=modelXbrl.roleTypeDefinition(ELR))
        modelXbrl.profileActivity("... find inconsistencies", minTimeToShow=1.0)
        modelXbrl.profileActivity() # reset

    def bindFacts(self, facts, ancestors):
        for f in facts:
            concept = f.concept
            if concept is not None and f.xValid >= VALID:
                # index facts by their calc relationship set
                if concept.isNumeric:
                    for ancestor in ancestors:
                        # tbd: uniqify context and unit
                        context = self.mapContext.get(f.context,f.context)
                        # must use nonDimAwareHash to achieve s-equal comparison of contexts
                        contextHash = context.contextNonDimAwareHash if context is not None else hash(None)
                        unit = self.mapUnit.get(f.unit,f.unit)
                        calcKey = (concept, ancestor, contextHash, unit)
                        self.itemFacts[calcKey].append(f)
                        bindKey = (ancestor, contextHash, unit)
                        self.itemConceptBindKeys[concept].add(bindKey)
                    self.sumFacts[calcKey].append(f) # sum only for immediate parent
                    self.sumConceptBindKeys[concept].add(bindKey)
                    # calcKey is the last ancestor added (immediate parent of fact)
                    if self.xbrl21 and not f.isNil:
                        fDup = self.duplicateKeyFacts.setdefault(calcKey, f)
                        if fDup is not f:
                            if self.deDuplicate: # add lesser precision fact to consistentDupFacts
                                if self.inferDecimals:
                                    d = inferredDecimals(f); dDup = inferredDecimals(fDup)
                                    dMin = min((d, dDup)); pMin = None
                                    hasAccuracy = (not isnan(d) and not isnan(dDup))
                                    fIsMorePrecise = (d > dDup)
                                else:
                                    p = inferredPrecision(f); pDup = inferredPrecision(fDup)
                                    dMin = None; pMin = min((p, pDup))
                                    hasAccuracy = (p != 0)
                                    fIsMorePrecise = (p > pDup)
                                if (hasAccuracy and
                                    roundValue(f.value,precision=pMin,decimals=dMin) ==
                                    roundValue(fDup.value,precision=pMin,decimals=dMin)):
                                    # consistent duplicate, f more precise than fDup, replace fDup with f
                                    if fIsMorePrecise: # works for inf and integer mixtures
                                        self.duplicateKeyFacts[calcKey] = f
                                        self.consistentDupFacts.add(fDup)
                                    else: # fDup is more precise or equally precise
                                        self.consistentDupFacts.add(f)
                                else: # invalid accuracy or inconsistent duplicates
                                    self.duplicatedFacts.add(f)
                                    self.duplicatedFacts.add(fDup)
                            else: # add both this fact and matching calcKey'ed fact to duplicatedFacts
                                self.duplicatedFacts.add(f)
                                self.duplicatedFacts.add(fDup)
                    if self.calc11:
                        self.calc11KeyFacts[calcKey].append(f)
                elif concept.isTuple and not f.isNil and not self.calc11:
                    self.bindFacts(f.modelTupleFacts, ancestors + [f])

                # index facts by their essence alias relationship set
                if concept in self.conceptsInEssencesAlias and not f.isNil:
                    ancestor = ancestors[-1]    # only care about direct parent
                    context = self.mapContext.get(f.context,f.context)
                    contextHash = context.contextNonDimAwareHash if context is not None else hash(None)
                    esAlKey = (concept, ancestor, contextHash)
                    self.esAlFacts[esAlKey].append(f)
                    bindKey = (ancestor, contextHash)
                    self.esAlConceptBindKeys[concept].add(bindKey)
                # index facts by their requires element usage
                if concept in self.conceptsInRequiresElement:
                    self.requiresElementFacts[concept].append(f)

    def consistentFactValueInterval(self, fList, truncate=False) -> tuple[decimal.Decimal | str, decimal.Decimal | str, bool, bool]:
        _excessDigitFacts = []
        if any(f.isNil for f in fList):
            if all(f.isNil for f in fList):
                return (NIL_FACT_SET,NIL_FACT_SET,True,True)
            _inConsistent = True # provide error message
        else: # not all have same decimals
            a = b = None
            inclA = inclB = _inConsistent = False
            decVals = {}
            for f in fList:
                _v = f.xValue
                if isnan(_v):
                    if len(fList) > 1:
                        _inConsistent = True
                    break
                _d = inferredDecimals(f)
                if insignificantDigits(_v, decimals=_d):
                    _excessDigitFacts.append(f)
                elif _d in decVals:
                    _inConsistent |= _v != decVals[_d]
                else:
                    decVals[_d] = _v
                    _a, _b, _inclA, _inclB = rangeValue(_v, _d, truncate=truncate)
                    if a is None or _a >= a:
                        a = _a
                        inclA |= _inclA
                    if b is None or _b <= b:
                        b = _b
                        inclB |= _inclB
            _inConsistent = (a == b and not(inclA and inclB)) or (a > b)
        if _excessDigitFacts:
            self.modelXbrl.log('INCONSISTENCY', "calc11e:excessDigits",
                _("Calculations check stopped for excess digits in fact values %(element)s: %(values)s, %(contextIDs)s."),
                modelObject=fList, element=_excessDigitFacts[0].qname,
                contextIDs=", ".join(sorted(set(f.contextID for f in _excessDigitFacts))),
                values=", ".join(strTruncate(f.value,64) for f in _excessDigitFacts))
            return (INCONSISTENT, INCONSISTENT,True,True)
        if _inConsistent:
            self.modelXbrl.log('INCONSISTENCY',
                "calc11e:disallowedDuplicateFactsUsingTruncation" if self.calc11t else "oime:disallowedDuplicateFacts",
                _("Calculations check stopped for duplicate fact values %(element)s: %(values)s, %(contextIDs)s."),
                modelObject=fList, element=fList[0].qname,
                contextIDs=", ".join(sorted(set(f.contextID for f in fList))),
                values=", ".join(strTruncate(f.value,64) for f in fList))
            return (INCONSISTENT, INCONSISTENT,True,True)
        return (a, b, inclA, inclB)

def roundFact(fact, inferDecimals=False, vDecimal=None):
    if vDecimal is None:
        vStr = fact.value
        try:
            vDecimal = decimal.Decimal(vStr)
            vFloatFact = float(vStr)
        except (decimal.InvalidOperation, ValueError): # would have been a schema error reported earlier
            vDecimal = NaN
            vFloatFact = floatNaN
    else: #only vFloat is defined, may not need vStr unless inferring precision from decimals
        if vDecimal.is_nan():
            return vDecimal
        vStr = None
        try:
            vFloatFact = float(fact.value)
        except ValueError:
            vFloatFact = floatNaN
    dStr = fact.decimals
    pStr = fact.precision
    if dStr == "INF" or pStr == "INF":
        vRounded = vDecimal
    elif inferDecimals: #infer decimals, round per 4.6.7.2, e.g., half-down
        if pStr:
            p = int(pStr)
            if p == 0:
                vRounded = NaN
            elif vDecimal == 0:
                vRounded = ZERO
            else:
                vAbs = fabs(vFloatFact)
                d = p - int(floor(log10(vAbs))) - 1
                # defeat binary rounding to nearest even
                #if trunc(fmod(vFloat * (10 ** d),2)) != 0:
                #    vFloat += 10 ** (-d - 1) * (1.0 if vFloat > 0 else -1.0)
                #vRounded = round(vFloat, d)
                vRounded = decimalRound(vDecimal,d,decimal.ROUND_HALF_EVEN)
        elif dStr:
            d = int(dStr)
            # defeat binary rounding to nearest even
            #if trunc(fmod(vFloat * (10 ** d),2)) != 0:
            #    vFloat += 10 ** (-d - 1) * (-1.0 if vFloat > 0 else 1.0)
            #vRounded = round(vFloat, d)
            #vRounded = round(vFloat,d)
            vRounded = decimalRound(vDecimal,d,decimal.ROUND_HALF_EVEN)
        else: # no information available to do rounding (other errors xbrl.4.6.3 error)
            vRounded = vDecimal
    else: # infer precision
        if dStr:
            match = numberPattern.match(vStr if vStr else str(vDecimal))
            if match:
                nonZeroInt, period, zeroDec, nonZeroDec, e, exp = match.groups()
                p = (len(nonZeroInt) if nonZeroInt and (len(nonZeroInt)) > 0 else -len(zeroDec)) + \
                    (int(exp) if exp and (len(exp) > 0) else 0) + \
                    (int(dStr))
            else:
                p = 0
        elif pStr:
            p = int(pStr)
        else: # no rounding information
            p = None
        if p == 0:
            vRounded = NaN
        elif vDecimal == 0:
            vRounded = vDecimal
        elif p is not None:  # round per 4.6.7.1, half-up
            vAbs = vDecimal.copy_abs()
            log = vAbs.log10()
            # defeat rounding to nearest even
            d = p - int(log) - (1 if vAbs >= 1 else 0)
            #if trunc(fmod(vFloat * (10 ** d),2)) != 0:
            #    vFloat += 10 ** (-d - 1) * (1.0 if vFloat > 0 else -1.0)
            #vRounded = round(vFloat, d)
            vRounded = decimalRound(vDecimal,d,decimal.ROUND_HALF_UP)
        else: # no information available to do rounding (other errors xbrl.4.6.3 error)
            vRounded = vDecimal
    return vRounded

def decimalRound(x, d, rounding):
    if x.is_normal() and -28 <= d <= 28: # prevent exception with excessive quantization digits
        if d >= 0:
            return x.quantize(ONE.scaleb(-d),rounding)
        else: # quantize only seems to work on fractional part, convert integer to fraction at scaled point
            return x.scaleb(d).quantize(ONE,rounding) * (TEN ** decimal.Decimal(-d)) # multiply by power of 10 to prevent scaleb scientific notatino
    return x # infinite, NaN, zero, or excessive decimal digits ( > 28 )

def inferredPrecision(fact):
    vStr = fact.value
    dStr = fact.decimals
    pStr = fact.precision
    if dStr == "INF" or pStr == "INF":
        return floatINF
    try:
        vFloat = float(vStr)
        if dStr:
            match = numberPattern.match(vStr if vStr else str(vFloat))
            if match:
                nonZeroInt, period, zeroDec, nonZeroDec, e, exp = match.groups()
                p = (len(nonZeroInt) if nonZeroInt else (-len(zeroDec) if nonZeroDec else 0)) + \
                    (int(exp) if exp else 0) + \
                    (int(dStr))
                if p < 0:
                    p = 0 # "pathological case" 2.1 spec example 13 line 7
            else:
                p = 0
        else:
            return int(pStr)
    except ValueError:
        return floatNaN
    if p == 0:
        return 0
    elif vFloat == 0:
        return 0
    else:
        return p

def inferredDecimals(fact: ModelFact) -> float | int:
    vStr = fact.value
    dStr = fact.decimals
    pStr = fact.precision
    if dStr == "INF" or pStr == "INF":
        return floatINF
    try:
        if pStr:
            p = int(pStr)
            if p == 0:
                return floatNaN # =0 cannot be determined
            vFloat = float(vStr)
            if vFloat == 0:
                return floatINF # =0 cannot be determined
            else:
                vAbs = fabs(vFloat)
                return p - int(floor(log10(vAbs))) - 1
        elif dStr:
            return int(dStr)
    except ValueError:
        pass
    return floatNaN

def roundValue(value, precision=None, decimals=None, scale=None):
    try:
        vDecimal = decimal.Decimal(value)
        if scale:
            iScale = int(scale)
            vDecimal = vDecimal.scaleb(iScale)
        if precision is not None:
            vFloat = float(value)
            if scale:
                vFloat = pow(vFloat, iScale)
    except (decimal.InvalidOperation, ValueError, TypeError): # would have been a schema error reported earlier  None gives Type Error (e.g., xsi:nil)
        return NaN
    if precision is not None:
        if not isinstance(precision, (int,float)):
            if precision == "INF":
                precision = floatINF
            else:
                try:
                    precision = int(precision)
                except ValueError: # would be a schema error
                    precision = floatNaN
        if isinf(precision):
            vRounded = vDecimal
        elif precision == 0 or isnan(precision):
            vRounded = NaN
        elif vFloat == 0:
            vRounded = ZERO
        else:
            vAbs = fabs(vFloat)
            log = log10(vAbs)
            d = precision - int(log) - (1 if vAbs >= 1 else 0)
            vRounded = decimalRound(vDecimal,d,decimal.ROUND_HALF_UP)
    elif decimals is not None:
        if not isinstance(decimals, (int,float)):
            if decimals == "INF":
                decimals = floatINF
            else:
                try:
                    decimals = int(decimals)
                except ValueError: # would be a schema error
                    decimals = floatNaN
        if isinf(decimals):
            vRounded = vDecimal
        elif isnan(decimals):
            vRounded = NaN
        else:
            vRounded = decimalRound(vDecimal,decimals,decimal.ROUND_HALF_EVEN)
    else:
        vRounded = vDecimal
    return vRounded

def rangeValue(value, decimals=None, truncate=False) -> tuple[decimal.Decimal, decimal.Decimal, bool, bool]:
    if isinstance(value, list):
        if len(value) == 1 and isinstance(value[0], ModelFact):
            return rangeValue(value[0].xValue, inferredDecimals(value[0]), truncate=truncate)
        # return intersection of fact value ranges
    if isinstance(value, ModelFact):
        return rangeValue(value.xValue, inferredDecimals(value), truncate=truncate)
    try:
        vDecimal = decimal.Decimal(value)
    except (decimal.InvalidOperation, ValueError): # would have been a schema error reported earlier
        return (NaN, NaN, True, True)
    if decimals is not None and decimals != "INF":
        if not isinstance(decimals, (int,float)):
            try:
                decimals = int(decimals)
            except ValueError: # would be a schema error
                decimals = floatNaN
        if not (isinf(decimals) or isnan(decimals)):
            dd = (TEN**(decimal.Decimal(decimals).quantize(ONE,decimal.ROUND_DOWN)*-ONE))
            if not truncate:
                dd /= TWO
                return (vDecimal - dd, vDecimal + dd, True, True)
            elif vDecimal > 0:
                return (vDecimal, vDecimal + dd, True, False)
            elif vDecimal < 0:
                return (vDecimal - dd, vDecimal, False, True)
            else:
                return (vDecimal - dd, vDecimal + dd, False, False)
    return (vDecimal, vDecimal, True, True)


def insignificantDigits(
        value: TypeXValue,
        decimals: int | float | Decimal | str) -> tuple[Decimal, Decimal] | None:
    # Normalize value
    try:
        valueDecimal = decimal.Decimal(value)
    except (decimal.InvalidOperation, ValueError):  # would have been a schema error reported earlier
        return None
    if not valueDecimal.is_normal():  # prevent exception with excessive quantization digits
        return None
    # Normalize decimals
    if isinstance(decimals, str):
        if decimals == "INF":
            return None
        else:
            try:
                decimals = int(decimals)
            except ValueError:  # would have been a schema error reported earlier
                return None
    if isinf(decimals) or isnan(decimals) or decimals <= -28:  # prevent exception with excessive quantization digits
        return None
    if decimals > 0:
        divisor = ONE.scaleb(-decimals)  # fractional scaling doesn't produce scientific notation
    else:  # extra quantize step to prevent scientific notation for decimal number
        divisor = ONE.scaleb(-decimals).quantize(ONE, decimal.ROUND_HALF_UP) # should never round
    try:
        quotient, insignificant = divmod(valueDecimal, divisor)
    except decimal.InvalidOperation:
        return None
    if insignificant:
        significant = quotient * divisor
        return significant, abs(insignificant)
    return None


def wrappedFactWithWeight(fact, weight, roundedValue):
    return ObjectPropertyViewWrapper(fact, ( ("weight", weight), ("roundedValue", roundedValue)) )

def wrappedSummationAndItems(fact, roundedSum, boundSummationItems):
    # need hash of facts and their values from boundSummationItems
    ''' ARELLE-281, replace: faster python-based hash (replace with hashlib for fewer collisions)
    itemValuesHash = hash( tuple(( hash(b.modelObject.qname), hash(b.extraProperties[1][1]) )
                                 # sort by qname so we don't care about reordering of summation terms
                                 for b in sorted(boundSummationItems,
                                                       key=lambda b: b.modelObject.qname)) )
    sumValueHash = hash( (hash(fact.qname), hash(roundedSum)) )
    '''
    sha256 = hashlib.sha256()
    # items hash: sort by qname so we don't care about reordering of summation terms in linkbase updates
    for b in sorted(boundSummationItems, key=lambda b: b.modelObject.qname):
        sha256.update(b.modelObject.qname.namespaceURI.encode('utf-8','replace')) #qname of erroneous submission may not be utf-8 perfectly encodable
        sha256.update(b.modelObject.qname.localName.encode('utf-8','replace'))
        sha256.update(str(b.extraProperties[1][1]).encode('utf-8','replace'))
    itemValuesHash = sha256.hexdigest()
    # summation value hash
    sha256 = hashlib.sha256()
    sha256.update(fact.qname.namespaceURI.encode('utf-8','replace'))
    sha256.update(fact.qname.localName.encode('utf-8','replace'))
    sha256.update(str(roundedSum).encode('utf-8','replace'))
    sumValueHash = sha256.hexdigest()
    # return list of bound summation followed by bound contributing items
    return [ObjectPropertyViewWrapper(fact,
                                      ( ("sumValueHash", sumValueHash),
                                        ("itemValuesHash", itemValuesHash),
                                        ("roundedSum", roundedSum) ))] + \
            boundSummationItems
