//
//  ItinerarySemanticTravelTests.swift
//  SportsTimeTests
//
//  Acceptance tests for semantic travel model in ItineraryReorderingLogic.
//  These tests verify the core invariants of the refactored logic.
//

import XCTest
@testable import SportsTime

private typealias H = ItineraryTestHelpers
private typealias Logic = ItineraryReorderingLogic

final class ItinerarySemanticTravelTests: XCTestCase {

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

    // MARK: - Acceptance Test A: No Travel Duplication

    /// flattenDays must NOT duplicate travel that appears in both travelBefore AND items.
    /// Under semantic model, travelBefore is IGNORED entirely.
    func test_A_flattenDays_ignoresTravelBefore_noDuplication() {
        // Create a travel segment
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")

        // Create day with travel in BOTH travelBefore (legacy) AND items (semantic)
        let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
        let days = [
            ItineraryDayData(
                id: 1,
                dayNumber: 1,
                date: testDate,
                games: [],
                items: [],
                travelBefore: nil
            ),
            ItineraryDayData(
                id: 2,
                dayNumber: 2,
                date: H.dayAfter(testDate),
                games: [],
                items: [travelItem],  // Travel in items (semantic model)
                travelBefore: travel  // Travel also in travelBefore (legacy)
            )
        ]

        // Provide sortOrder lookup for the travel
        let result = Logic.flattenDays(days) { segment in
            if segment.fromLocation.name == "Detroit" && segment.toLocation.name == "Chicago" {
                return 1.0  // sortOrder >= 0 means after games
            }
            return nil
        }

        // Count travel rows
        let travelCount = result.filter { row in
            if case .travel = row { return true }
            return false
        }.count

        XCTAssertEqual(travelCount, 1, "Travel should appear exactly ONCE (travelBefore is ignored)")
    }

    /// When travelBefore exists but items is empty, travel should NOT appear at all.
    func test_A_flattenDays_travelBeforeOnly_notIncluded() {
        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: [],  // No travel in items
                travelBefore: travel  // Only in travelBefore (legacy)
            )
        ]

        let result = Logic.flattenDays(days) { _ in 1.0 }

        let travelCount = result.filter { row in
            if case .travel = row { return true }
            return false
        }.count

        XCTAssertEqual(travelCount, 0, "travelBefore should be completely ignored - no travel should appear")
    }

    // MARK: - Acceptance Test B: dayForTravelAt Uses Backward Scan

    /// dayForTravelAt must use backward scan (same as dayNumber), not forward scan.
    func test_B_dayForTravelAt_usesBackwardScan_matchesDayNumber() {
        // Build items: [day1, game1, travel, day2, game2]
        // Travel at row 2 should return day 1 (backward scan finds day1 header)
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
        let game1 = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let game2 = H.makeRichGame(city: "Chicago", hour: 19, baseDate: H.dayAfter(testDate))

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .games([game1], dayNumber: 1),
            .travel(travel, dayNumber: 2),  // This is what legacy would say
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
            .games([game2], dayNumber: 2)
        ]

        let travelRow = 2

        // Both functions should return the SAME value
        let dayNumberResult = Logic.dayNumber(in: items, forRow: travelRow)
        let dayForTravelResult = Logic.dayForTravelAt(row: travelRow, in: items)

        XCTAssertEqual(dayNumberResult, dayForTravelResult,
            "dayForTravelAt must match dayNumber (backward scan)")
        XCTAssertEqual(dayForTravelResult, 1,
            "Travel at row 2 should belong to day 1 (backward scan finds day1 header)")
    }

    /// Travel placed after day header (but no next header) uses backward scan.
    func test_B_dayForTravelAt_atEndOfList_usesBackwardScan() {
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
            .travel(travel, dayNumber: 2)  // At end of list
        ]

        let result = Logic.dayForTravelAt(row: 2, in: items)
        let dayNumberResult = Logic.dayNumber(in: items, forRow: 2)

        XCTAssertEqual(result, dayNumberResult)
        XCTAssertEqual(result, 2, "Travel at row 2 after day2 header should belong to day 2")
    }

    // MARK: - Acceptance Test C: Custom Item Valid Destinations Include "Above Games"

    /// Custom items can be placed above games (with negative sortOrder).
    func test_C_customItemValidDestinations_includesAboveGames() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")

        // Row layout: [0: header, 1: games, 2: customItem]
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),  // Row 0
            .games([game], dayNumber: 1),              // Row 1
            .customItem(customItem)                    // Row 2
        ]

        // Create constraints that allow custom items anywhere
        let constraints = ItineraryConstraints(tripDayCount: 1, items: [])

        let validRows = Logic.computeValidDestinationRowsProposed(
            flatItems: items,
            sourceRow: 2,  // Moving customItem from row 2
            dragged: .customItem(customItem),
            travelValidRanges: [:],
            constraints: constraints,
            findTravelItem: { _ in nil },
            makeTravelItem: { segment in
                let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
                return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info))
            },
            findTravelSortOrder: { _ in nil }
        )

        // After removing customItem at row 2, array is: [header, games] with count 2
        // Valid proposed rows are 1 (after header) and 2 (after games, but beyond count-1)
        // So proposedRow 1 should put item between header and games
        XCTAssertTrue(validRows.contains(1),
            "proposedRow 1 (insert between header and games) should be valid")
    }

    /// Verify sortOrder is negative when custom item dropped above games.
    func test_C_customItemAboveGames_getsNegativeSortOrder() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 5.0, title: "Activity")

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .customItem(customItem),  // Now at row 1 (moved above games)
            .games([game], dayNumber: 1)  // Row 2
        ]

        let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }

        XCTAssertLessThan(sortOrder, 0, "Custom item above games must have negative sortOrder")
    }

    // MARK: - Acceptance Test D: Travel Edge-Day Respects SortOrder

    /// Travel on departure game day is valid only if sortOrder > game's sortOrder.
    func test_D_travelOnDepartureGameDay_validOnlyAfterGame() {
        // Game at city A on day 3
        let gameA = H.makeGameItem(city: "CityA", day: 3)
        let gameB = H.makeGameItem(city: "CityB", day: 6)

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

        let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 3, sortOrder: 50)

        // Travel on day 3 BEFORE game (sortOrder = -50) should be INVALID
        XCTAssertFalse(
            constraints.isValidPosition(for: travelItem, day: 3, sortOrder: -50),
            "Travel before departure game should be invalid"
        )

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

    /// Travel on arrival game day is valid only if sortOrder < game's sortOrder.
    func test_D_travelOnArrivalGameDay_validOnlyBeforeGame() {
        let gameA = H.makeGameItem(city: "CityA", day: 3)
        let gameB = H.makeGameItem(city: "CityB", day: 6)

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

        let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 6, sortOrder: 50)

        // Travel on day 6 AFTER game (sortOrder = 150) should be INVALID
        XCTAssertFalse(
            constraints.isValidPosition(for: travelItem, day: 6, sortOrder: 150),
            "Travel after arrival game should be invalid"
        )

        // Travel on day 6 BEFORE game (sortOrder = -50) should be VALID
        XCTAssertTrue(
            constraints.isValidPosition(for: travelItem, day: 6, sortOrder: -50),
            "Travel before arrival game should be valid"
        )
    }

    // MARK: - Acceptance Test E: computeValidDestinationRowsProposed Matches Constraints

    /// For each proposedRow, simulate → compute (day, sortOrder) → constraints.isValidPosition must match.
    func test_E_computeValidDestinationRows_matchesConstraintsValidation() {
        let gameA = H.makeRichGame(city: "CityA", hour: 19, baseDate: testDate)
        let gameBDate = Calendar.current.date(byAdding: .day, value: 3, to: testDate)!
        let gameB = H.makeRichGame(city: "CityB", hour: 19, baseDate: gameBDate)
        let travel = H.makeTravelSegment(from: "CityA", to: "CityB")

        let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!
        let day3Date = Calendar.current.date(byAdding: .day, value: 2, to: testDate)!
        let day4Date = Calendar.current.date(byAdding: .day, value: 3, to: testDate)!

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .games([gameA], dayNumber: 1),
            .dayHeader(dayNumber: 2, date: day2Date),
            .travel(travel, dayNumber: 2),  // Source row 3
            .dayHeader(dayNumber: 3, date: day3Date),
            .dayHeader(dayNumber: 4, date: day4Date),
            .games([gameB], dayNumber: 4)
        ]

        let gameItemA = H.makeGameItem(city: "CityA", day: 1)
        let gameItemB = H.makeGameItem(city: "CityB", day: 4)
        let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0)

        let constraints = ItineraryConstraints(tripDayCount: 4, items: [gameItemA, gameItemB])

        let travelValidRanges = ["travel:0:citya->cityb": 1...4]

        let validRows = Logic.computeValidDestinationRowsProposed(
            flatItems: items,
            sourceRow: 3,
            dragged: .travel(travel, dayNumber: 2),
            travelValidRanges: travelValidRanges,
            constraints: constraints,
            findTravelItem: { _ in travelItem },
            makeTravelItem: { _ in travelItem },
            findTravelSortOrder: { _ in 1.0 }
        )

        // Manually verify each row
        for proposedRow in 1..<items.count {
            let simulated = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: proposedRow)
            guard simulated.didMove else { continue }

            let destRow = simulated.destinationRowInNewArray

            // Skip day headers
            if case .dayHeader = simulated.items[destRow] {
                XCTAssertFalse(validRows.contains(proposedRow),
                    "Day header row \(proposedRow) should not be valid")
                continue
            }

            let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
            let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in 1.0 }

            let constraintSaysValid = constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
            let functionSaysValid = validRows.contains(proposedRow)

            XCTAssertEqual(constraintSaysValid, functionSaysValid,
                "Row \(proposedRow) → day \(day), sortOrder \(sortOrder): " +
                "constraint says \(constraintSaysValid), function says \(functionSaysValid)")
        }
    }

    /// Custom item should also match constraints validation.
    func test_E_customItemValidDestinations_matchesConstraints() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Lunch")
        let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .games([game], dayNumber: 1),
            .customItem(customItem),  // Source row 2
            .dayHeader(dayNumber: 2, date: day2Date)
        ]

        let constraints = ItineraryConstraints(tripDayCount: 2, items: [])

        let validRows = Logic.computeValidDestinationRowsProposed(
            flatItems: items,
            sourceRow: 2,
            dragged: .customItem(customItem),
            travelValidRanges: [:],
            constraints: constraints,
            findTravelItem: { _ in nil },
            makeTravelItem: { segment in
                let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
                return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info))
            },
            findTravelSortOrder: { _ in nil }
        )

        // Verify each row matches constraint
        for proposedRow in 1..<items.count {
            let simulated = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: proposedRow)
            guard simulated.didMove else { continue }

            let destRow = simulated.destinationRowInNewArray

            // Skip day headers
            if case .dayHeader = simulated.items[destRow] {
                XCTAssertFalse(validRows.contains(proposedRow),
                    "Day header row \(proposedRow) should not be valid")
                continue
            }

            let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
            let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in nil }

            let testItem = ItineraryItem(
                id: customItem.id,
                tripId: customItem.tripId,
                day: day,
                sortOrder: sortOrder,
                kind: customItem.kind
            )

            let constraintSaysValid = constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder)
            let functionSaysValid = validRows.contains(proposedRow)

            XCTAssertEqual(constraintSaysValid, functionSaysValid,
                "Custom item row \(proposedRow) → day \(day), sortOrder \(sortOrder): mismatch")
        }
    }

    // MARK: - SimulateMove Bounds Safety

    func test_simulateMove_negativeSourceRow_returnsOriginal() {
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate)
        ]

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

        XCTAssertFalse(result.didMove, "Invalid sourceRow should not move")
        XCTAssertEqual(result.items.count, 1)
    }

    func test_simulateMove_sourceRowOutOfBounds_returnsOriginal() {
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate)
        ]

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

        XCTAssertFalse(result.didMove, "Out of bounds sourceRow should not move")
        XCTAssertEqual(result.items.count, 1)
    }

    func test_simulateMove_validSourceRow_didMoveIsTrue() {
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
        ]

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

        XCTAssertTrue(result.didMove, "Valid sourceRow should move")
    }

    // MARK: - Audit Fix Tests: Travel Never Disappears

    /// Travel in items must appear even if findTravelSortOrder returns nil.
    func test_flattenDays_travelAppearsEvenWithNilSortOrder() {
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
        let travelItem = ItineraryRowItem.travel(travel, dayNumber: 1)

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

        // Return nil for sortOrder lookup - should use default (1.0)
        let result = Logic.flattenDays(days) { _ in nil }

        let travelCount = result.filter { row in
            if case .travel = row { return true }
            return false
        }.count

        XCTAssertEqual(travelCount, 1, "Travel must appear even when sortOrder lookup returns nil")
    }

    // MARK: - Audit Fix Tests: travelRow Semantic Lookup

    /// travelRow must find travel by scanning day section, not by embedded dayNumber.
    func test_travelRow_usesSemanticDayLookup() {
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")

        // Travel has embedded dayNumber 99 (wrong), but is positioned after day 2 header
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
            .travel(travel, dayNumber: 99)  // Wrong embedded dayNumber
        ]

        // Semantic lookup should find travel in day 2 section
        let result = Logic.travelRow(in: items, forDay: 2)

        XCTAssertEqual(result, 2, "travelRow should find travel in day 2 section by position, not embedded dayNumber")
    }

    /// travelRow returns nil if no travel in that day section.
    func test_travelRow_returnsNilIfNoTravelInDaySection() {
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .travel(travel, dayNumber: 1),  // Travel in day 1
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
        ]

        // Day 2 has no travel
        let result = Logic.travelRow(in: items, forDay: 2)

        XCTAssertNil(result, "travelRow should return nil when no travel in day section")
    }

    // MARK: - Audit Fix Tests: calculateSortOrder Region Correctness

    /// Before-games region must always return negative sortOrder.
    func test_calculateSortOrder_beforeGamesRegion_alwaysNegative() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Morning")

        // Item placed BEFORE games row
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),  // Row 0
            .customItem(customItem),                    // Row 1 - before games
            .games([game], dayNumber: 1)                // Row 2
        ]

        let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }

        XCTAssertLessThan(sortOrder, 0, "Item before games must have negative sortOrder")
    }

    /// After-games region must always return non-negative sortOrder.
    func test_calculateSortOrder_afterGamesRegion_alwaysNonNegative() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Evening")

        // Item placed AFTER games row
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .games([game], dayNumber: 1),
            .customItem(customItem)  // Row 2 - after games
        ]

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

        XCTAssertGreaterThanOrEqual(sortOrder, 0, "Item after games must have non-negative sortOrder")
    }

    /// First item in before-games region gets proper negative sortOrder.
    func test_calculateSortOrder_firstItemBeforeGames_getsNegativeValue() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        // Create a custom item to place before games (simulating a moved item)
        let customItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Pre-game activity")

        // This represents the state AFTER moving an item to row 1 (between header and games)
        // calculateSortOrder expects the moved item already in the array
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),  // Row 0
            .customItem(customItem),                    // Row 1 - the moved item (before games)
            .games([game], dayNumber: 1)                // Row 2
        ]

        // Calculate sortOrder for the item at row 1
        let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }

        // With no other movable items in before-games region, should get -1.0
        XCTAssertLessThan(sortOrder, 0, "First item before games should get negative sortOrder")
        XCTAssertEqual(sortOrder, -1.0, "With no neighbors in before-games region, should return -1.0")
    }

    // MARK: - Audit Fix Tests: Coordinate Space Conversion

    /// proposedToOriginal converts correctly when proposed < sourceRow.
    func test_proposedToOriginal_belowSource_unchanged() {
        // Source at row 5, proposed is 3
        // After removing row 5, proposed 3 is still original 3
        let result = Logic.proposedToOriginal(3, sourceRow: 5)
        XCTAssertEqual(result, 3)
    }

    /// proposedToOriginal converts correctly when proposed >= sourceRow.
    func test_proposedToOriginal_atOrAboveSource_addOne() {
        // Source at row 5, proposed is 5
        // After removing row 5, proposed 5 corresponds to original 6
        let result = Logic.proposedToOriginal(5, sourceRow: 5)
        XCTAssertEqual(result, 6)

        // Source at row 5, proposed is 7
        let result2 = Logic.proposedToOriginal(7, sourceRow: 5)
        XCTAssertEqual(result2, 8)
    }

    /// originalToProposed converts correctly.
    func test_originalToProposed_convertsCorrectly() {
        // Original 3 with source at 5 -> proposed 3 (below source)
        XCTAssertEqual(Logic.originalToProposed(3, sourceRow: 5), 3)

        // Original 7 with source at 5 -> proposed 6 (above source)
        XCTAssertEqual(Logic.originalToProposed(7, sourceRow: 5), 6)

        // Original 5 with source at 5 -> nil (is the source)
        XCTAssertNil(Logic.originalToProposed(5, sourceRow: 5))
    }

    // MARK: - Audit Fix Tests: DragZones Coordinate Space

    /// DragZones validDropRows should be in ORIGINAL coordinate space.
    func test_dragZones_returnsOriginalCoordinates() {
        let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")

        // [0: header, 1: games, 2: customItem]
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .games([game], dayNumber: 1),
            .customItem(customItem)
        ]

        let constraints = ItineraryConstraints(tripDayCount: 1, items: [])

        let zones = Logic.calculateCustomItemDragZones(
            item: customItem,
            sourceRow: 2,
            flatItems: items,
            constraints: constraints,
            findTravelSortOrder: { _ in nil }
        )

        // Source row (2) should NOT be in invalidRowIndices (it's being dragged)
        XCTAssertFalse(zones.invalidRowIndices.contains(2),
            "Source row should not be in invalidRowIndices")

        // Row 0 (header) should be invalid
        XCTAssertTrue(zones.invalidRowIndices.contains(0),
            "Day header should be invalid")

        // validDropRows are in ORIGINAL coordinate space
        // After removing row 2, post-removal array is [header, games] with count 2
        // Proposed indices: 0 (before header - invalid), 1 (after header/games), 2 (append at end)
        // - Proposed 1 -> original 1 (below source 2)
        // - Proposed 2 -> original 3 (at/above source, so +1)
        // Note: original index 3 is valid for append operations (insert at end)
        for validRow in zones.validDropRows {
            // Valid rows must be <= items.count (allowing append at end which is items.count)
            XCTAssertLessThanOrEqual(validRow, items.count,
                "Valid drop rows must be valid indices for insertion (including append)")
            // Source row should not be included
            XCTAssertNotEqual(validRow, 2,
                "Valid drop rows should not include source row itself")
        }
    }

    /// DragZones invalidRowIndices should not include sourceRow.
    func test_dragZones_excludesSourceRowFromInvalid() {
        let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")

        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .travel(travel, dayNumber: 1),  // Source row 1
            .dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
        ]

        let zones = Logic.calculateTravelDragZones(
            segment: travel,
            sourceRow: 1,
            flatItems: items,
            travelValidRanges: [:],
            constraints: nil,
            findTravelItem: { _ in nil },
            makeTravelItem: { segment in
                let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
                return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .travel(info))
            },
            findTravelSortOrder: { _ in 1.0 }
        )

        XCTAssertFalse(zones.invalidRowIndices.contains(1),
            "Source row (being dragged) should not be marked invalid")
    }

    // MARK: - Audit Fix Tests: computeValidDestinationRowsProposed End Position

    /// Verify that append-at-end is handled correctly.
    func test_computeValidDestinationRows_includesAppendAtEnd() {
        let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")

        // [0: header, 1: customItem]
        let items: [ItineraryRowItem] = [
            .dayHeader(dayNumber: 1, date: testDate),
            .customItem(customItem)
        ]

        let constraints = ItineraryConstraints(tripDayCount: 1, items: [])

        let validRows = Logic.computeValidDestinationRowsProposed(
            flatItems: items,
            sourceRow: 1,
            dragged: .customItem(customItem),
            travelValidRanges: [:],
            constraints: constraints,
            findTravelItem: { _ in nil },
            makeTravelItem: { _ in fatalError() },
            findTravelSortOrder: { _ in nil }
        )

        // After removing row 1, array is [header] with count 1
        // Valid proposed positions: 1 (after header, which is append-at-end)
        // maxProposed = items.count - 1 = 1
        // So we test 1...1 which includes the append position

        // The proposed row 1 should be valid (append after header)
        // But wait - we need to check what simulateMove does:
        // simulateMove with count=1 (after removal), proposed=1 -> clampedDest=1 which is insert at end
        // This inserts AFTER the header, which is valid

        XCTAssertTrue(validRows.contains(1),
            "Proposed row 1 (append after header) should be valid")
    }
}
