import Foundation
import Combine
import CoreData

@MainActor
class ChartsViewModel: ObservableObject {
    // MARK: - Chart Types

    enum ChartType: String, CaseIterable, Identifiable {
        case evolution = "Evolution"
        case allocation = "Allocation"
        case performance = "Performance"
        case contributions = "Contributions"
        case rollingReturn = "Rolling 12M"
        case riskReturn = "Risk vs Return"
        case cashflow = "Net vs Contributions"
        case drawdown = "Drawdown"
        case volatility = "Volatility"
        case prediction = "Prediction"
        case yearOverYear = "Year vs Year"
        case comparison = "Compare"
        case simulator = "What If"
        case periodComparison = "Period vs Period"

        var id: String { rawValue }

        var icon: String {
            switch self {
            case .evolution: return "chart.line.uptrend.xyaxis"
            case .allocation: return "chart.pie.fill"
            case .performance: return "chart.bar.fill"
            case .contributions: return "tray.and.arrow.down.fill"
            case .rollingReturn: return "arrow.triangle.2.circlepath"
            case .riskReturn: return "dot.square"
            case .cashflow: return "chart.bar.xaxis"
            case .drawdown: return "arrow.down.right.circle"
            case .volatility: return "waveform.path.ecg"
            case .prediction: return "wand.and.stars"
            case .yearOverYear: return "calendar"
            case .comparison: return "chart.xyaxis.line"
            case .simulator: return "slider.horizontal.3"
            case .periodComparison: return "calendar.badge.clock"
            }
        }

        var isPremium: Bool {
            switch self {
            case .evolution, .comparison, .periodComparison:
                return false
            case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
                 .drawdown, .volatility, .prediction, .yearOverYear, .simulator:
                return true
            }
        }

        var description: String {
            switch self {
            case .evolution:
                return "Track your portfolio value over time"
            case .allocation:
                return "See how your investments are distributed"
            case .performance:
                return "Compare returns across categories"
            case .contributions:
                return "Review monthly inflows over time"
            case .rollingReturn:
                return "See rolling 12-month performance"
            case .riskReturn:
                return "Compare volatility vs return"
            case .cashflow:
                return "Compare growth vs contributions"
            case .drawdown:
                return "Analyze declines from peak values"
            case .volatility:
                return "Understand investment risk levels"
            case .prediction:
                return "View 12-month forecasts"
            case .yearOverYear:
                return "Compare portfolio values across years"
            case .comparison:
                return "Compare sources side by side"
            case .simulator:
                return "Simulate portfolio reallocation"
            case .periodComparison:
                return "Compare two time periods side by side"
            }
        }
    }

    // MARK: - Published Properties

    @Published var selectedChartType: ChartType = .evolution
    @Published var selectedCategory: Category?
    @Published var selectedSource: InvestmentSource?
    @Published var selectedSourceIds: Set<UUID> = []
    @Published var selectedTimeRange: TimeRange = .year
    @Published var selectedAccount: Account?
    @Published var showAllAccounts = true
    @Published var selectedBreakdown: BreakdownMode = .category

    @Published var evolutionData: [(date: Date, value: Decimal)] = []
    @Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
    @Published var allocationData: [(category: String, value: Decimal, color: String)] = []
    @Published var performanceData: [(category: String, cagr: Double, color: String)] = []
    @Published var contributionsData: [(date: Date, amount: Decimal)] = []
    @Published var rollingReturnData: [(date: Date, value: Double)] = []
    @Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
    @Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
    @Published var drawdownData: [(date: Date, drawdown: Double)] = []
    @Published var volatilityData: [(date: Date, volatility: Double)] = []
    @Published var predictionData: [Prediction] = []
    @Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
    @Published var yearOverYearData: [YearSeries] = []
    /// Months of data hidden by the free-tier 12-month history limit (0 for premium).
    /// Drives the "unlock X more months" teaser in the charts screen.
    @Published var hiddenHistoryMonths: Int = 0
    /// Portfolio-level metrics for the active time range — feeds the iPad KPI header.
    @Published var portfolioMetrics: InvestmentMetrics = .empty
    @Published var yoySelectedYears: Set<Int> = []

    struct YearSeries: Identifiable {
        let id: Int  // year
        let year: Int
        let values: [Double]  // cumulative % return within year (first available month = 0%), NaN = no data
        var forecastEndValue: Double? = nil  // estimated cumulative % at year-end (only for the current, still-incomplete year)
    }

    // MARK: - Comparison chart data models

    enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
        case indexed = "Base 100"
        case returnPct = "Return %"
        case absolute = "Value"
        case monthlyReturn = "Month %"
        var id: String { rawValue }
    }

    struct ComparisonSeries: Identifiable {
        let id: UUID
        let name: String
        let colorHex: String
        let points: [(date: Date, value: Double)]
    }

    @Published var comparisonData: [ComparisonSeries] = []
    @Published var comparisonSelectedSourceIds: Set<UUID> = []
    @Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct
    @Published var comparisonAvailableSources: [InvestmentSource] = []

    // MARK: - Simulator chart data models

    struct SimulatorSource: Identifiable {
        let id: UUID
        let name: String
        let currentPct: Double
        var simulatedPct: Double
        let colorHex: String
    }

    @Published var simulatorSources: [SimulatorSource] = []
    @Published var simulatorActualData: [(date: Date, value: Double)] = []
    @Published var simulatorData: [(date: Date, value: Double)] = []

    // MARK: - Period comparison data models

    struct PeriodSeries: Identifiable {
        let id: String
        let label: String
        let colorHex: String   // e.g. "#0000FF" for blue, "#FF8000" for orange
        let points: [(monthOffset: Int, returnPct: Double)]
    }

    @Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date()
    @Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
    @Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date()
    @Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date()
    @Published var periodComparisonData: [PeriodSeries] = []

    @Published var isLoading = false
    @Published var showingPaywall = false
    @Published private var predictionMonthsAhead = 12
    @Published var performancePeriodMonths: Int = 12

    // MARK: - Time Range

    enum TimeRange: String, CaseIterable, Identifiable {
        case month = "1M"
        case quarter = "3M"
        case halfYear = "6M"
        case year = "12M"
        case yearToDate = "YTD"
        case all = "All"

        var id: String { rawValue }

        var months: Int? {
            switch self {
            case .month: return 1
            case .quarter: return 3
            case .halfYear: return 6
            case .year: return 12
            case .yearToDate: return nil
            case .all: return nil
            }
        }

        func startDate(referenceDate: Date = Date()) -> Date? {
            switch self {
            case .month, .quarter, .halfYear, .year:
                guard let months else { return nil }
                return referenceDate.adding(months: -months).startOfDay
            case .yearToDate:
                return referenceDate.startOfYear
            case .all:
                return nil
            }
        }
    }

    enum BreakdownMode: String, CaseIterable, Identifiable {
        case category = "By Category"
        case source = "By Source"

        var id: String { rawValue }
    }

    static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool {
        breakdown == .category
    }

    // MARK: - Dependencies

    private let sourceRepository: InvestmentSourceRepository
    private let categoryRepository: CategoryRepository
    private let snapshotRepository: SnapshotRepository
    private let calculationService: CalculationService
    private let predictionEngine: PredictionEngine
    private let freemiumValidator: FreemiumValidator
    private let maxHistoryMonths = 60
    private let maxStackedCategories = 6
    private let maxChartPoints = 500
    private var cancellables = Set<AnyCancellable>()
    private var allCategories: [Category] {
        categoryRepository.categories
    }

    // MARK: - Performance: Caching and State
    private var lastChartType: ChartType?
    private var lastTimeRange: TimeRange?
    private var lastCategoryId: UUID?
    private var lastSourceId: UUID?
    private var lastSourceIds: Set<UUID> = []
    private var lastAccountId: UUID?
    private var lastShowAllAccounts: Bool = true
    private var lastBreakdown: BreakdownMode = .category
    private var cachedSnapshots: [Snapshot]?
    private var isUpdateInProgress = false

    // MARK: - Initialization

    init(
        sourceRepository: InvestmentSourceRepository? = nil,
        categoryRepository: CategoryRepository? = nil,
        snapshotRepository: SnapshotRepository? = nil,
        calculationService: CalculationService? = nil,
        predictionEngine: PredictionEngine? = nil,
        iapService: IAPService
    ) {
        self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
        self.categoryRepository = categoryRepository ?? CategoryRepository()
        self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
        self.calculationService = calculationService ?? .shared
        self.predictionEngine = predictionEngine ?? .shared
        self.freemiumValidator = FreemiumValidator(iapService: iapService)

        setupObservers()
        loadData()
    }

    // MARK: - Setup

    private func setupObservers() {
        // Performance: Combine all selection changes into a single debounced stream
        // This prevents multiple rapid updates when switching between views
        Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
            .combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
            .combineLatest($selectedSourceIds)
            .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
            .sink { [weak self] outer, sourceIds in
                guard let self else { return }
                let (combined, selectedSource, selectedBreakdown, showAll) = outer
                let (chartType, category, timeRange, _) = combined

                // Performance: Skip update if nothing meaningful changed
                let safeSelectedAccountId = self.safeSelectedAccountId
                let hasChanges = self.lastChartType != chartType ||
                    self.lastTimeRange != timeRange ||
                    self.lastCategoryId != category?.id ||
                    self.lastSourceId != selectedSource?.id ||
                    self.lastSourceIds != sourceIds ||
                    self.lastAccountId != safeSelectedAccountId ||
                    self.lastShowAllAccounts != showAll ||
                    self.lastBreakdown != selectedBreakdown

                if hasChanges {
                    // Raw snapshots only depend on the data selection (sources/account/
                    // category) — switching chart type, range or breakdown reuses them.
                    let dataSelectionChanged = self.lastCategoryId != category?.id ||
                        self.lastSourceId != selectedSource?.id ||
                        self.lastSourceIds != sourceIds ||
                        self.lastAccountId != safeSelectedAccountId ||
                        self.lastShowAllAccounts != showAll
                    self.lastChartType = chartType
                    self.lastTimeRange = timeRange
                    self.lastCategoryId = category?.id
                    self.lastSourceId = selectedSource?.id
                    self.lastSourceIds = sourceIds
                    self.lastAccountId = safeSelectedAccountId
                    self.lastShowAllAccounts = showAll
                    self.lastBreakdown = selectedBreakdown
                    if dataSelectionChanged {
                        self.cachedSnapshots = nil
                    }
                    self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
                }
            }
            .store(in: &cancellables)

        // Comparison chart: react to source selection and display mode changes
        Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode)
            .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
            .sink { [weak self] _ in
                guard let self, self.selectedChartType == .comparison else { return }
                self.isLoading = true
                let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
                let allSourceIds = allSources.compactMap { $0.id }
                var snapshots: [Snapshot]
                if let cached = self.cachedSnapshots {
                    snapshots = cached
                } else {
                    snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
                    snapshots = self.freemiumValidator.filterSnapshots(snapshots)
                }
                var filtered = snapshots
                if let cutoff = self.selectedTimeRange.startDate() {
                    filtered = snapshots.filter { $0.date >= cutoff }
                }
                self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
                self.isLoading = false
            }
            .store(in: &cancellables)

        // Simulator chart: react to slider changes
        $simulatorSources
            .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
            .sink { [weak self] sources in
                guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return }
                self.recalculateSimulatedLine(sources: sources)
            }
            .store(in: &cancellables)

        // Performance: react to custom period slider
        $performancePeriodMonths
            .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
            .sink { [weak self] _ in
                guard let self, self.selectedChartType == .performance else { return }
                self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange)
            }
            .store(in: &cancellables)

        // Period comparison: react to date picker changes
        Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .sink { [weak self] _ in
                guard let self, self.selectedChartType == .periodComparison else { return }
                let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
                let allSourceIds = allSources.compactMap { $0.id }
                let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
                self.calculatePeriodComparisonData(allSnapshots: snapshots)
            }
            .store(in: &cancellables)
    }

    // MARK: - Data Loading

    func loadData() {
        updateChartData(
            chartType: selectedChartType,
            category: selectedCategory,
            timeRange: selectedTimeRange
        )

        FirebaseService.shared.logScreenView(screenName: "Charts")
    }

    func selectChart(_ chartType: ChartType) {
        if chartType.isPremium && !freemiumValidator.isPremium {
            showingPaywall = true
            FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
            return
        }

        selectedChartType = chartType
        let allowedRanges = availableTimeRanges(for: chartType)
        if !allowedRanges.contains(selectedTimeRange) {
            selectedTimeRange = allowedRanges.first ?? .year
        }
        FirebaseService.shared.logChartViewed(
            chartType: chartType.rawValue,
            isPremium: chartType.isPremium
        )
    }

    func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
        switch chartType {
        case .evolution:
            return [.all, .yearToDate, .year, .quarter]
        case .performance, .yearOverYear, .simulator, .periodComparison:
            return []  // Performance uses custom slider; others have no time range
        case .comparison:
            return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
        default:
            return [.month, .quarter, .halfYear, .year, .all]
        }
    }

    private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
        // Performance: Prevent re-entrancy
        guard !isUpdateInProgress else { return }
        isUpdateInProgress = true
        isLoading = true

        defer {
            isLoading = false
            isUpdateInProgress = false
        }

        let sources: [InvestmentSource]
        if !selectedSourceIds.isEmpty {
            sources = sourceRepository.sources.filter { source in
                selectedSourceIds.contains(source.id) && shouldIncludeSource(source)
            }
        } else if let selectedSource {
            sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) }
        } else if let category = category {
            sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
        } else {
            sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
        }

        let sourceIds = sources.compactMap { $0.id }

        // Performance: cache the FULL history once and slice by time range in memory.
        // The cache is shared across chart types and ranges, so it must never be
        // fetched with a range-dependent months limit (that truncated Year vs Year
        // and "All" after visiting a chart with a shorter range).
        var snapshots: [Snapshot]
        if let cached = cachedSnapshots {
            snapshots = cached
        } else {
            snapshots = snapshotRepository.fetchSnapshots(
                for: sourceIds,
                months: maxHistoryMonths
            )
            hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
            snapshots = freemiumValidator.filterSnapshots(snapshots)
            cachedSnapshots = snapshots
        }

        if let cutoffDate = timeRange.startDate() {
            snapshots = snapshots.filter { $0.date >= cutoffDate }
        }

        // Portfolio KPIs for the visible range (iPad header)
        portfolioMetrics = computePortfolioKPIs(from: snapshots)

        let completedSnapshots = filterSnapshotsForCharts(
            sources: sources,
            snapshots: snapshots
        )

        // Performance: Only calculate data for the selected chart type
        switch chartType {
        case .evolution:
            calculateEvolutionData(from: completedSnapshots)
            let categoriesForChart = categoriesForStackedChart(
                sources: sources,
                selectedCategory: selectedCategory
            )
            calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
        case .allocation:
            calculateAllocationData(for: sources, breakdown: selectedBreakdown)
            calculateAllocationEvolutionData(from: completedSnapshots)
        case .performance:
            // Fetch with performancePeriodMonths (independent of selectedTimeRange)
            let perfSourceIds = sources.compactMap { $0.id }
            let perfAllSnapshots = freemiumValidator.filterSnapshots(
                snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1)
            )
            let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date()
            let perfFiltered = filterSnapshotsForCharts(
                sources: sources,
                snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff }
            )
            let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered)
            calculatePerformanceData(
                for: sources,
                snapshotsBySource: completedSnapshotsBySource,
                breakdown: selectedBreakdown
            )
        case .contributions:
            calculateContributionsData(from: completedSnapshots)
        case .rollingReturn:
            calculateRollingReturnData(from: completedSnapshots)
        case .riskReturn:
            let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
            calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource)
        case .cashflow:
            calculateCashflowData(from: completedSnapshots)
        case .drawdown:
            calculateDrawdownData(from: completedSnapshots)
        case .volatility:
            calculateVolatilityData(from: completedSnapshots)
        case .prediction:
            calculatePredictionData(from: completedSnapshots)
        case .yearOverYear:
            calculateYearOverYearData(from: completedSnapshots)
        case .comparison:
            // Use ALL sources, not filtered by selectedSource/category
            let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
            comparisonAvailableSources = allSources.sorted {
                $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
            }
            let allSourceIds = allSources.compactMap { $0.id }
            var allSnapshots: [Snapshot]
            if let cached = cachedSnapshots {
                allSnapshots = cached
            } else {
                allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
                allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
            }
            var filteredForComparison = allSnapshots
            if let cutoff = timeRange.startDate() {
                filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
            }
            calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
        case .simulator:
            let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
            let allSourceIds = allSources.compactMap { $0.id }
            let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
            calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots)
        case .periodComparison:
            let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
            let allSourceIds = allSources.compactMap { $0.id }
            let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
            calculatePeriodComparisonData(allSnapshots: allSnapshots)
        }

        if let selected = selectedCategory,
           !availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
            selectedCategory = nil
        }
        if let selected = selectedSource,
           !availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
            selectedSource = nil
        }
    }

    func availableCategories(
        for chartType: ChartType,
        sources: [InvestmentSource]? = nil
    ) -> [Category] {
        let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
        let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
        let filtered = allCategories.filter { categoriesWithData.contains($0.id) }

        switch chartType {
        case .evolution, .prediction, .allocation, .performance:
            return filtered
        case .comparison, .simulator, .periodComparison:
            return []
        default:
            return []
        }
    }

    func availableSources(
        for chartType: ChartType,
        sources: [InvestmentSource]? = nil
    ) -> [InvestmentSource] {
        let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
        switch chartType {
        case .evolution, .allocation, .performance:
            return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
        case .comparison, .simulator, .periodComparison:
            return []
        default:
            return []
        }
    }

    private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
        if showAllAccounts || selectedAccount == nil {
            return true
        }
        guard let selectedId = safeSelectedAccountId else { return true }
        return source.account?.id == selectedId
    }

    private var safeSelectedAccountId: UUID? {
        guard !showAllAccounts,
              let selected = selectedAccount,
              !selected.isDeleted else {
            return nil
        }
        return selected.id
    }

    /// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
    static let sourceColorHexesPublic: [String] = [
        "#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
        "#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
    ]

    /// Assigns a unique color to each source based on its sorted position among all sources.
    private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] {
        let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
        var map: [UUID: String] = [:]
        for (index, source) in sorted.enumerated() {
            map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count]
        }
        return map
    }

    private func categoriesForStackedChart(
        sources: [InvestmentSource],
        selectedCategory: Category?
    ) -> [Category] {
        var totals: [UUID: Decimal] = [:]

        for source in sources {
            guard let categoryId = source.category?.id else { continue }
            totals[categoryId, default: 0] += source.latestValue
        }

        var topCategoryIds = Set(
            totals.sorted { $0.value > $1.value }
                .prefix(maxStackedCategories)
                .map { $0.key }
        )

        if let selectedCategory {
            topCategoryIds.insert(selectedCategory.id)
        }

        return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
    }

    private func downsampleSeries(
        _ data: [(date: Date, value: Decimal)],
        maxPoints: Int
    ) -> [(date: Date, value: Decimal)] {
        guard data.count > maxPoints, maxPoints > 0 else { return data }
        let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
        var sampled: [(date: Date, value: Decimal)] = []
        sampled.reserveCapacity(maxPoints)

        var index = 0
        while index < data.count {
            let end = min(index + bucketSize, data.count)
            let bucket = data[index..<end]
            if let last = bucket.last {
                sampled.append(last)
            }
            index += bucketSize
        }
        return sampled
    }

    private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
        guard dates.count > maxPoints, maxPoints > 0 else { return dates }
        let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
        var sampled: [Date] = []
        sampled.reserveCapacity(maxPoints)

        var index = 0
        while index < dates.count {
            let end = min(index + bucketSize, dates.count)
            let bucket = dates[index..<end]
            if let last = bucket.last {
                sampled.append(last)
            }
            index += bucketSize
        }
        return sampled
    }

    // MARK: - Effective Month Mapping

    /// Maps a snapshot date to its effective check-in month.
    /// Snapshots on days 1–20 are attributed to the previous month's check-in.
    private func chartMonth(for snapshotDate: Date) -> DateComponents {
        let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
        return Calendar.current.dateComponents([.year, .month], from: effective)
    }

    private func chartMonthStart(for snapshotDate: Date) -> Date {
        MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
    }

    // MARK: - Chart Calculations

    private func calculateEvolutionData(from snapshots: [Snapshot]) {
        let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
        let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
            chartMonth(for: snapshot.date)
        }

        var series: [(date: Date, value: Decimal)] = []
        series.reserveCapacity(groupedByMonth.count)

        for (key, monthSnapshots) in groupedByMonth {
            var latestBySource: [UUID: Snapshot] = [:]
            for snapshot in monthSnapshots {
                guard let sourceId = snapshot.source?.id else { continue }
                if let existing = latestBySource[sourceId] {
                    if snapshot.date > existing.date {
                        latestBySource[sourceId] = snapshot
                    }
                } else {
                    latestBySource[sourceId] = snapshot
                }
            }

            let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
            let date = Calendar.current.date(from: key) ?? Date()
            series.append((date: date, value: total))
        }

        series.sort { $0.date < $1.date }

        evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
    }

    private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
        let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
        let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
        let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
        let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
            Calendar.current.startOfDay(for: $0.date)
        }
        let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)

        var latestBySource: [UUID: Snapshot] = [:]
        var points: [CategoryEvolutionPoint] = []

        for date in uniqueDates {
            if let daySnapshots = snapshotsByDay[date] {
                for snapshot in daySnapshots {
                    guard let sourceId = snapshot.source?.id else { continue }
                    latestBySource[sourceId] = snapshot
                }
            }

            var valuesByCategory: [UUID: Decimal] = [:]
            for snapshot in latestBySource.values {
                guard let category = snapshot.source?.category else { continue }
                valuesByCategory[category.id, default: 0] += snapshot.decimalValue
            }

            for category in filteredCategories {
                let value = valuesByCategory[category.id] ?? 0
                points.append(CategoryEvolutionPoint(
                    date: date,
                    categoryName: category.name,
                    colorHex: category.colorHex,
                    value: value
                ))
            }
        }

        categoryEvolutionData = points
    }

    private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) {
        switch breakdown {
        case .category:
            let categories = categoryRepository.categories
            let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }

            allocationData = categories.compactMap { category in
                let categorySources = valuesByCategory[category.id] ?? []
                let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
                guard categoryValue > 0 else { return nil }

                return (
                    category: category.name,
                    value: categoryValue,
                    color: category.colorHex
                )
            }.sorted { $0.value > $1.value }
        case .source:
            let colorMap = sourceColorMap(for: sources)
            allocationData = sources.compactMap { source in
                guard source.latestValue > 0 else { return nil }
                return (
                    category: source.name,
                    value: source.latestValue,
                    color: colorMap[source.id] ?? "#6B7280"
                )
            }.sorted { $0.value > $1.value }
        }
    }

    private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) {
        let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
        let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
            chartMonth(for: snapshot.date)
        }

        let sortedMonths = groupedByMonth.keys.sorted {
            let d1 = ($0.year ?? 0) * 100 + ($0.month ?? 0)
            let d2 = ($1.year ?? 0) * 100 + ($1.month ?? 0)
            return d1 < d2
        }

        // First pass: compute totals per category across all months for stable ordering
        var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
        var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []

        for monthKey in sortedMonths {
            guard let monthSnapshots = groupedByMonth[monthKey],
                  let monthDate = Calendar.current.date(from: monthKey) else { continue }

            var categoryTotals: [String: (value: Decimal, color: String)] = [:]
            var sourceLatest: [UUID: Snapshot] = [:]

            for snapshot in monthSnapshots {
                guard let sourceId = snapshot.source?.id else { continue }
                if let existing = sourceLatest[sourceId] {
                    if snapshot.date > existing.date {
                        sourceLatest[sourceId] = snapshot
                    }
                } else {
                    sourceLatest[sourceId] = snapshot
                }
            }

            for (_, snapshot) in sourceLatest {
                let categoryName = snapshot.source?.category?.name ?? "Other"
                let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280"
                let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex)
                categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex)

                let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex)
                globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex)
            }

            let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value }
            guard total > 0 else { continue }
            monthlyData.append((date: monthDate, categories: categoryTotals))
        }

        // Stable category order based on overall totals (largest first)
        let stableCategoryOrder = globalCategoryTotals
            .sorted { $0.value.total > $1.value.total }
            .map { $0.key }

        // Second pass: emit data points in stable order
        var result: [(date: Date, category: String, percentage: Double, color: String)] = []
        for month in monthlyData {
            let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value }
            guard total > 0 else { continue }

            for category in stableCategoryOrder {
                guard let info = month.categories[category] else { continue }
                let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue
                result.append((date: month.date, category: category, percentage: percentage, color: info.color))
            }
        }

        allocationEvolutionData = result
    }

    private func completedMonthKeys(
        sources: [InvestmentSource],
        snapshots: [Snapshot]
    ) -> Set<DateComponents> {
        let sourceIds = Set(sources.compactMap { $0.id })
        guard !sourceIds.isEmpty else { return [] }

        let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
            chartMonth(for: snapshot.date)
        }

        var completed: Set<DateComponents> = []
        completed.reserveCapacity(groupedByMonth.count)

        for (key, monthSnapshots) in groupedByMonth {
            // A month is complete when all active sources have snapshot data for it.
            // MonthlyCheckInStore (UserDefaults) is NOT synced via iCloud — don't use it as a filter.
            let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
            if sourceIds.isSubset(of: monthSourceIds) {
                completed.insert(key)
            }
        }

        return completed
    }

    private func filterSnapshotsForCharts(
        sources: [InvestmentSource],
        snapshots: [Snapshot]
    ) -> [Snapshot] {
        let completedKeys = completedMonthKeys(sources: sources, snapshots: snapshots)
        return snapshots.filter { completedKeys.contains(chartMonth(for: $0.date)) }
    }

    private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] {
        var grouped: [UUID: [Snapshot]] = [:]
        for snapshot in snapshots {
            guard let id = snapshot.source?.id else { continue }
            grouped[id, default: []].append(snapshot)
        }
        return grouped
    }

    private func calculatePerformanceData(
        for sources: [InvestmentSource],
        snapshotsBySource: [UUID: [Snapshot]],
        breakdown: BreakdownMode
    ) {
        switch breakdown {
        case .category:
            let categories = categoryRepository.categories
            let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }

            performanceData = categories.compactMap { category in
                let categorySources = sourcesByCategory[category.id] ?? []
                let snapshots = categorySources.compactMap { source -> [Snapshot]? in
                    let id = source.id
                    return snapshotsBySource[id]
                }.flatMap { $0 }
                guard snapshots.count >= 2 else { return nil }

                let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
                guard let first = monthlyTotals.first,
                      let last = monthlyTotals.last,
                      first.totalValue > 0 else { return nil }
                let cagr = calculationService.calculateCAGR(
                    startValue: first.totalValue,
                    endValue: last.totalValue,
                    startDate: first.date,
                    endDate: last.date
                )

                return (
                    category: category.name,
                    cagr: cagr,
                    color: category.colorHex
                )
            }.sorted { $0.cagr > $1.cagr }
        case .source:
            let colorMap = sourceColorMap(for: sources)
            performanceData = sources.compactMap { source in
                guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
                let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
                guard let first = monthlyTotals.first,
                      let last = monthlyTotals.last,
                      first.totalValue > 0 else { return nil }

                let cagr = calculationService.calculateCAGR(
                    startValue: first.totalValue,
                    endValue: last.totalValue,
                    startDate: first.date,
                    endDate: last.date
                )

                return (
                    category: source.name,
                    cagr: cagr,
                    color: colorMap[source.id] ?? "#6B7280"
                )
            }.sorted { $0.cagr > $1.cagr }
        }
    }

    private func calculateContributionsData(from snapshots: [Snapshot]) {
        let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
        contributionsData = grouped.map { date, items in
            let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
            return (date: date, amount: total)
        }
        .sorted { $0.date < $1.date }
    }

    private func calculateRollingReturnData(from _: [Snapshot]) {
        // Needs full history (not time-filtered) to have 13+ months
        let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
        let allSnapshots = freemiumValidator.filterSnapshots(
            snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
        )
        let totals = monthlyTotals(from: allSnapshots)
        guard totals.count >= 13 else {
            rollingReturnData = []
            return
        }

        var returns: [(date: Date, value: Double)] = []
        for index in 12..<totals.count {
            let current = totals[index]
            let base = totals[index - 12]
            guard base.totalValue > 0 else { continue }
            let change = current.totalValue - base.totalValue
            let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
            returns.append((date: current.date, value: percent))
        }
        rollingReturnData = returns
    }

    private func calculateRiskReturnData(
        for sources: [InvestmentSource],
        snapshotsBySource: [UUID: [Snapshot]]
    ) {
        let categories = categoryRepository.categories
        let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }

        riskReturnData = categories.compactMap { category in
            let categorySources = sourcesByCategory[category.id] ?? []
            let snapshots = categorySources.compactMap { source -> [Snapshot]? in
                let id = source.id
                return snapshotsBySource[id]
            }.flatMap { $0 }
            let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
            guard monthlyTotals.count >= 3,
                  let first = monthlyTotals.first,
                  let last = monthlyTotals.last,
                  first.totalValue > 0 else { return nil }
            let cagr = calculationService.calculateCAGR(
                startValue: first.totalValue,
                endValue: last.totalValue,
                startDate: first.date,
                endDate: last.date
            )
            let monthlyReturns = monthlyReturnSeries(from: monthlyTotals)
            let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
            return (
                category: category.name,
                cagr: cagr,
                volatility: volatility,
                color: category.colorHex
            )
        }.sorted { $0.cagr > $1.cagr }
    }

    private func calculateCashflowData(from snapshots: [Snapshot]) {
        let monthlyTotals = monthlyTotals(from: snapshots)
        let contributionsByMonth = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
            .mapValues { items in
                items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
            }

        var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
        for index in 0..<monthlyTotals.count {
            let current = monthlyTotals[index]
            let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
            let contributions = contributionsByMonth[current.date] ?? 0
            let netPerformance = current.totalValue - previousTotal - contributions
            data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
        }
        cashflowData = data
    }

    private func calculateDrawdownData(from snapshots: [Snapshot]) {
        let totals = monthlyTotals(from: snapshots)
        guard !totals.isEmpty else {
            drawdownData = []
            return
        }

        guard let firstTotal = totals.first else {
            drawdownData = []
            return
        }
        var peak = firstTotal.totalValue
        var data: [(date: Date, drawdown: Double)] = []

        for point in totals {
            let value = point.totalValue
            if value > peak { peak = value }
            let drawdown = peak > 0
                ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
                : 0
            data.append((date: point.date, drawdown: -drawdown))
        }

        drawdownData = data
    }

    private func calculateVolatilityData(from snapshots: [Snapshot]) {
        let totals = monthlyTotals(from: snapshots)
        guard totals.count >= 4 else {
            volatilityData = []
            return
        }

        // Compute monthly return series from portfolio totals
        var monthlyReturns: [Double] = []
        var returnDates: [Date] = []
        for i in 1..<totals.count {
            let prev = totals[i - 1].totalValue
            let curr = totals[i].totalValue
            guard prev > 0 else { continue }
            let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100
            monthlyReturns.append(ret)
            returnDates.append(totals[i].date)
        }

        let windowSize = 3
        guard monthlyReturns.count >= windowSize else {
            volatilityData = []
            return
        }

        // Rolling std dev of monthly returns (annualised)
        var data: [(date: Date, volatility: Double)] = []
        for i in (windowSize - 1)..<monthlyReturns.count {
            let window = Array(monthlyReturns[(i - windowSize + 1)...i])
            let mean = window.reduce(0, +) / Double(window.count)
            let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(max(1, window.count - 1))
            let stdDev = sqrt(max(0, variance))
            data.append((date: returnDates[i], volatility: stdDev))
        }

        volatilityData = data
    }

    private func calculatePredictionData(from snapshots: [Snapshot]) {
        guard freemiumValidator.canViewPredictions() else {
            predictionData = []
            return
        }
        let totals = monthlyTotals(from: snapshots)
        let series = totals.map { (date: $0.date, value: $0.totalValue) }
        let result = predictionEngine.predict(series: series, monthsAhead: predictionMonthsAhead)
        predictionData = result.predictions
    }

    private func calculateYearOverYearData(from _: [Snapshot]) {
        // Always use full history (not time-range filtered)
        let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
        let allSnapshots = freemiumValidator.filterSnapshots(
            snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
        )

        let totals = monthlyTotals(from: allSnapshots)
        guard !totals.isEmpty else {
            yearOverYearData = []
            return
        }

        let calendar = Calendar.current

        // Group monthly totals by (year, month)
        var byYearMonth: [Int: [Int: Decimal]] = [:]
        for point in totals {
            let year = calendar.component(.year, from: point.date)
            let month = calendar.component(.month, from: point.date)
            byYearMonth[year, default: [:]][month] = point.totalValue
        }

        let years = byYearMonth.keys.sorted()
        guard !years.isEmpty else {
            yearOverYearData = []
            return
        }

        let nowYear = calendar.component(.year, from: Date())
        let nowMonth = calendar.component(.month, from: Date())

        // For each year, compute cumulative % return from the first available month (baseline = 0%)
        yearOverYearData = years.map { year -> YearSeries in
            var values = [Double](repeating: Double.nan, count: 12)
            let yearData = byYearMonth[year] ?? [:]
            let sortedMonths = yearData.keys.sorted()
            guard let baselineMonth = sortedMonths.first,
                  let baselineDecimal = yearData[baselineMonth] else {
                return YearSeries(id: year, year: year, values: values)
            }
            let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue
            guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) }

            for monthIdx in 0..<12 {
                let month = monthIdx + 1
                if let v = yearData[month] {
                    let vDouble = NSDecimalNumber(decimal: v).doubleValue
                    values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0
                }
            }

            // #146: forecast the year-end value for the current, still-incomplete year so the
            // "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12.
            var forecastEnd: Double? = nil
            if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 {
                let monthsAhead = 12 - lastDataMonth
                let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in
                    guard let v = yearData[m],
                          let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil }
                    return (date: d, value: v)
                }
                if absSeries.count >= 3 {
                    let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead)
                    if let predDec = result.predictions.last?.predictedValue {
                        let predDouble = NSDecimalNumber(decimal: predDec).doubleValue
                        forecastEnd = ((predDouble - baseline) / baseline) * 100.0
                    }
                }
                // Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December.
                if forecastEnd == nil,
                   let firstIdx = (0..<12).first(where: { !values[$0].isNaN }),
                   let lastIdx = (0..<12).last(where: { !values[$0].isNaN }),
                   lastIdx > firstIdx {
                    let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx)
                    forecastEnd = monthlyRate * Double(11 - firstIdx)
                }
            }

            return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd)
        }

        // Default-select the last 2 available years
        let availableYears = Set(years)
        if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) {
            yoySelectedYears = Set(years.suffix(2))
        }
    }

    // MARK: - Comparison Chart Calculation

    func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
        let colorHexes = Self.sourceColorHexesPublic
        let sortedSources = sources.sorted {
            $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
        }

        var result: [ComparisonSeries] = []

        for (index, source) in sortedSources.enumerated() {
            guard let sourceId = source.id as UUID?,
                  comparisonSelectedSourceIds.contains(sourceId) else { continue }

            let sourceSnapshots = allSnapshots
                .filter { $0.source?.id == sourceId }
                .sorted { $0.date < $1.date }

            // Group by month, take latest per month
            let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
                chartMonth(for: snap.date)
            }
            var monthlyValues: [(date: Date, value: Double)] = []
            for (key, snaps) in grouped {
                guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
                let date = Calendar.current.date(from: key) ?? Date()
                monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
            }
            monthlyValues.sort { $0.date < $1.date }
            guard !monthlyValues.isEmpty else { continue }

            let firstValue = monthlyValues[0].value
            let points: [(date: Date, value: Double)]
            switch comparisonDisplayMode {
            case .indexed:
                points = monthlyValues.map { item in
                    let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0
                    return (date: item.date, value: v)
                }
            case .returnPct:
                points = monthlyValues.map { item in
                    let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0
                    return (date: item.date, value: v)
                }
            case .absolute:
                points = monthlyValues
            case .monthlyReturn:
                points = monthlyValues.enumerated().map { idx, item in
                    guard idx > 0 else { return (date: item.date, value: 0.0) }
                    let prev = monthlyValues[idx - 1].value
                    let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0
                    return (date: item.date, value: pct)
                }
            }

            let colorHex = colorHexes[index % colorHexes.count]
            result.append(ComparisonSeries(
                id: sourceId,
                name: source.name,
                colorHex: colorHex,
                points: points
            ))
        }

        comparisonData = result
    }

    // MARK: - Simulator Chart Calculation

    func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
        guard !sources.isEmpty else {
            simulatorSources = []
            simulatorActualData = []
            simulatorData = []
            return
        }

        let colorHexes = Self.sourceColorHexesPublic
        let sortedSources = sources.sorted {
            $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
        }

        // Build monthly totals per source
        var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
        for source in sortedSources {
            guard let sourceId = source.id as UUID? else { continue }
            let sourceSnapshots = allSnapshots
                .filter { $0.source?.id == sourceId }
                .sorted { $0.date < $1.date }
            let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
                chartMonth(for: snap.date)
            }
            var monthly: [(date: Date, value: Double)] = []
            for (key, snaps) in grouped {
                guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
                let date = Calendar.current.date(from: key) ?? Date()
                monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
            }
            monthly.sort { $0.date < $1.date }
            sourceMonthlyValues[sourceId] = monthly
        }

        // Gather all unique months across sources
        var allMonthsSet = Set<Date>()
        for monthly in sourceMonthlyValues.values {
            monthly.forEach { allMonthsSet.insert($0.date) }
        }
        let allMonths = allMonthsSet.sorted()
        guard !allMonths.isEmpty else {
            simulatorActualData = []
            simulatorData = []
            return
        }

        // Build actual total portfolio per month (forward-fill per source)
        var lastValueBySource: [UUID: Double] = [:]
        var actualTotals: [(date: Date, value: Double)] = []
        for month in allMonths {
            for source in sortedSources {
                guard let sourceId = source.id as UUID? else { continue }
                if let monthly = sourceMonthlyValues[sourceId],
                   let entry = monthly.first(where: { $0.date == month }) {
                    lastValueBySource[sourceId] = entry.value
                }
            }
            let total = lastValueBySource.values.reduce(0, +)
            actualTotals.append((date: month, value: total))
        }

        // Normalize actual to Base 100
        let firstTotal = actualTotals.first?.value ?? 1
        simulatorActualData = actualTotals.map { item in
            (date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0)
        }

        // Compute current weights
        let latestTotalValue = actualTotals.last?.value ?? 0
        var simulatorNew: [SimulatorSource] = []
        for (index, source) in sortedSources.enumerated() {
            guard let sourceId = source.id as UUID? else { continue }
            let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0
            let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0
            // Preserve existing simulatedPct if source already exists in simulatorSources
            let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct
            simulatorNew.append(SimulatorSource(
                id: sourceId,
                name: source.name,
                currentPct: currentPct,
                simulatedPct: existingPct,
                colorHex: colorHexes[index % colorHexes.count]
            ))
        }

        // Only reset sliders when the source set actually changed; otherwise refresh
        // current percentages but keep the user's simulated allocations.
        if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
            simulatorSources = simulatorNew
        } else {
            simulatorSources = zip(simulatorSources, simulatorNew).map { old, new in
                var updated = new
                updated.simulatedPct = old.simulatedPct
                return updated
            }
        }

        recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
    }

    func recalculateSimulatedLine(sources: [SimulatorSource]) {
        // Re-fetch source monthly values for recalculation from current allSources
        let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
        let allSourceIds = allSources.compactMap { $0.id }
        let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)

        var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
        for source in allSources {
            guard let sourceId = source.id as UUID? else { continue }
            let sourceSnapshots = allSnapshots
                .filter { $0.source?.id == sourceId }
                .sorted { $0.date < $1.date }
            let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
                chartMonth(for: snap.date)
            }
            var monthly: [(date: Date, value: Double)] = []
            for (key, snaps) in grouped {
                guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
                let date = Calendar.current.date(from: key) ?? Date()
                monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
            }
            monthly.sort { $0.date < $1.date }
            sourceMonthlyValues[sourceId] = monthly
        }

        var allMonthsSet = Set<Date>()
        for monthly in sourceMonthlyValues.values {
            monthly.forEach { allMonthsSet.insert($0.date) }
        }
        let allMonths = allMonthsSet.sorted()
        recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
    }

    private func recalculateSimulatedLine(
        sources: [SimulatorSource],
        sourceMonthlyValues: [UUID: [(date: Date, value: Double)]],
        allMonths: [Date]
    ) {
        guard !allMonths.isEmpty, !sources.isEmpty else {
            simulatorData = []
            return
        }

        // Normalize weights
        let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct }
        guard totalSimulated > 0 else {
            simulatorData = []
            return
        }
        let normalizedWeights: [UUID: Double] = Dictionary(
            uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) }
        )

        // Build per-source monthly return series
        var sourceReturns: [UUID: [Date: Double]] = [:]
        for source in sources {
            guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue }
            var returns: [Date: Double] = [:]
            for i in 1..<monthly.count {
                let prev = monthly[i - 1].value
                let curr = monthly[i].value
                if prev > 0 {
                    returns[monthly[i].date] = (curr - prev) / prev
                }
            }
            sourceReturns[source.id] = returns
        }

        // Simulate portfolio
        var simulated = 100.0
        var simulatedSeries: [(date: Date, value: Double)] = []
        if let firstMonth = allMonths.first {
            simulatedSeries.append((date: firstMonth, value: simulated))
        }
        for i in 1..<allMonths.count {
            let month = allMonths[i]
            var portfolioReturn = 0.0
            for source in sources {
                let weight = normalizedWeights[source.id] ?? 0
                let ret = sourceReturns[source.id]?[month] ?? 0
                portfolioReturn += weight * ret
            }
            simulated *= (1 + portfolioReturn)
            simulatedSeries.append((date: month, value: simulated))
        }

        simulatorData = simulatedSeries
    }

    // MARK: - Period Comparison Chart Calculation

    func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
        func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
            // Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes,
            // por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one).
            // Cota superior exclusiva al inicio del mes siguiente → incluye todo el último mes (simétrico A/B).
            let rangeStart = start.startOfMonth
            let rangeEnd = end.startOfMonth.adding(months: 1)
            let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd }
            guard !periodSnapshots.isEmpty else { return nil }

            // Group by month, compute portfolio total.
            // Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango).
            // chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para
            // snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer
            // el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B).
            let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
                Calendar.current.dateComponents([.year, .month], from: snap.date)
            }
            var monthlyTotalsArr: [(date: Date, value: Double)] = []
            for (key, snaps) in grouped {
                var latestBySource: [UUID: Snapshot] = [:]
                for snap in snaps {
                    guard let sourceId = snap.source?.id else { continue }
                    if let existing = latestBySource[sourceId] {
                        if snap.date > existing.date { latestBySource[sourceId] = snap }
                    } else {
                        latestBySource[sourceId] = snap
                    }
                }
                let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue }
                let date = Calendar.current.date(from: key) ?? Date()
                monthlyTotalsArr.append((date: date, value: total))
            }
            monthlyTotalsArr.sort { $0.date < $1.date }
            guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }

            let points = monthlyTotalsArr.enumerated().map { index, item in
                (monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0)
            }
            return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points)
        }

        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "MMM yyyy"

        let labelA = "\(dateFormatter.string(from: periodAStart))–\(dateFormatter.string(from: periodAEnd))"
        let labelB = "\(dateFormatter.string(from: periodBStart))–\(dateFormatter.string(from: periodBEnd))"

        var result: [PeriodSeries] = []
        if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd,
                                          label: labelA, colorHex: "#3478F6", id: "A") {
            result.append(seriesA)
        }
        if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd,
                                          label: labelB, colorHex: "#FF9500", id: "B") {
            result.append(seriesB)
        }
        periodComparisonData = result
    }

    /// Portfolio-level KPIs computed over the AGGREGATED monthly totals.
    /// Feeding raw multi-source snapshots into CalculationService.calculateMetrics
    /// produced nonsense (first/last belong to different sources) — the aggregate
    /// series is the correct basis for portfolio return, CAGR, volatility and drawdown.
    private func computePortfolioKPIs(from snapshots: [Snapshot]) -> InvestmentMetrics {
        let totals = monthlyTotals(from: snapshots)
        guard totals.count >= 2,
              let first = totals.first, let last = totals.last,
              first.totalValue > 0 else {
            let value = totals.last?.totalValue ?? 0
            return InvestmentMetrics(
                totalValue: value, totalContributions: 0,
                absoluteReturn: 0, percentageReturn: 0,
                cagr: 0, twr: 0, volatility: 0, maxDrawdown: 0, sharpeRatio: 0,
                bestMonth: nil, worstMonth: nil, winRate: 0, averageMonthlyReturn: 0,
                startDate: totals.first?.date, endDate: totals.last?.date,
                totalMonths: totals.count
            )
        }

        let absolute = last.totalValue - first.totalValue
        let percentage = (absolute / first.totalValue) * 100
        let cagr = calculationService.calculateCAGR(
            startValue: first.totalValue, endValue: last.totalValue,
            startDate: first.date, endDate: last.date
        )

        // Monthly returns over the aggregate series
        var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
        for i in 1..<totals.count where totals[i - 1].totalValue > 0 {
            let r = NSDecimalNumber(
                decimal: (totals[i].totalValue - totals[i - 1].totalValue) / totals[i - 1].totalValue
            ).doubleValue * 100
            monthlyReturns.append(.init(date: totals[i].date, returnPercentage: r))
        }
        let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
        let maxDrawdown = calculationService.calculateMaxDrawdown(values: totals.map { $0.totalValue })
        let avgMonthly = monthlyReturns.isEmpty
            ? 0 : monthlyReturns.map { $0.returnPercentage }.reduce(0, +) / Double(monthlyReturns.count)
        let sharpe = calculationService.calculateSharpeRatio(
            averageReturn: avgMonthly * 12, volatility: volatility, riskFreeRate: 2.0
        )

        return InvestmentMetrics(
            totalValue: last.totalValue,
            totalContributions: 0,
            absoluteReturn: absolute,
            percentageReturn: percentage,
            cagr: cagr, twr: 0,
            volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpe,
            bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
            worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
            winRate: calculationService.calculateWinRate(monthlyReturns: monthlyReturns),
            averageMonthlyReturn: avgMonthly,
            startDate: first.date, endDate: last.date,
            totalMonths: totals.count
        )
    }

    private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
        let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
            chartMonthStart(for: snapshot.date)
        }

        let months = groupedByMonth.keys.sorted()
        guard !months.isEmpty else { return [] }

        var latestBySource: [UUID: Snapshot] = [:]
        var totals: [(date: Date, totalValue: Decimal)] = []

        for month in months {
            if let monthSnapshots = groupedByMonth[month] {
                for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) {
                    guard let sourceId = snapshot.source?.id else { continue }
                    latestBySource[sourceId] = snapshot
                }
            }

            let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
            totals.append((date: month, totalValue: total))
        }

        return totals
    }

    private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
        let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
        let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
            chartMonth(for: snapshot.date)
        }

        var totals: [(date: Date, totalValue: Decimal)] = []
        totals.reserveCapacity(groupedByMonth.count)

        for (key, monthSnapshots) in groupedByMonth {
            var latestBySource: [UUID: Snapshot] = [:]
            for snapshot in monthSnapshots {
                guard let sourceId = snapshot.source?.id else { continue }
                if let existing = latestBySource[sourceId] {
                    if snapshot.date > existing.date {
                        latestBySource[sourceId] = snapshot
                    }
                } else {
                    latestBySource[sourceId] = snapshot
                }
            }

            let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
            let date = Calendar.current.date(from: key) ?? Date()
            totals.append((date: date, totalValue: total))
        }

        return totals.sorted { $0.date < $1.date }
    }

    private func monthlyReturnSeries(
        from monthlyTotals: [(date: Date, totalValue: Decimal)]
    ) -> [InvestmentMetrics.MonthlyReturn] {
        guard monthlyTotals.count >= 2 else { return [] }

        var returns: [InvestmentMetrics.MonthlyReturn] = []
        returns.reserveCapacity(monthlyTotals.count - 1)

        for index in 1..<monthlyTotals.count {
            let previous = monthlyTotals[index - 1]
            let current = monthlyTotals[index]
            guard previous.totalValue > 0 else { continue }
            let returnPercentage = NSDecimalNumber(
                decimal: (current.totalValue - previous.totalValue) / previous.totalValue
            ).doubleValue * 100
            returns.append(InvestmentMetrics.MonthlyReturn(
                date: current.date,
                returnPercentage: returnPercentage
            ))
        }

        return returns
    }

    func updatePredictionTargetDate(_ goals: [Goal]) {
        let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
        guard let latestGoalDate = futureGoalDates.max(),
              let lastSnapshotDate = evolutionData.last?.date else {
            predictionMonthsAhead = 12
            return
        }

        let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
        predictionMonthsAhead = max(12, months)
        if selectedChartType == .prediction {
            updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
        }
    }

    // MARK: - Computed Properties

    var categories: [Category] {
        categoryRepository.categories
    }

    var availableChartTypes: [ChartType] {
        ChartType.allCases
    }

    var isPremium: Bool {
        freemiumValidator.isPremium
    }

    var hasData: Bool {
        !sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
    }
}
