import Foundation enum RDEPUBPageMapTakeoverDecision { case keepCurrentWindow case expandWindow(RDEPUBBackgroundCoverageSegment) case segmentReplace(RDEPUBBackgroundCoverageSegment) case fullReplace(RDEPUBBookPageMap) } final class RDEPUBPageMapReconciliationCoordinator { private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { self.context = context } func evaluateTakeover( candidatePageMap: RDEPUBBookPageMap?, candidateSegment: RDEPUBBackgroundCoverageSegment?, currentWindow: RDEPUBBookPageMap?, jumpSession: RDEPUBJumpSession? ) -> RDEPUBPageMapTakeoverDecision { guard let currentWindow else { if let candidatePageMap { return .fullReplace(candidatePageMap) } return .keepCurrentWindow } let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } let lastBuildableSpineIndex = context.publication?.spine.indices .reversed() .first(where: { index in guard let item = context.publication?.spine[index] else { return false } return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) }) ?? 0 if let jumpSession { let protectedIndices = jumpSession.protectedSpineIndices if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) { if let candidateSegment { let candidateIndices = candidateSegment.resolvedSpineIndices let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) / Double(protectedIndices.count) if coverageRatio < 0.8 { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)" ) return .keepCurrentWindow } } } } if let currentSpineIndex { let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex if requiresAdjacentCoverage { if let candidateSegment { let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1) let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1) if !hasPrev || !hasNext { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)" ) return .keepCurrentWindow } } } } if let candidateSegment { let currentRenderSignature = context.currentRenderSignature() if candidateSegment.renderSignature != currentRenderSignature { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)" ) return .keepCurrentWindow } } if let candidateSegment { return evaluateSegmentTakeover( candidateSegment: candidateSegment, currentWindow: currentWindow, currentSpineIndex: currentSpineIndex, lastBuildableSpineIndex: lastBuildableSpineIndex ) } if let candidatePageMap { return evaluateFullPageMapTakeover( candidatePageMap: candidatePageMap, currentWindow: currentWindow, currentSpineIndex: currentSpineIndex, lastBuildableSpineIndex: lastBuildableSpineIndex ) } return .keepCurrentWindow } private func evaluateSegmentTakeover( candidateSegment: RDEPUBBackgroundCoverageSegment, currentWindow: RDEPUBBookPageMap, currentSpineIndex: Int?, lastBuildableSpineIndex: Int ) -> RDEPUBPageMapTakeoverDecision { let currentIndices = Set(currentWindow.entries.map { $0.spineIndex }) let candidateIndices = candidateSegment.resolvedSpineIndices if let currentSpineIndex { if !candidateIndices.contains(currentSpineIndex) { return .keepCurrentWindow } } if let currentSpineIndex { let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 let hasNext = candidateIndices.contains(currentSpineIndex + 1) || currentSpineIndex == lastBuildableSpineIndex if !hasPrev || !hasNext { return .keepCurrentWindow } } let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) || currentIndices.contains(candidateSegment.upperSpineIndex + 1) || candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) || candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min) if isContinuous { return .expandWindow(candidateSegment) } else { let overlap = currentIndices.intersection(candidateIndices) let overlapRatio = Double(overlap.count) / Double(currentIndices.count) if overlapRatio > 0.5 { return .segmentReplace(candidateSegment) } } return .keepCurrentWindow } private func evaluateFullPageMapTakeover( candidatePageMap: RDEPUBBookPageMap, currentWindow: RDEPUBBookPageMap, currentSpineIndex: Int?, lastBuildableSpineIndex: Int ) -> RDEPUBPageMapTakeoverDecision { let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex }) let currentIndices = Set(currentWindow.entries.map { $0.spineIndex }) let currentEntries = currentWindow.entries.count if let currentSpineIndex { if !candidateIndices.contains(currentSpineIndex) { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)" ) return .keepCurrentWindow } } if let currentSpineIndex { let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 let hasNext = candidateIndices.contains(currentSpineIndex + 1) || currentSpineIndex == lastBuildableSpineIndex if !hasPrev || !hasNext { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)" ) return .keepCurrentWindow } } let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices) if coversCurrentWindow { RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)" ) return .fullReplace(candidatePageMap) } RDEPUBBackgroundTrace.log( "Reconciliation", "evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)" ) return .keepCurrentWindow } func protectedSpineIndices( currentSpineIndex: Int?, jumpSession: RDEPUBJumpSession? ) -> Set { var indices: Set = [] if let currentSpineIndex { indices.insert(currentSpineIndex) if currentSpineIndex > 0 { indices.insert(currentSpineIndex - 1) } indices.insert(currentSpineIndex + 1) } if let jumpSession { indices.formUnion(jumpSession.protectedSpineIndices) } return indices } }