//
//  ItineraryReorderingLogic.swift
//  SportsTime
//
//  Pure functions for itinerary reordering logic.
//  Extracted from ItineraryTableViewController for testability.
//
//  SEMANTIC TRAVEL MODEL:
//  - Travel items are positioned semantically via (day, sortOrder), not structurally.
//  - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0).
//  - The legacy `travelBefore` field on ItineraryDayData is IGNORED by flattenDays.
//  - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader.
//
//  COORDINATE SPACE CONVENTIONS:
//  - "Original indices": Row indices in the current flatItems array (0..<flatItems.count)
//  - "Proposed indices": Row indices in post-removal array (UITableView move semantics)
//    After removing sourceRow, the array has count-1 elements. Insert positions are 0...count-1.
//  - simulateMove: Takes proposed index, returns post-move array + actual destination
//  - computeValidDestinationRowsProposed: Returns PROPOSED indices (for UITableView delegate)
//  - DragZones: invalidRowIndices and validDropRows are in ORIGINAL space (for UI highlighting)
//  - To convert: proposedToOriginal(proposed, sourceRow) and originalToProposed(original, sourceRow)
//

import Foundation

// MARK: - Pure Functions for Itinerary Reordering

/// Container for all pure reordering logic.
/// Using an enum (no cases) as a namespace for static functions.
enum ItineraryReorderingLogic {
    
    // MARK: - Row Flattening
    
    /// Default sortOrder for travel when lookup returns nil.
    /// Travel defaults to after-games region (positive value).
    private static let defaultTravelSortOrder: Double = 1.0
    
    /// Flattens hierarchical day data into a single array of row items.
    ///
    /// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely.
    /// Travel segments must be included in `day.items` with appropriate sortOrder.
    ///
    /// For each day, rows are added in this order:
    /// 1. Day header - "Day N · Date"
    /// 2. Items with sortOrder < 0 (before games), sorted by sortOrder ascending
    /// 3. Games - all games for this day (grouped as one row)
    /// 4. Items with sortOrder >= 0 (after games), sorted by sortOrder ascending
    ///
    /// - Parameters:
    ///   - days: Array of ItineraryDayData from the wrapper
    ///   - findTravelSortOrder: Closure to look up sortOrder for a travel segment
    /// - Returns: Flattened array of ItineraryRowItem
    static func flattenDays(
        _ days: [ItineraryDayData],
        findTravelSortOrder: (TravelSegment) -> Double?
    ) -> [ItineraryRowItem] {
        var flatItems: [ItineraryRowItem] = []
        
        for day in days {
            // NOTE: day.travelBefore is IGNORED under semantic travel model.
            // Travel must be in day.items with a sortOrder to appear.
            
            // 1. Day header (structural anchor)
            flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
            
            // 2. Partition movable items around games boundary
            // Tuple includes tiebreaker for stable sorting when sortOrders are equal
            var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
            var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
            var insertionOrder = 0
            
            for row in day.items {
                let sortOrder: Double
                let tiebreaker = insertionOrder
                insertionOrder += 1
                
                switch row {
                case .customItem(let item):
                    sortOrder = item.sortOrder
                    
                case .travel(let segment, _):
                    // Use provided sortOrder if available, otherwise default to after-games position.
                    // nil is valid during initial display before travel is persisted.
                    let lookedUp = findTravelSortOrder(segment)
                    sortOrder = lookedUp ?? defaultTravelSortOrder
                    print("📋 [flattenDays] Travel \(segment.fromLocation.name)->\(segment.toLocation.name) on day \(day.dayNumber): lookedUp=\(String(describing: lookedUp)), using sortOrder=\(sortOrder)")
                    
                case .games, .dayHeader:
                    // These item types are not movable and handled separately.
                    continue
                }
                
                if sortOrder < 0 {
                    beforeGames.append((sortOrder, tiebreaker, row))
                } else {
                    afterGames.append((sortOrder, tiebreaker, row))
                }
            }
            
            beforeGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
            afterGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
            
            flatItems.append(contentsOf: beforeGames.map { $0.item })
            
            // 3. Games for this day (bundled as one row)
            if !day.games.isEmpty {
                flatItems.append(.games(day.games, dayNumber: day.dayNumber))
            }
            
            // 4. Items after games
            flatItems.append(contentsOf: afterGames.map { $0.item })
        }
        
        return flatItems
    }
    
    
    // MARK: - Day Number Lookup
    
    /// Finds which day a row at the given index belongs to.
    ///
    /// Scans backwards from the row to find a `.dayHeader`.
    /// Returns 1 as fallback if no header is found.
    ///
    /// - Parameters:
    ///   - items: The flat array of row items
    ///   - row: The row index to look up
    /// - Returns: The day number (1-indexed)
    static func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int {
        guard !items.isEmpty else { return 1 }
        let clamped = min(max(0, row), items.count - 1)
        for i in stride(from: clamped, through: 0, by: -1) {
            if case .dayHeader(let dayNum, _) = items[i] {
                return dayNum
            }
        }
        return 1
    }
    
    /// Finds the row index of the day header for a specific day number.
    ///
    /// - Parameters:
    ///   - items: The flat array of row items
    ///   - day: The day number to find
    /// - Returns: The row index, or nil if not found
    static func dayHeaderRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
        for (index, item) in items.enumerated() {
            if case .dayHeader(let dayNum, _) = item, dayNum == day {
                return index
            }
        }
        return nil
    }
    
    /// Finds the row index of the travel segment on a specific day.
    ///
    /// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel().
    /// Instead, scans the day section (between dayHeader(day) and dayHeader(day+1))
    /// and returns the first travel row found.
    ///
    /// - Parameters:
    ///   - items: The flat array of row items
    ///   - day: The day number to find
    /// - Returns: The row index, or nil if no travel on that day
    static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
        guard let headerRow = dayHeaderRow(in: items, forDay: day) else {
            return nil
        }
        
        for i in (headerRow + 1)..<items.count {
            switch items[i] {
            case .dayHeader:
                return nil
            case .travel:
                return i
            default:
                continue
            }
        }
        return nil
    }
    
    
    /// Legacy version that uses embedded dayNumber (unreliable under semantic model).
    @available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup")
    static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
        for (index, item) in items.enumerated() {
            if case .travel(_, let dayNum) = item, dayNum == day {
                return index
            }
        }
        return nil
    }
    
    /// Determines which day a travel segment belongs to at a given row position.
    ///
    /// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader.
    /// This is consistent with how all movable items determine their day.
    ///
    /// - Parameters:
    ///   - row: The row index of the travel
    ///   - items: The flat array of row items
    /// - Returns: The day number the travel belongs to
    static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int {
        return dayNumber(in: items, forRow: row)
    }
    
    
    // MARK: - Move Simulation
    
    /// Result of simulating a move operation.
    struct SimulatedMove {
        let items: [ItineraryRowItem]
        let destinationRowInNewArray: Int
        let didMove: Bool  // false if move was invalid/no-op
    }
    
    /// Simulates UITableView move semantics with bounds safety.
    ///
    /// UITableView moves work as: remove at sourceRow from ORIGINAL array,
    /// then insert at destinationProposedRow in the NEW array (post-removal coordinate space).
    ///
    /// - Parameters:
    ///   - original: The original flat items array
    ///   - sourceRow: Where the item is being moved from
    ///   - destinationProposedRow: Where it's being moved to (in post-removal space)
    /// - Returns: The new array, the actual destination row, and whether the move occurred
    static func simulateMove(
        original: [ItineraryRowItem],
        sourceRow: Int,
        destinationProposedRow: Int
    ) -> SimulatedMove {
        guard sourceRow >= 0 && sourceRow < original.count else {
            return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false)
        }
        
        var items = original
        let moving = items.remove(at: sourceRow)
        let clampedDest = min(max(0, destinationProposedRow), items.count)
        items.insert(moving, at: clampedDest)
        return SimulatedMove(items: items, destinationRowInNewArray: clampedDest, didMove: true)
    }
    
    
    // MARK: - Coordinate Space Conversion
    
    /// Converts a proposed destination index to the equivalent original index.
    ///
    /// UITableView move semantics: remove at sourceRow first, then insert at proposed position.
    /// This means proposed indices >= sourceRow map to original indices + 1.
    ///
    /// - Parameters:
    ///   - proposed: Index in post-removal coordinate space
    ///   - sourceRow: The row being moved (in original space)
    /// - Returns: Equivalent index in original coordinate space
    static func proposedToOriginal(_ proposed: Int, sourceRow: Int) -> Int {
        if proposed >= sourceRow {
            return proposed + 1
        } else {
            return proposed
        }
    }
    
    /// Converts an original index to the equivalent proposed destination index.
    ///
    /// - Parameters:
    ///   - original: Index in original coordinate space
    ///   - sourceRow: The row being moved (in original space)
    /// - Returns: Equivalent index in post-removal coordinate space, or nil if original == sourceRow
    static func originalToProposed(_ original: Int, sourceRow: Int) -> Int? {
        if original == sourceRow {
            // The dragged item itself has no proposed equivalent
            return nil
        } else if original > sourceRow {
            return original - 1
        } else {
            return original
        }
    }
    
    // MARK: - Sort Order Calculation
    
    /// Calculates the sortOrder for an item dropped at the given row position.
    ///
    /// Uses **midpoint insertion** algorithm to avoid renumbering existing items:
    /// - Between items A(1.0) and B(2.0): new sortOrder = 1.5
    /// - First item in empty day: sortOrder = 1.0
    /// - After last item: sortOrder = last + 1.0
    /// - Before first item: sortOrder = first / 2.0
    ///
    /// **Region classification**:
    /// - `row < gamesRow` => before-games region => sortOrder < 0
    /// - `row > gamesRow` => after-games region => sortOrder >= 0
    /// - `row == gamesRow` => treated as after-games (cannot drop ON games row)
    /// - No games on day => after-games region (sortOrder >= 0)
    ///
    /// - Parameters:
    ///   - items: The flat array of row items (with moved item already in place)
    ///   - row: The row index where the item was dropped
    ///   - findTravelSortOrder: Closure to look up sortOrder for travel segments
    /// - Returns: The calculated sortOrder
    static func calculateSortOrder(
        in items: [ItineraryRowItem],
        at row: Int,
        findTravelSortOrder: (TravelSegment) -> Double?
    ) -> Double {
        let day = dayNumber(in: items, forRow: row)

        // Find games row for this day (if any)
        var gamesRow: Int? = nil
        for i in 0..<items.count {
            if case .games(_, let d) = items[i], d == day {
                gamesRow = i
                break
            }
            if case .dayHeader(let d, _) = items[i], d > day {
                break
            }
        }

        // DEBUG: Log the row positions
        print("🔢 [calculateSortOrder] row=\(row), day=\(day), gamesRow=\(String(describing: gamesRow))")
        print("🔢 [calculateSortOrder] items around row:")
        for i in max(0, row - 2)...min(items.count - 1, row + 2) {
            let marker = i == row ? "→" : " "
            let gMarker = (gamesRow == i) ? " [GAMES]" : ""
            print("🔢   \(marker) [\(i)] \(items[i])\(gMarker)")
        }

        // Strict region classification:
        // - row < gamesRow => before-games (negative sortOrder)
        // - row >= gamesRow OR no games => after-games (positive sortOrder)
        let isBeforeGames: Bool
        if let gr = gamesRow {
            isBeforeGames = row < gr
            print("🔢 [calculateSortOrder] row(\(row)) < gamesRow(\(gr)) = \(isBeforeGames) → isBeforeGames=\(isBeforeGames)")
        } else {
            isBeforeGames = false  // No games means everything is "after games"
            print("🔢 [calculateSortOrder] No games on day \(day) → isBeforeGames=false")
        }
        
        /// Get sortOrder from a movable item (custom item or travel)
        func movableSortOrder(_ idx: Int) -> Double? {
            guard idx >= 0 && idx < items.count else { return nil }
            switch items[idx] {
            case .customItem(let item):
                return item.sortOrder
            case .travel(let segment, _):
                return findTravelSortOrder(segment)
            default:
                return nil
            }
        }
        
        /// Scan backward from start, stopping at boundaries, looking for movable items in the same region
        func scanBackward(from start: Int) -> Double? {
            var i = start
            while i >= 0 {
                // Stop at day boundaries
                if case .dayHeader(let d, _) = items[i] {
                    if d != day { break }
                    break  // Stop at own day header too
                }
                // Stop at games boundary (don't cross into other region)
                if case .games(_, let d) = items[i], d == day { break }
                
                if let v = movableSortOrder(i) {
                    // Only return values in the correct region
                    if isBeforeGames {
                        if v < 0 { return v }
                    } else {
                        if v >= 0 { return v }
                    }
                }
                i -= 1
            }
            return nil
        }
        
        /// Scan forward from start, stopping at boundaries, looking for movable items in the same region
        func scanForward(from start: Int) -> Double? {
            var i = start
            while i < items.count {
                // Stop at day boundaries
                if case .dayHeader(let d, _) = items[i] {
                    if d != day { break }
                    break  // Stop at any day header
                }
                // Stop at games boundary (don't cross into other region)
                if case .games(_, let d) = items[i], d == day { break }
                
                if let v = movableSortOrder(i) {
                    // Only return values in the correct region
                    if isBeforeGames {
                        if v < 0 { return v }
                    } else {
                        if v >= 0 { return v }
                    }
                }
                i += 1
            }
            return nil
        }
        
        let result: Double
        if isBeforeGames {
            // Above games: sortOrder should be negative
            let prev = scanBackward(from: row - 1)
            let next = scanForward(from: row + 1)

            let upperBound: Double = 0.0  // Games boundary
            switch (prev, next) {
            case (nil, nil):
                result = -1.0
            case (let p?, nil):
                result = (p + upperBound) / 2.0
            case (nil, let n?):
                // First item before games: place it before the next item.
                // n should always be negative (scanForward filters for region).
                if n >= 0 {
                    // This shouldn't happen - scanForward should only return negative values
                    // in before-games region. Return safe default and assert in debug.
                    assertionFailure("Before-games region has non-negative sortOrder: \(n)")
                    result = -1.0
                } else {
                    // Place before n by subtracting 1.0 (simpler and more consistent than min(n/2, n-1))
                    result = n - 1.0
                }
            case (let p?, let n?):
                result = (p + n) / 2.0
            }
        } else {
            // Below games: sortOrder should be >= 0
            let prev = scanBackward(from: row - 1) ?? 0.0
            let next = scanForward(from: row + 1)

            switch next {
            case nil:
                result = (prev == 0.0) ? 1.0 : (prev + 1.0)
            case let n?:
                result = (prev + n) / 2.0
            }
        }

        print("🔢 [calculateSortOrder] RESULT: \(result) (isBeforeGames=\(isBeforeGames))")
        return result
    }
    
    // MARK: - Valid Drop Computation
    
    /// Computes all valid destination rows in **proposed** coordinate space.
    ///
    /// For BOTH travel and custom items, we:
    /// 1. Simulate the move
    /// 2. Compute the resulting (day, sortOrder)
    /// 3. Validate with ItineraryConstraints
    ///
    /// This ensures drop targets match what will actually be persisted.
    ///
    /// - Parameters:
    ///   - flatItems: The current flat items array
    ///   - sourceRow: The row being moved
    ///   - dragged: The item being dragged
    ///   - travelValidRanges: Valid day ranges for travel segments
    ///   - constraints: The constraint system for validation
    ///   - findTravelItem: Closure to find ItineraryItem for a travel segment
    ///   - makeTravelItem: Closure to create a default ItineraryItem for travel
    ///   - findCustomItem: Closure to find ItineraryItem for a custom item row
    ///   - findTravelSortOrder: Closure to find sortOrder for travel segments
    /// - Returns: Array of valid row indices in proposed coordinate space
    static func computeValidDestinationRowsProposed(
        flatItems: [ItineraryRowItem],
        sourceRow: Int,
        dragged: ItineraryRowItem,
        travelValidRanges: [String: ClosedRange<Int>],
        constraints: ItineraryConstraints?,
        findTravelItem: (TravelSegment) -> ItineraryItem?,
        makeTravelItem: (TravelSegment) -> ItineraryItem,
        findTravelSortOrder: @escaping (TravelSegment) -> Double?
    ) -> [Int] {
        let maxProposed = max(0, flatItems.count - 1)
        guard maxProposed > 0 else { return [] }
        
        switch dragged {
        case .customItem(let customItem):
            // Custom items use the same simulation+validation approach as travel
            guard let constraints = constraints else {
                // No constraint engine: allow all rows except 0 and day headers
                return (1...maxProposed).filter { proposedRow in
                    let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
                    guard simulated.didMove else { return false }
                    // Don't allow dropping ON a day header
                    if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] {
                        return false
                    }
                    return true
                }
            }
            
            var valid: [Int] = []
            valid.reserveCapacity(maxProposed)
            
            for proposedRow in 1...maxProposed {
                let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
                guard simulated.didMove else { continue }
                
                let destRowInSim = simulated.destinationRowInNewArray
                
                // Don't allow dropping ON a day header
                if case .dayHeader = simulated.items[destRowInSim] {
                    continue
                }
                
                let day = dayNumber(in: simulated.items, forRow: destRowInSim)
                let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
                
                // Create a temporary item model with the computed position
                let testItem = ItineraryItem(
                    id: customItem.id,
                    tripId: customItem.tripId,
                    day: day,
                    sortOrder: sortOrder,
                    kind: customItem.kind
                )
                
                if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
                    valid.append(proposedRow)
                }
            }
            
            return valid
            
        case .travel(let segment, _):
            // Use existing model if available, otherwise create a default
            let model = findTravelItem(segment) ?? makeTravelItem(segment)
            let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model)
            let validDayRange = travelValidRanges[travelId]
            
            guard let constraints = constraints else {
                // No constraint engine, allow all rows except 0 and day headers
                return (1...maxProposed).filter { proposedRow in
                    let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
                    guard simulated.didMove else { return false }
                    if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] {
                        return false
                    }
                    return true
                }
            }
            
            var valid: [Int] = []
            valid.reserveCapacity(maxProposed)
            
            for proposedRow in 1...maxProposed {
                let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
                guard simulated.didMove else { continue }
                
                let destRowInSim = simulated.destinationRowInNewArray
                
                // Don't allow dropping ON a day header
                if case .dayHeader = simulated.items[destRowInSim] {
                    continue
                }
                
                let day = dayNumber(in: simulated.items, forRow: destRowInSim)
                
                // Check day range constraint (quick rejection)
                if let range = validDayRange, !range.contains(day) {
                    continue
                }
                
                // Check sortOrder constraint
                let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
                
                // Create a testItem with computed day/sortOrder (like custom items do)
                // This ensures constraints.isValidPosition sees the actual proposed position
                let testItem = ItineraryItem(
                    id: model.id,
                    tripId: model.tripId,
                    day: day,
                    sortOrder: sortOrder,
                    kind: model.kind
                )
                
                if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
                    valid.append(proposedRow)
                }
            }
            
            return valid
            
        default:
            // Day headers and games can't be moved
            return []
        }
    }
    
    // MARK: - Drag Zones
    
    /// Result of calculating drag zones for visual feedback.
    ///
    /// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices).
    /// This is what the UI needs for highlighting rows before the move occurs.
    struct DragZones {
        /// Rows that should be dimmed/disabled in the UI (original indices)
        let invalidRowIndices: Set<Int>
        /// Rows where drop is allowed (original indices)
        let validDropRows: [Int]
        /// Game IDs that act as barriers for this drag
        let barrierGameIds: Set<String>
    }
    
    /// Calculates drag zones for a travel segment using simulation+validation.
    ///
    /// This ensures UI feedback matches what will actually be accepted on drop.
    /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting.
    ///
    /// - Parameters:
    ///   - segment: The travel segment being dragged
    ///   - sourceRow: The current row of the travel (original index)
    ///   - flatItems: The current flat items array
    ///   - travelValidRanges: Valid day ranges for travel segments
    ///   - constraints: The constraint system
    ///   - findTravelItem: Closure to find ItineraryItem for travel
    ///   - makeTravelItem: Closure to create a default ItineraryItem for travel
    ///   - findTravelSortOrder: Closure to find sortOrder for travel
    /// - Returns: Drag zones with invalid rows, valid rows, and barrier game IDs (all in original space)
    static func calculateTravelDragZones(
        segment: TravelSegment,
        sourceRow: Int,
        flatItems: [ItineraryRowItem],
        travelValidRanges: [String: ClosedRange<Int>],
        constraints: ItineraryConstraints?,
        findTravelItem: (TravelSegment) -> ItineraryItem?,
        makeTravelItem: (TravelSegment) -> ItineraryItem,
        findTravelSortOrder: @escaping (TravelSegment) -> Double?
    ) -> DragZones {
        // Get valid rows in PROPOSED coordinate space
        let validRowsProposed = computeValidDestinationRowsProposed(
            flatItems: flatItems,
            sourceRow: sourceRow,
            dragged: .travel(segment, dayNumber: 0),  // dayNumber doesn't matter for validation
            travelValidRanges: travelValidRanges,
            constraints: constraints,
            findTravelItem: findTravelItem,
            makeTravelItem: makeTravelItem,
            findTravelSortOrder: findTravelSortOrder
        )
        
        // Convert valid rows from proposed to original coordinate space
        let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
        let validSet = Set(validRowsOriginal)
        
        // Compute invalid rows in original coordinate space
        var invalidRows = Set<Int>()
        for i in 0..<flatItems.count {
            if i == sourceRow {
                // The source row itself is neither valid nor invalid - it's being dragged
                continue
            }
            if !validSet.contains(i) {
                invalidRows.insert(i)
            }
        }
        
        // Find barrier games using constraints
        var barrierGameIds = Set<String>()
        if let travelItem = findTravelItem(segment),
           let constraints = constraints {
            let barriers = constraints.barrierGames(for: travelItem)
            barrierGameIds = Set(barriers.compactMap { $0.gameId })
        }
        
        return DragZones(
            invalidRowIndices: invalidRows,
            validDropRows: validRowsOriginal,
            barrierGameIds: barrierGameIds
        )
    }
    
    /// Calculates drag zones for a custom item using simulation+validation.
    ///
    /// This ensures UI feedback matches what will actually be accepted on drop.
    /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting.
    ///
    /// - Parameters:
    ///   - item: The custom item being dragged
    ///   - sourceRow: The current row of the item (original index)
    ///   - flatItems: The current flat items array
    ///   - constraints: The constraint system
    ///   - findTravelSortOrder: Closure to find sortOrder for travel
    /// - Returns: Drag zones with invalid rows and valid rows (all in original space)
    static func calculateCustomItemDragZones(
        item: ItineraryItem,
        sourceRow: Int,
        flatItems: [ItineraryRowItem],
        constraints: ItineraryConstraints?,
        findTravelSortOrder: @escaping (TravelSegment) -> Double?
    ) -> DragZones {
        // Get valid rows in PROPOSED coordinate space
        let validRowsProposed = computeValidDestinationRowsProposed(
            flatItems: flatItems,
            sourceRow: sourceRow,
            dragged: .customItem(item),
            travelValidRanges: [:],  // Custom items don't use travel ranges
            constraints: constraints,
            findTravelItem: { _ in nil },
            makeTravelItem: { _ in
                // This won't be called for custom items
                fatalError("makeTravelItem called for custom item")
            },
            findTravelSortOrder: findTravelSortOrder
        )
        
        // Convert valid rows from proposed to original coordinate space
        let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
        let validSet = Set(validRowsOriginal)
        
        // Compute invalid rows in original coordinate space
        var invalidRows = Set<Int>()
        for i in 0..<flatItems.count {
            if i == sourceRow {
                // The source row itself is neither valid nor invalid - it's being dragged
                continue
            }
            if !validSet.contains(i) {
                invalidRows.insert(i)
            }
        }
        
        return DragZones(
            invalidRowIndices: invalidRows,
            validDropRows: validRowsOriginal,
            barrierGameIds: []  // No barrier highlighting for custom items
        )
    }
    
    // MARK: - Legacy Compatibility
    
    /// Legacy version of calculateTravelDragZones that doesn't require sourceRow.
    /// Uses day-range-based calculation only.
    ///
    /// - Note: Prefer the version with sourceRow for accurate validation.
    @available(*, deprecated, message: "Use calculateTravelDragZones(segment:sourceRow:...) for accurate validation")
    static func calculateTravelDragZones(
        segment: TravelSegment,
        flatItems: [ItineraryRowItem],
        travelValidRanges: [String: ClosedRange<Int>],
        constraints: ItineraryConstraints?,
        findTravelItem: (TravelSegment) -> ItineraryItem?
    ) -> DragZones {
        let model = findTravelItem(segment)
        let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model)

        guard let validRange = travelValidRanges[travelId] else {
            return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: [])
        }

        var invalidRows = Set<Int>()
        var validRows: [Int] = []

        for (index, rowItem) in flatItems.enumerated() {
            let dayNum: Int
            switch rowItem {
            case .dayHeader(let d, _):
                dayNum = d
            case .games(_, let d):
                dayNum = d
            case .travel(_, let d):
                dayNum = d
            case .customItem(let item):
                dayNum = item.day
            }
            
            if validRange.contains(dayNum) {
                validRows.append(index)
            } else {
                invalidRows.insert(index)
            }
        }
        
        // Find barrier games using constraints
        var barrierGameIds = Set<String>()
        if let travelItem = findTravelItem(segment),
           let constraints = constraints {
            let barriers = constraints.barrierGames(for: travelItem)
            barrierGameIds = Set(barriers.compactMap { $0.gameId })
        }
        
        return DragZones(
            invalidRowIndices: invalidRows,
            validDropRows: validRows,
            barrierGameIds: barrierGameIds
        )
    }
    
    /// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow.
    ///
    /// - Note: Prefer the version with sourceRow for accurate validation.
    @available(*, deprecated, message: "Use calculateCustomItemDragZones(item:sourceRow:...) for accurate validation")
    static func calculateCustomItemDragZones(
        item: ItineraryItem,
        flatItems: [ItineraryRowItem]
    ) -> DragZones {
        var invalidRows = Set<Int>()
        var validRows: [Int] = []
        
        for (index, rowItem) in flatItems.enumerated() {
            if case .dayHeader = rowItem {
                invalidRows.insert(index)
            } else {
                validRows.append(index)
            }
        }
        
        return DragZones(
            invalidRowIndices: invalidRows,
            validDropRows: validRows,
            barrierGameIds: []
        )
    }
    
    // MARK: - Travel ID Lookup

    /// Find the travel ID key for a segment in the travelValidRanges dictionary.
    /// Keys are formatted as "travel:INDEX:from->to".
    /// When multiple keys share the same city pair (repeat visits), matches by
    /// checking all keys and preferring the one whose index matches the model's segmentIndex.
    private static func travelIdForSegment(
        _ segment: TravelSegment,
        in travelValidRanges: [String: ClosedRange<Int>],
        model: ItineraryItem? = nil
    ) -> String {
        let suffix = "\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
        let matchingKeys = travelValidRanges.keys.filter { $0.hasSuffix(suffix) }

        if matchingKeys.count == 1, let key = matchingKeys.first {
            return key
        }

        // Multiple matches (repeat city pair) — use segmentIndex from model to disambiguate
        if let segIdx = model?.travelInfo?.segmentIndex {
            let expected = "travel:\(segIdx):\(suffix)"
            if matchingKeys.contains(expected) {
                return expected
            }
        }

        // Fallback: return first match or construct without index
        return matchingKeys.first ?? "travel:\(suffix)"
    }

    // MARK: - Utility Functions
    
    /// Finds the nearest value in a sorted array using binary search.
    ///
    /// - Parameters:
    ///   - sorted: A sorted array of integers
    ///   - target: The target value to find the nearest match for
    /// - Returns: The nearest value, or nil if array is empty
    static func nearestValue(in sorted: [Int], to target: Int) -> Int? {
        guard !sorted.isEmpty else { return nil }
        
        var low = 0
        var high = sorted.count
        
        // Binary search for insertion point
        while low < high {
            let mid = (low + high) / 2
            if sorted[mid] < target {
                low = mid + 1
            } else {
                high = mid
            }
        }
        
        let after = (low < sorted.count) ? sorted[low] : nil
        let before = (low > 0) ? sorted[low - 1] : nil
        
        switch (before, after) {
        case let (b?, a?):
            // Both exist, return the closer one
            return (target - b) <= (a - target) ? b : a
        case let (b?, nil):
            return b
        case let (nil, a?):
            return a
        default:
            return nil
        }
    }
    
    /// Calculates target destination with constraint snapping.
    ///
    /// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row.
    ///
    /// **COORDINATE SPACE**: This function expects all indices in PROPOSED coordinate space.
    /// The caller must ensure validDestinationRows comes from computeValidDestinationRowsProposed.
    ///
    /// **UX RULE**: Row 0 is forbidden (always a day header). If proposedRow <= 0, it's clamped to 1.
    /// This is a UX-level rule, not a semantic constraint - day headers cannot receive drops.
    ///
    /// - Parameters:
    ///   - proposedRow: The user's proposed drop position (in proposed coordinate space)
    ///   - validDestinationRows: Pre-computed valid rows from computeValidDestinationRowsProposed
    ///   - sourceRow: The original row (fallback if no valid destination found)
    /// - Returns: The target row to use (in proposed coordinate space)
    ///
    /// - Note: Uses O(n) contains check. For repeated calls, consider passing a Set instead.
    ///         However, validDestinationRows is typically small (< 50 items), so this is fine.
    static func calculateTargetRow(
        proposedRow: Int,
        validDestinationRows: [Int],
        sourceRow: Int
    ) -> Int {
        // UX rule: forbid dropping at absolute top (row 0 is always a day header)
        var row = proposedRow
        if row <= 0 { row = 1 }
        
        // If already valid, use it
        if validDestinationRows.contains(row) {
            return row
        }
        
        // Snap to nearest valid destination (validDestinationRows must be sorted for binary search)
        return nearestValue(in: validDestinationRows, to: row) ?? sourceRow
    }
}
