from fontTools.ttLib.tables import otTables as ot
from fontTools.misc.fixedTools import otRound
from fontTools.ttLib.beyond64k import _compact_layout_tables
from copy import deepcopy
import logging

log = logging.getLogger("fontTools.varLib.instancer")

_LOOKUP_CONDITION_FALSE = 0
_LOOKUP_CONDITION_TRUE = 1
_LOOKUP_CONDITION_KEEP = 2


def _featureVariationRecordIsUnique(rec, seen):
    conditionSet = []
    conditionSets = (
        rec.ConditionSet.ConditionTable if rec.ConditionSet is not None else []
    )
    for cond in conditionSets:
        if cond is None:
            continue
        if cond.Format != 1:
            # can't tell whether this is duplicate, assume is unique
            return True
        conditionSet.append(
            (cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
        )
    # besides the set of conditions, we also include the FeatureTableSubstitution
    # version to identify unique FeatureVariationRecords, even though only one
    # version is currently defined. It's theoretically possible that multiple
    # records with same conditions but different substitution table version be
    # present in the same font for backward compatibility.
    substitutionVersion = (
        rec.FeatureTableSubstitution.Version
        if rec.FeatureTableSubstitution is not None
        else 0x00010000
    )
    recordKey = frozenset([substitutionVersion] + conditionSet)
    if recordKey in seen:
        return False
    else:
        seen.add(recordKey)  # side effect
        return True


def _limitFeatureVariationConditionRange(condition, axisLimit):
    minValue = condition.FilterRangeMinValue
    maxValue = condition.FilterRangeMaxValue

    if (
        minValue > maxValue
        or minValue > axisLimit.maximum
        or maxValue < axisLimit.minimum
    ):
        # condition invalid or out of range
        return

    return tuple(
        axisLimit.renormalizeValue(v, extrapolate=False) for v in (minValue, maxValue)
    )


def _conditionAppliesAtDefault(
    condition, axisLimits, fvarAxes, depth=64, instancer=None
):
    if condition is None:
        return True
    if not depth:
        return False
    if condition.Format == 1:
        if condition.AxisIndex >= len(fvarAxes):
            return False
        axisTag = fvarAxes[condition.AxisIndex].axisTag
        default = axisLimits[axisTag].default if axisTag in axisLimits else 0
        return condition.FilterRangeMinValue <= default <= condition.FilterRangeMaxValue
    if condition.Format == 2:
        value = condition.DefaultValue
        if instancer is not None:
            value += instancer[condition.VarIdx]
        return value > 0
    if condition.Format in (3, 4):
        values = (
            _conditionAppliesAtDefault(
                child, axisLimits, fvarAxes, depth - 1, instancer
            )
            for child in condition.ConditionTable
        )
        return all(values) if condition.Format == 3 else any(values)
    if condition.Format == 5:
        return not _conditionAppliesAtDefault(
            condition.ConditionTable, axisLimits, fvarAxes, depth - 1, instancer
        )
    return False


def _instantiateFeatureVariationRecord(
    record,
    recIdx,
    axisLimits,
    fvarAxes,
    axisIndexMap,
    conditionResults,
    conditionDefaults,
):
    applies = True
    shouldKeep = False
    newConditions = []
    if record.ConditionSet is None:
        record.ConditionSet = ot.ConditionSet()
        record.ConditionSet.ConditionTable = []
        record.ConditionSet.ConditionCount = 0
    for i, condition in enumerate(record.ConditionSet.ConditionTable):
        if condition is None:
            continue
        if condition.Format in (1, 2, 3, 4, 5):
            applies &= conditionDefaults[id(condition)]
            result = _instantiateLookupCondition(
                condition, axisLimits, fvarAxes, axisIndexMap, conditionResults
            )
            if result == _LOOKUP_CONDITION_FALSE:
                newConditions = None
                applies = False
                break
            if result == _LOOKUP_CONDITION_KEEP:
                newConditions.append(condition)
                shouldKeep = True
            elif axisIndexMap:
                shouldKeep = True
        else:
            raise NotImplementedError(
                f"FeatureVariations condition format {condition.Format} is not supported"
            )

    # Satisfied pinned conditions can leave a universal first-match record.
    universal = newConditions is not None and not newConditions
    if newConditions is not None and (shouldKeep or universal):
        record.ConditionSet.ConditionTable = newConditions
        if not newConditions:
            record.ConditionSet = None
        shouldKeep = shouldKeep or bool(axisIndexMap)
    else:
        shouldKeep = False

    return applies, shouldKeep, universal


def _trueLookupCondition():
    condition = ot.ConditionTable()
    condition.Format = 2
    condition.DefaultValue = 1
    condition.VarIdx = ot.NO_VARIATION_INDEX
    return condition


def _instantiateLookupCondition(
    condition, axisLimits, fvarAxes, axisIndexMap, conditionResults, depth=64
):
    # Shared nodes must be folded and remapped only once, even across tables.
    key = id(condition)
    if key not in conditionResults:
        conditionResults[key] = _instantiateLookupConditionImpl(
            condition, axisLimits, fvarAxes, axisIndexMap, conditionResults, depth
        )
    return conditionResults[key]


def _instantiateLookupConditionImpl(
    condition, axisLimits, fvarAxes, axisIndexMap, conditionResults, depth
):
    if condition is None:
        return _LOOKUP_CONDITION_TRUE

    if not depth:
        log.warning("LookupVariationRecord condition nesting is too deep; ignored")
        return _LOOKUP_CONDITION_KEEP

    if condition.Format == 1:
        axisIdx = condition.AxisIndex
        if axisIdx >= len(fvarAxes):
            log.warning(
                "LookupVariationRecord condition has invalid axis index %d; ignored",
                axisIdx,
            )
            return _LOOKUP_CONDITION_KEEP

        axisTag = fvarAxes[axisIdx].axisTag
        from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances

        triple = axisLimits.get(axisTag, NormalizedAxisTripleAndDistances(-1, 0, +1))
        minimum = condition.FilterRangeMinValue
        maximum = condition.FilterRangeMaxValue
        if minimum > maximum or triple.minimum > maximum or triple.maximum < minimum:
            return _LOOKUP_CONDITION_FALSE
        if triple.minimum == triple.maximum:
            return (
                _LOOKUP_CONDITION_TRUE
                if minimum <= triple.default <= maximum
                else _LOOKUP_CONDITION_FALSE
            )

        newRange = _limitFeatureVariationConditionRange(condition, triple)
        if newRange is None or axisTag not in axisIndexMap:
            return _LOOKUP_CONDITION_FALSE

        condition.AxisIndex = axisIndexMap[axisTag]
        condition.FilterRangeMinValue, condition.FilterRangeMaxValue = newRange
        if newRange[0] <= -1 and newRange[1] >= 1:
            return _LOOKUP_CONDITION_TRUE
        return _LOOKUP_CONDITION_KEEP

    if condition.Format == 2:
        if condition.VarIdx != ot.NO_VARIATION_INDEX:
            return _LOOKUP_CONDITION_KEEP
        return (
            _LOOKUP_CONDITION_TRUE
            if condition.DefaultValue > 0
            else _LOOKUP_CONDITION_FALSE
        )

    if condition.Format in (3, 4):
        newConditions = []
        for child in condition.ConditionTable:
            result = _instantiateLookupCondition(
                child, axisLimits, fvarAxes, axisIndexMap, conditionResults, depth - 1
            )
            if condition.Format == 3 and result == _LOOKUP_CONDITION_FALSE:
                return _LOOKUP_CONDITION_FALSE
            if condition.Format == 4 and result == _LOOKUP_CONDITION_TRUE:
                return _LOOKUP_CONDITION_TRUE
            if result == _LOOKUP_CONDITION_KEEP:
                newConditions.append(child)

        condition.ConditionTable = newConditions
        condition.ConditionCount = len(newConditions)
        if newConditions:
            return _LOOKUP_CONDITION_KEEP
        return (
            _LOOKUP_CONDITION_TRUE if condition.Format == 3 else _LOOKUP_CONDITION_FALSE
        )

    if condition.Format == 5:
        result = _instantiateLookupCondition(
            condition.ConditionTable,
            axisLimits,
            fvarAxes,
            axisIndexMap,
            conditionResults,
            depth - 1,
        )
        if result == _LOOKUP_CONDITION_TRUE:
            return _LOOKUP_CONDITION_FALSE
        if result == _LOOKUP_CONDITION_FALSE:
            return _LOOKUP_CONDITION_TRUE
        return _LOOKUP_CONDITION_KEEP

    log.warning(
        "LookupVariationRecord condition has unsupported format %d; ignored",
        condition.Format,
    )
    return _LOOKUP_CONDITION_KEEP


def _instantiateLookupVariations(
    table, fvarAxes, axisLimits, axisIndexMap, conditionResults
):
    variations = table.FeatureVariations
    records = getattr(variations, "LookupVariationRecord", [])
    if not records:
        return

    fullyInstanced = not axisIndexMap
    newRecords = []
    for record in records:
        featureLookups = record.FeatureLookupsTable
        selectedLookups = []
        newConditionRecords = []
        canBake = fullyInstanced

        for conditionRecord in featureLookups.LookupConditionRecord:
            result = _instantiateLookupCondition(
                conditionRecord.ConditionTable,
                axisLimits,
                fvarAxes,
                axisIndexMap,
                conditionResults,
            )
            if result == _LOOKUP_CONDITION_FALSE:
                continue
            if result == _LOOKUP_CONDITION_TRUE:
                selectedLookups.extend(conditionRecord.LookupIndexList.LookupIndex)
                if not fullyInstanced:
                    conditionRecord.ConditionTable = _trueLookupCondition()
                    newConditionRecords.append(conditionRecord)
                continue

            canBake = False
            newConditionRecords.append(conditionRecord)

        if canBake and table.FeatureList is not None:
            featureRecord = table.FeatureList.FeatureRecord[record.FeatureIndex]
            feature = deepcopy(featureRecord.Feature)
            lookupIndices = (
                list(feature.LookupListIndex) if featureLookups.Flags & 1 else []
            )
            lookupIndices.extend(selectedLookups)
            feature.LookupListIndex = sorted(set(lookupIndices))
            feature.LookupCount = len(feature.LookupListIndex)
            featureRecord.Feature = feature
        else:
            featureLookups.LookupConditionRecord = newConditionRecords
            featureLookups.LookupConditionCount = len(newConditionRecords)
            newRecords.append(record)

    variations.LookupVariationRecord = newRecords
    variations.LookupVariationCount = len(newRecords)


def _iterLookupVariationConditions(varfont, done=None):
    conditions = []
    for tableTag in ("GSUB", "GPOS"):
        if tableTag not in varfont:
            continue
        variations = getattr(varfont[tableTag].table, "FeatureVariations", None)
        for record in getattr(variations, "FeatureVariationRecord", []):
            if record.ConditionSet is not None:
                conditions.extend(record.ConditionSet.ConditionTable)
        for record in getattr(variations, "LookupVariationRecord", []):
            conditions.extend(
                conditionRecord.ConditionTable
                for conditionRecord in record.FeatureLookupsTable.LookupConditionRecord
            )

    seen = set() if done is None else done
    while conditions:
        condition = conditions.pop()
        if condition is None:
            continue
        if id(condition) in seen:
            continue
        seen.add(id(condition))
        yield condition

        if condition.Format in (3, 4):
            conditions.extend(condition.ConditionTable)
        elif condition.Format == 5:
            conditions.append(condition.ConditionTable)


def instantiateLookupVariationConditionValues(
    varfont, defaultDeltas=None, varIndexMapping=None, *, done=None
):
    for condition in _iterLookupVariationConditions(varfont, done):
        if condition.Format == 2:
            varIdx = condition.VarIdx
            newVarIdx = (
                varIdx
                if varIndexMapping is None
                else varIndexMapping.get(varIdx, ot.NO_VARIATION_INDEX)
            )
            if defaultDeltas is not None:
                value = condition.DefaultValue + defaultDeltas.get(varIdx, 0)
                # Once constant, only the unrounded sign matters. Rounding a
                # small positive value to zero would change the condition.
                condition.DefaultValue = (
                    int(value > 0)
                    if newVarIdx == ot.NO_VARIATION_INDEX
                    else otRound(value)
                )
            if varIndexMapping is not None:
                condition.VarIdx = newVarIdx


def _instantiateFeatureVariations(
    table, fvarAxes, axisLimits, conditionResults, conditionDefaults
):
    pinnedAxes = set(axisLimits.pinnedLocation())
    axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes]
    axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder}

    featureVariationApplied = False
    uniqueRecords = set()
    newRecords = []
    defaultsSubsts = None

    for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord):
        applies, shouldKeep, universal = _instantiateFeatureVariationRecord(
            record,
            i,
            axisLimits,
            fvarAxes,
            axisIndexMap,
            conditionResults,
            conditionDefaults,
        )

        if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords):
            newRecords.append(record)

        if applies and not featureVariationApplied:
            if record.FeatureTableSubstitution is not None:
                assert record.FeatureTableSubstitution.Version == 0x00010000
                defaultsSubsts = deepcopy(record.FeatureTableSubstitution)
                for default, rec in zip(
                    defaultsSubsts.SubstitutionRecord,
                    record.FeatureTableSubstitution.SubstitutionRecord,
                ):
                    default.Feature = deepcopy(
                        table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature
                    )
                    table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = (
                        deepcopy(rec.Feature)
                    )
            # Set variations only once
            # A null substitution still matches and blocks later records.
            featureVariationApplied = True

        # Further records don't have a chance to apply after a universal record
        if universal:
            break

    # Insert a catch-all record to reinstate the old features if necessary
    if featureVariationApplied and newRecords and not universal:
        defaultRecord = ot.FeatureVariationRecord()
        defaultRecord.ConditionSet = ot.ConditionSet()
        defaultRecord.ConditionSet.ConditionTable = []
        defaultRecord.ConditionSet.ConditionCount = 0
        defaultRecord.FeatureTableSubstitution = defaultsSubsts

        newRecords.append(defaultRecord)

    variations = table.FeatureVariations
    variations.FeatureVariationRecord = newRecords
    variations.FeatureVariationCount = len(newRecords)

    _instantiateLookupVariations(
        table, fvarAxes, axisLimits, axisIndexMap, conditionResults
    )

    if not (
        variations.FeatureVariationCount
        or getattr(variations, "LookupVariationCount", 0)
    ):
        del table.FeatureVariations
        # downgrade table version if there are no FeatureVariations left
        table.Version = 0x00010000


def _checkFeatureVariationConditions(varfont):
    # Check both tables before any instancing changes their stores or features.
    for tableTag in ("GPOS", "GSUB"):
        if tableTag not in varfont:
            continue
        variations = getattr(varfont[tableTag].table, "FeatureVariations", None)
        if variations is None:
            continue
        for record in variations.FeatureVariationRecord:
            if record.ConditionSet is None:
                continue
            for condition in record.ConditionSet.ConditionTable:
                if condition is None:
                    continue
                if condition.Format not in (1, 2, 3, 4, 5):
                    raise NotImplementedError(
                        f"{tableTag} FeatureVariations condition format "
                        f"{condition.Format} is not supported"
                    )


def instantiateFeatureVariations(varfont, axisLimits):
    _checkFeatureVariationConditions(varfont)
    with _compact_layout_tables(varfont):
        _instantiateFeatureVariationsInFont(varfont, axisLimits)


def _instantiateFeatureVariationsInFont(varfont, axisLimits):
    from fontTools.varLib.varStore import VarStoreInstancer

    store = (
        getattr(varfont["GDEF"].table, "VarStore", None) if "GDEF" in varfont else None
    )
    defaultInstancer = VarStoreInstancer(store, varfont["fvar"].axes, {})
    conditionResults = {}
    conditionDefaults = {}
    # Record default applicability before any shared condition is modified.
    for tableTag in ("GPOS", "GSUB"):
        if tableTag not in varfont:
            continue
        variations = getattr(varfont[tableTag].table, "FeatureVariations", None)
        for record in getattr(variations, "FeatureVariationRecord", []):
            if record.ConditionSet is None:
                continue
            for condition in record.ConditionSet.ConditionTable:
                key = id(condition)
                if key not in conditionDefaults:
                    conditionDefaults[key] = _conditionAppliesAtDefault(
                        condition,
                        axisLimits,
                        varfont["fvar"].axes,
                        instancer=defaultInstancer,
                    )
    for tableTag in ("GPOS", "GSUB"):
        if tableTag not in varfont or not getattr(
            varfont[tableTag].table, "FeatureVariations", None
        ):
            continue
        log.info("Instantiating FeatureVariations of %s table", tableTag)
        _instantiateFeatureVariations(
            varfont[tableTag].table,
            varfont["fvar"].axes,
            axisLimits,
            conditionResults,
            conditionDefaults,
        )
        # remove unreferenced lookups
        varfont[tableTag].prune_lookups()
