//
//  ItineraryReorderingLogicTests.swift
//  SportsTimeTests
//
//  Comprehensive tests for ItineraryReorderingLogic pure functions.
//  These tests exercise all the business logic without UIKit dependencies.
//

import XCTest
@testable import SportsTime

private typealias H = ItineraryTestHelpers
private typealias Logic = ItineraryReorderingLogic

final class ItineraryReorderingLogicTests: XCTestCase {

    private let testDate = H.testDate
    private let testTripId = H.testTripId

    // MARK: - Test Data Builders

    /// Creates a flat items array from a simple DSL.
    /// Format: [.day(1), .game("Detroit"), .custom("A", 1.0), .travel("Chi", "Det"), ...]
    private func buildFlatItems(_ elements: [TestElement]) -> [ItineraryRowItem] {
        var items: [ItineraryRowItem] = []
        for element in elements {
            switch element {
            case .day(let num):
                let date = Calendar.current.date(byAdding: .day, value: num - 1, to: testDate)!
                items.append(.dayHeader(dayNumber: num, date: date))

            case .game(let city, let day):
                let game = H.makeRichGame(city: city, hour: 19, baseDate: testDate)
                items.append(.games([game], dayNumber: day))

            case .custom(let title, let sortOrder, let day):
                let item = H.makeCustomItem(day: day, sortOrder: sortOrder, title: title)
                items.append(.customItem(item))

            case .travel(let from, let to, let day):
                let segment = H.makeTravelSegment(from: from, to: to)
                items.append(.travel(segment, dayNumber: day))
            }
        }
        return items
    }

    private enum TestElement {
        case day(Int)
        case game(String, day: Int)
        case custom(String, sortOrder: Double, day: Int)
        case travel(from: String, to: String, day: Int)
    }

    // MARK: - nearestValue Tests

    func test_nearestValue_emptyArray_returnsNil() {
        let result = Logic.nearestValue(in: [], to: 5)
        XCTAssertNil(result)
    }

    func test_nearestValue_singleElement_returnsThatElement() {
        XCTAssertEqual(Logic.nearestValue(in: [3], to: 1), 3)
        XCTAssertEqual(Logic.nearestValue(in: [3], to: 5), 3)
        XCTAssertEqual(Logic.nearestValue(in: [3], to: 3), 3)
    }

    func test_nearestValue_exactMatch_returnsExact() {
        let sorted = [1, 3, 5, 7, 9]
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 5), 5)
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 1), 1)
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 9), 9)
    }

    func test_nearestValue_betweenValues_returnsCloser() {
        let sorted = [1, 5, 10, 20]
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 3), 1) // 3 is closer to 1 than 5
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 4), 5) // 4 is closer to 5 than 1
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 7), 5) // 7 is closer to 5 than 10
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 8), 10) // 8 is closer to 10 than 5
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 15), 10) // 15 is closer to 10 than 20
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 16), 20) // 16 is closer to 20 than 10
    }

    func test_nearestValue_belowMin_returnsMin() {
        let sorted = [5, 10, 15]
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 0), 5)
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: -100), 5)
    }

    func test_nearestValue_aboveMax_returnsMax() {
        let sorted = [5, 10, 15]
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 20), 15)
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 100), 15)
    }

    func test_nearestValue_tieBreaker_prefersLower() {
        // When equidistant, should prefer the lower value
        let sorted = [1, 5]
        XCTAssertEqual(Logic.nearestValue(in: sorted, to: 3), 1) // 3 is equidistant from 1 and 5
    }

    // MARK: - simulateMove Tests

    func test_simulateMove_moveForward() {
        // [A, B, C, D] -> Move A to position 2 -> [B, C, A, D]
        let items = buildFlatItems([.day(1), .day(2), .day(3), .day(4)])

        let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 2)

        XCTAssertEqual(result.items.count, 4)
        XCTAssertEqual(result.destinationRowInNewArray, 2)

        // After removing item at 0, array is [B, C, D] (indices 0, 1, 2)
        // Insert at 2 gives [B, C, A, D]
        if case .dayHeader(let day1, _) = result.items[0] { XCTAssertEqual(day1, 2) }
        if case .dayHeader(let day2, _) = result.items[1] { XCTAssertEqual(day2, 3) }
        if case .dayHeader(let day3, _) = result.items[2] { XCTAssertEqual(day3, 1) } // Moved item
        if case .dayHeader(let day4, _) = result.items[3] { XCTAssertEqual(day4, 4) }
    }

    func test_simulateMove_moveBackward() {
        // [A, B, C, D] -> Move D to position 1 -> [A, D, B, C]
        let items = buildFlatItems([.day(1), .day(2), .day(3), .day(4)])

        let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 1)

        XCTAssertEqual(result.items.count, 4)
        XCTAssertEqual(result.destinationRowInNewArray, 1)

        if case .dayHeader(let day1, _) = result.items[0] { XCTAssertEqual(day1, 1) }
        if case .dayHeader(let day2, _) = result.items[1] { XCTAssertEqual(day2, 4) } // Moved item
        if case .dayHeader(let day3, _) = result.items[2] { XCTAssertEqual(day3, 2) }
        if case .dayHeader(let day4, _) = result.items[3] { XCTAssertEqual(day4, 3) }
    }

    func test_simulateMove_moveToEnd() {
        // [A, B, C] -> Move A to end -> [B, C, A]
        let items = buildFlatItems([.day(1), .day(2), .day(3)])

        let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 2)

        XCTAssertEqual(result.destinationRowInNewArray, 2)
        if case .dayHeader(let day, _) = result.items[2] { XCTAssertEqual(day, 1) }
    }

    func test_simulateMove_moveToStart() {
        // [A, B, C] -> Move C to start -> [C, A, B]
        let items = buildFlatItems([.day(1), .day(2), .day(3)])

        let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 0)

        XCTAssertEqual(result.destinationRowInNewArray, 0)
        if case .dayHeader(let day, _) = result.items[0] { XCTAssertEqual(day, 3) }
    }

    func test_simulateMove_samePosition() {
        // [A, B, C] -> Move B to same position -> [A, B, C]
        let items = buildFlatItems([.day(1), .day(2), .day(3)])

        let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 1)

        // After remove at 1: [A, C], insert at 1: [A, B, C]
        XCTAssertEqual(result.destinationRowInNewArray, 1)
        if case .dayHeader(let day, _) = result.items[1] { XCTAssertEqual(day, 2) }
    }

    // MARK: - dayNumber Tests

    func test_dayNumber_rowOnHeader_returnsThatDay() {
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .day(2),
            .game("Chicago", day: 2)
        ])

        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 0), 1) // Day 1 header
        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 2) // Day 2 header
    }

    func test_dayNumber_rowAfterHeader_returnsThatDay() {
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .custom("Lunch", sortOrder: 1.0, day: 1),
            .day(2),
            .game("Chicago", day: 2)
        ])

        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 1), 1) // Game on day 1
        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 1) // Custom on day 1
        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 4), 2) // Game on day 2
    }

    func test_dayNumber_travelBeforeHeader_returnsThatDay() {
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .travel(from: "Detroit", to: "Chicago", day: 2),
            .day(2),
            .game("Chicago", day: 2)
        ])

        // Travel at row 2 should return day 1 (scans backward, finds Day 1 header)
        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 2), 1)
    }

    func test_dayNumber_emptyArray_returnsDefault() {
        XCTAssertEqual(Logic.dayNumber(in: [], forRow: 0), 1)
        XCTAssertEqual(Logic.dayNumber(in: [], forRow: 5), 1)
    }

    func test_dayNumber_outOfBounds_clampsAndReturns() {
        let items = buildFlatItems([.day(1), .day(2)])

        // Out of bounds high should clamp and return day 2
        XCTAssertEqual(Logic.dayNumber(in: items, forRow: 100), 2)
    }

    // MARK: - dayHeaderRow Tests

    func test_dayHeaderRow_findsCorrectRow() {
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .day(2),
            .game("Chicago", day: 2),
            .day(3)
        ])

        XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 1), 0)
        XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 2), 2)
        XCTAssertEqual(Logic.dayHeaderRow(in: items, forDay: 3), 4)
    }

    func test_dayHeaderRow_dayNotFound_returnsNil() {
        let items = buildFlatItems([.day(1), .day(2)])

        XCTAssertNil(Logic.dayHeaderRow(in: items, forDay: 5))
    }

    // MARK: - travelRow Tests

    func test_travelRow_findsCorrectRow() {
        // Semantic model: travelRow finds travel in the section AFTER the day header
        // Travel must be positioned within its correct day section
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .day(2),
            .travel(from: "Detroit", to: "Chicago", day: 2),  // Row 3: in day 2 section
            .day(3),
            .travel(from: "Chicago", to: "Milwaukee", day: 3)  // Row 5: in day 3 section
        ])

        XCTAssertEqual(Logic.travelRow(in: items, forDay: 2), 3)
        XCTAssertEqual(Logic.travelRow(in: items, forDay: 3), 5)
    }

    func test_travelRow_noTravelOnDay_returnsNil() {
        // Travel is in day 2 section, so day 1 has no travel
        let items = buildFlatItems([
            .day(1),
            .day(2),
            .travel(from: "Detroit", to: "Chicago", day: 2)  // In day 2 section
        ])

        XCTAssertNil(Logic.travelRow(in: items, forDay: 1))
    }

    // MARK: - dayForTravelAt Tests

    func test_dayForTravelAt_usesBackwardScan() {
        // Semantic model: travel belongs to the day of the nearest preceding header
        let items = buildFlatItems([
            .day(1),
            .travel(from: "Detroit", to: "Chicago", day: 2),  // Row 1
            .day(2),
            .travel(from: "Chicago", to: "Milwaukee", day: 3),  // Row 3
            .day(3)
        ])

        // Travel at row 1 finds Day 1 header at row 0 (backward scan)
        XCTAssertEqual(Logic.dayForTravelAt(row: 1, in: items), 1)
        // Travel at row 3 finds Day 2 header at row 2 (backward scan)
        XCTAssertEqual(Logic.dayForTravelAt(row: 3, in: items), 2)
    }

    func test_dayForTravelAt_lastItem_fallsBackToLastDay() {
        let items = buildFlatItems([
            .day(1),
            .day(2),
            .travel(from: "Detroit", to: "Chicago", day: 2)  // Travel at end
        ])

        // No header after travel, should fallback scan backward
        XCTAssertEqual(Logic.dayForTravelAt(row: 2, in: items), 2)
    }

    // MARK: - calculateSortOrder Tests (Midpoint Algorithm)

    func test_calculateSortOrder_emptyDay_returns1() {
        // Day with only header, no items
        let items = buildFlatItems([
            .day(1),
            .day(2)
        ])

        // Simulating drop right after day 1 header (row 0)
        // After inserting at row 1, day 1 has no other items
        let mockItems = buildFlatItems([
            .day(1),
            .custom("New", sortOrder: 999, day: 1), // Placeholder for dropped item
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: mockItems, at: 1) { _ in nil }
        XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01)
    }

    func test_calculateSortOrder_betweenTwoItems_returnsMidpoint() {
        // Items at 1.0 and 3.0, drop between them should get 2.0
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .custom("New", sortOrder: 999, day: 1), // Dropped item at row 2
            .custom("B", sortOrder: 3.0, day: 1),
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
        XCTAssertEqual(sortOrder, 2.0, accuracy: 0.01)
    }

    func test_calculateSortOrder_afterLastItem_returnsLastPlusOne() {
        // Last item at 3.0, drop after should get 4.0
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .custom("B", sortOrder: 3.0, day: 1),
            .custom("New", sortOrder: 999, day: 1), // Dropped at end
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: items, at: 3) { _ in nil }
        XCTAssertEqual(sortOrder, 4.0, accuracy: 0.01)
    }

    func test_calculateSortOrder_beforeFirstItem_returnsHalf() {
        // First item at 2.0, drop before should get 1.0 (2.0 / 2)
        let items = buildFlatItems([
            .day(1),
            .custom("New", sortOrder: 999, day: 1), // Dropped first
            .custom("A", sortOrder: 2.0, day: 1),
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
        XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01) // 2.0 / 2 = 1.0
    }

    func test_calculateSortOrder_manyMidpoints_maintainsPrecision() {
        // After many insertions, values should still be distinct
        var sortOrders: [Double] = [1.0, 2.0]

        for _ in 0..<30 {
            let midpoint = (sortOrders[0] + sortOrders[1]) / 2.0
            sortOrders.insert(midpoint, at: 1)
        }

        // All values should be distinct
        let uniqueCount = Set(sortOrders).count
        XCTAssertEqual(uniqueCount, sortOrders.count, "All sort orders should be unique")

        // All should be properly ordered
        for i in 0..<(sortOrders.count - 1) {
            XCTAssertLessThan(sortOrders[i], sortOrders[i + 1])
        }
    }

    func test_calculateSortOrder_beforeGames_negativeValue() {
        // Item dropped before games should get negative sortOrder
        let items = buildFlatItems([
            .day(1),
            .custom("New", sortOrder: 999, day: 1), // Dropped before games
            .game("Detroit", day: 1),
            .custom("After", sortOrder: 1.0, day: 1),
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
        XCTAssertLessThan(sortOrder, 0, "Item before games should have negative sortOrder")
    }

    func test_calculateSortOrder_afterGames_positiveValue() {
        // Item dropped after games should get positive sortOrder
        let items = buildFlatItems([
            .day(1),
            .game("Detroit", day: 1),
            .custom("New", sortOrder: 999, day: 1), // Dropped after games
            .day(2)
        ])

        let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
        XCTAssertGreaterThan(sortOrder, 0, "Item after games should have positive sortOrder")
    }

    // MARK: - calculateTargetRow Tests

    func test_calculateTargetRow_validRow_returnsProposed() {
        let validRows = [1, 2, 3, 4, 5]
        let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: validRows, sourceRow: 1)
        XCTAssertEqual(result, 3)
    }

    func test_calculateTargetRow_invalidRow_snapsToNearest() {
        let validRows = [2, 4, 6]
        let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: validRows, sourceRow: 1)
        XCTAssertEqual(result, 2) // 3 is closer to 2 than 4
    }

    func test_calculateTargetRow_row0_clampedTo1() {
        let validRows = [1, 2, 3]
        let result = Logic.calculateTargetRow(proposedRow: 0, validDestinationRows: validRows, sourceRow: 2)
        XCTAssertEqual(result, 1)
    }

    func test_calculateTargetRow_noValidRows_returnsSource() {
        let result = Logic.calculateTargetRow(proposedRow: 3, validDestinationRows: [], sourceRow: 5)
        XCTAssertEqual(result, 5)
    }

    // MARK: - flattenDays Tests

    func test_flattenDays_emptyDays_returnsEmpty() {
        let result = Logic.flattenDays([], findTravelSortOrder: { _ in nil })
        XCTAssertTrue(result.isEmpty)
    }

    func test_flattenDays_singleEmptyDay_returnsHeaderOnly() {
        let days = [
            ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
        ]

        let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })

        XCTAssertEqual(result.count, 1)
        if case .dayHeader(let day, _) = result[0] {
            XCTAssertEqual(day, 1)
        } else {
            XCTFail("Expected dayHeader")
        }
    }

    func test_flattenDays_dayWithGames_correctOrder() {
        let games = [H.makeRichGame(city: "Detroit", hour: 19)]
        let days = [
            ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil)
        ]

        let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })

        XCTAssertEqual(result.count, 2)
        XCTAssertTrue(result[0].id.starts(with: "day:"))
        XCTAssertTrue(result[1].id.starts(with: "games:"))
    }

    func test_flattenDays_travelBeforeIsIgnored() {
        // Semantic model: travelBefore is IGNORED - travel must be in items to appear
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
        let days = [
            ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil),
            ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel)
        ]

        let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })

        // travelBefore is ignored, so only headers appear
        // Day 1: header
        // Day 2: header (no travel)
        XCTAssertEqual(result.count, 2, "travelBefore should be ignored - only headers should appear")
        XCTAssertTrue(result[0].id.starts(with: "day:1"))
        XCTAssertTrue(result[1].id.starts(with: "day:2"))
    }

    func test_flattenDays_itemsPartitionedAroundGames() {
        // Items with negative sortOrder go before games, positive after
        let games = [H.makeRichGame(city: "Detroit", hour: 19)]
        let beforeItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: -1.0, kind: .custom(CustomInfo(title: "Before", icon: "🌅")))
        let afterItem = ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .custom(CustomInfo(title: "After", icon: "🌙")))

        let days = [
            ItineraryDayData(
                id: 1,
                dayNumber: 1,
                date: testDate,
                games: games,
                items: [.customItem(beforeItem), .customItem(afterItem)],
                travelBefore: nil
            )
        ]

        let result = Logic.flattenDays(days, findTravelSortOrder: { _ in nil })

        // Expected order: header, beforeItem, games, afterItem
        XCTAssertEqual(result.count, 4)
        XCTAssertTrue(result[0].id.starts(with: "day:"))
        XCTAssertTrue(result[1].id.contains("Before") || result[1].id.starts(with: "item:"))
        XCTAssertTrue(result[2].id.starts(with: "games:"))
        XCTAssertTrue(result[3].id.contains("After") || result[3].id.starts(with: "item:"))
    }

    // MARK: - Complex Move Scenarios (Travel Constraints)

    /// Scenario: [day3][gameA][day4][travel a->b][day5][day6][gameB]
    /// Travel can only move within valid day range based on game constraints
    func test_travelMove_constrainedByGames() {
        // Setup: Game A on day 3 (departure city), Game B on day 6 (arrival city)
        // Travel A->B valid range: days 3-6 (after game A, before game B)

        let items = buildFlatItems([
            .day(3),
            .game("CityA", day: 3),
            .day(4),
            .travel(from: "CityA", to: "CityB", day: 4),
            .day(5),
            .day(6),
            .game("CityB", day: 6)
        ])

        // Original: [day3(0), gameA(1), day4(2), travel(3), day5(4), day6(5), gameB(6)]
        XCTAssertEqual(items.count, 7)

        // After removing travel at row 3: [day3, gameA, day4, day5, day6, gameB]
        // with day5 at index 3, day6 at index 4
        // To insert between day5 and day6 headers, use proposedRow = 4
        let moveResult = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 4)

        // Travel should now be between day 5 and day 6 headers (at index 4)
        XCTAssertEqual(moveResult.destinationRowInNewArray, 4)
        if case .travel = moveResult.items[4] {
            // dayNumber scans backward - travel at row 4 will find day5 header at row 3
            let day = Logic.dayNumber(in: moveResult.items, forRow: 4)
            XCTAssertEqual(day, 5, "Travel should now belong to day 5")
        } else {
            XCTFail("Expected travel at index 4")
        }
    }

    /// Scenario: Move travel past the arrival game (should be invalid)
    func test_travelMove_cannotMovePastArrivalGame() {
        // Travel A->B cannot go to day 7 if there's a game at B on day 6
        let gameA = H.makeGameItem(city: "CityA", day: 3)
        let gameB = H.makeGameItem(city: "CityB", day: 6)
        let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 4, sortOrder: 50)

        let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])

        // Valid position check: day 7 should be invalid
        XCTAssertFalse(
            constraints.isValidPosition(for: travelItem, day: 7, sortOrder: 50),
            "Travel cannot be on day 7 (missed game on day 6)"
        )

        // Valid position check: day 5 should be valid
        XCTAssertTrue(
            constraints.isValidPosition(for: travelItem, day: 5, sortOrder: 50),
            "Travel on day 5 should be valid"
        )
    }

    /// Scenario: Move travel before the departure game (should be invalid)
    func test_travelMove_cannotMoveBeforeDepartureGame() {
        // Travel A->B cannot go to day 2 if there's a game at A on day 3
        let gameA = H.makeGameItem(city: "CityA", day: 3)
        let gameB = H.makeGameItem(city: "CityB", day: 6)
        let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 4, sortOrder: 50)

        let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])

        // Day 2 should be invalid (haven't played game at A yet)
        XCTAssertFalse(
            constraints.isValidPosition(for: travelItem, day: 2, sortOrder: 50),
            "Travel on day 2 is invalid (game at A is on day 3)"
        )

        // Day 3 AFTER game should be valid
        XCTAssertTrue(
            constraints.isValidPosition(for: travelItem, day: 3, sortOrder: 150),
            "Travel on day 3 after game should be valid"
        )
    }

    // MARK: - Complex Move Scenarios (Custom Items)

    /// Scenario: [day3][game][custom][day4][travel][day5][custom2]
    /// Moving custom items around
    func test_customItem_moveWithinSameDay() {
        let items = buildFlatItems([
            .day(3),
            .game("Detroit", day: 3),
            .custom("A", sortOrder: 1.0, day: 3),
            .custom("B", sortOrder: 2.0, day: 3),
            .day(4)
        ])

        // Move B before A: row 3 -> row 2
        let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 2)

        // After move: [day3][game][B][A][day4]
        XCTAssertEqual(result.destinationRowInNewArray, 2)

        // Calculate new sortOrder for B at row 2
        let sortOrder = Logic.calculateSortOrder(in: result.items, at: 2) { _ in nil }
        XCTAssertLessThan(sortOrder, 1.0, "B moved before A(1.0) should have sortOrder < 1.0")
    }

    /// Scenario: Move custom item across days
    func test_customItem_moveAcrossDays() {
        let items = buildFlatItems([
            .day(3),
            .custom("A", sortOrder: 1.0, day: 3),
            .day(4),
            .custom("B", sortOrder: 1.0, day: 4),
            .day(5)
        ])

        // Move A (row 1) to day 4 (row 3, after B)
        let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)

        let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
        XCTAssertEqual(newDay, 4, "A should now be on day 4")
    }

    /// Scenario: [day3][custom][game][day4][travel][custom2][day5]
    func test_customItem_moveBeforeGames_getsNegativeSortOrder() {
        let items = buildFlatItems([
            .day(3),
            .game("Detroit", day: 3),
            .custom("A", sortOrder: 1.0, day: 3),  // After game
            .day(4)
        ])

        // Move A before game: row 2 -> row 1
        let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 1)

        // After move: [day3][A][game][day4]
        let sortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }
        XCTAssertLessThan(sortOrder, 0, "Custom item before game should have negative sortOrder")
    }

    /// Scenario: Multiple items, complex reordering
    /// [day3][custom1][custom2][game][day4][travel][day5]
    /// Move custom2 to day 5
    func test_customItem_moveToEmptyDay() {
        let items = buildFlatItems([
            .day(3),
            .custom("A", sortOrder: -2.0, day: 3),
            .custom("B", sortOrder: -1.0, day: 3),
            .game("Detroit", day: 3),
            .day(4),
            .travel(from: "Detroit", to: "Chicago", day: 4),
            .day(5)
        ])

        // Move B (row 2) to day 5 (row 6, after day5 header)
        let result = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: 6)

        let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
        XCTAssertEqual(newDay, 5, "B should now be on day 5")

        let sortOrder = Logic.calculateSortOrder(in: result.items, at: result.destinationRowInNewArray) { _ in nil }
        XCTAssertEqual(sortOrder, 1.0, accuracy: 0.01, "First item on empty day should get 1.0")
    }

    /// Scenario: Move custom between two existing items on different day
    func test_customItem_moveBetweenExistingItems() {
        let items = buildFlatItems([
            .day(3),
            .custom("A", sortOrder: 1.0, day: 3),
            .day(4),
            .custom("B", sortOrder: 1.0, day: 4),
            .custom("C", sortOrder: 3.0, day: 4),
            .day(5)
        ])

        // Original: [day3(0), A(1), day4(2), B(3), C(4), day5(5)]
        // After removing A at row 1: [day3, day4, B, C, day5] with B at index 2, C at index 3
        // To insert between B and C, use proposedRow = 3
        let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)

        let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
        XCTAssertEqual(newDay, 4, "A should now be on day 4")

        // A should get sortOrder between B(1.0) and C(3.0) = 2.0
        let sortOrder = Logic.calculateSortOrder(in: result.items, at: result.destinationRowInNewArray) { _ in nil }
        XCTAssertEqual(sortOrder, 2.0, accuracy: 0.01, "A between B(1.0) and C(3.0) should get 2.0")
    }

    // MARK: - Edge Cases

    func test_moveLastItem_toFirstPosition() {
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .custom("B", sortOrder: 2.0, day: 1),
            .custom("C", sortOrder: 3.0, day: 1)
        ])

        // Move C (row 3) to row 1 (before A)
        let result = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: 1)

        // Order should be: [day1][C][A][B]
        XCTAssertEqual(result.destinationRowInNewArray, 1)

        let sortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }
        XCTAssertLessThan(sortOrder, 1.0, "C moved before A(1.0) should have sortOrder < 1.0")
    }

    func test_moveFirstItem_toLastPosition() {
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .custom("B", sortOrder: 2.0, day: 1),
            .custom("C", sortOrder: 3.0, day: 1)
        ])

        // Move A (row 1) to row 3 (after C)
        let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 3)

        // Order should be: [day1][B][C][A]
        XCTAssertEqual(result.destinationRowInNewArray, 3)

        let sortOrder = Logic.calculateSortOrder(in: result.items, at: 3) { _ in nil }
        XCTAssertGreaterThan(sortOrder, 3.0, "A moved after C(3.0) should have sortOrder > 3.0")
    }

    func test_moveItem_acrossManyDays() {
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .day(2),
            .day(3),
            .day(4),
            .custom("B", sortOrder: 1.0, day: 4),
            .day(5)
        ])

        // Move A (row 1) to day 5 (row 6)
        let result = Logic.simulateMove(original: items, sourceRow: 1, destinationProposedRow: 6)

        let newDay = Logic.dayNumber(in: result.items, forRow: result.destinationRowInNewArray)
        XCTAssertEqual(newDay, 5, "A should now be on day 5")
    }

    func test_consecutiveMoves_sortOrdersRemainValid() {
        // Simulate multiple consecutive moves and verify sortOrders stay ordered
        var items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .custom("B", sortOrder: 2.0, day: 1),
            .custom("C", sortOrder: 3.0, day: 1),
            .custom("D", sortOrder: 4.0, day: 1)
        ])

        // Move D to first, then C to first, then B to first
        for sourceRow in [4, 4, 4] {
            let result = Logic.simulateMove(original: items, sourceRow: sourceRow, destinationProposedRow: 1)
            let newSortOrder = Logic.calculateSortOrder(in: result.items, at: 1) { _ in nil }

            // Manually update the item's sortOrder (simulating what the app would do)
            items = result.items
            if case .customItem(var item) = items[1] {
                item = ItineraryItem(
                    id: item.id,
                    tripId: item.tripId,
                    day: item.day,
                    sortOrder: newSortOrder,
                    kind: item.kind
                )
                items[1] = .customItem(item)
            }
        }

        // Extract all sortOrders
        var sortOrders: [Double] = []
        for item in items {
            if case .customItem(let customItem) = item {
                sortOrders.append(customItem.sortOrder)
            }
        }

        // Verify all are properly ordered (ascending in the array)
        for i in 0..<(sortOrders.count - 1) {
            XCTAssertLessThan(sortOrders[i], sortOrders[i + 1],
                "SortOrders should remain properly ordered after multiple moves")
        }
    }

    // MARK: - DragZones Tests

    func test_calculateCustomItemDragZones_headersInvalid() {
        let items = buildFlatItems([
            .day(1),
            .custom("A", sortOrder: 1.0, day: 1),
            .day(2),
            .custom("B", sortOrder: 1.0, day: 2),
            .day(3)
        ])

        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
        let zones = Logic.calculateCustomItemDragZones(item: customItem, flatItems: items)

        // Headers at rows 0, 2, 4 should be invalid
        XCTAssertTrue(zones.invalidRowIndices.contains(0))
        XCTAssertTrue(zones.invalidRowIndices.contains(2))
        XCTAssertTrue(zones.invalidRowIndices.contains(4))

        // Items at rows 1, 3 should be valid
        XCTAssertTrue(zones.validDropRows.contains(1))
        XCTAssertTrue(zones.validDropRows.contains(3))
    }

    func test_calculateTravelDragZones_respectsDayRange() {
        let items = buildFlatItems([
            .day(1),
            .game("CityA", day: 1),
            .day(2),
            .travel(from: "CityA", to: "CityB", day: 2),
            .day(3),
            .game("CityB", day: 3)
        ])

        let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
        let travelValidRanges = ["travel:0:citya->cityb": 1...3]

        let zones = Logic.calculateTravelDragZones(
            segment: segment,
            flatItems: items,
            travelValidRanges: travelValidRanges,
            constraints: nil,
            findTravelItem: { _ in nil }
        )

        // All days 1-3 should be valid (6 rows total)
        XCTAssertEqual(zones.validDropRows.count, 6)
        XCTAssertTrue(zones.invalidRowIndices.isEmpty)
    }
}
