import Foundation
import PokerCore
import PokerSession
import PokerUI
import TestKit

/// Numbering the seats: the small blind is 1, the button is last, and the number is the thing the
/// player reads. The numbering is display only, so all of this is about what someone is told —
/// nothing here should ever be able to change how a hand is played.
public func seatNumberTests(_ t: TestRun) {
    t.suite("seat numbers") { t in

        t.test("nine handed reads the way the table sits") {
            let nine = Position.names(forPlayerCount: 9)
            t.equal(nine.map { $0.number(inTableOf: 9) },
                    [1, 2, 3, 4, 5, 6, 7, 8, 9],
                    "seats should count round from the small blind")
            t.equal(Position.smallBlind.number(inTableOf: 9), 1)
            t.equal(Position.underTheGun.number(inTableOf: 9), 3)
            t.equal(Position.cutoff.number(inTableOf: 9), 8)
            t.equal(Position.button.number(inTableOf: 9), 9)
        }

        t.test("every table size numbers 1 through however many are sitting") {
            for count in 2...9 {
                let numbers = Position.names(forPlayerCount: count)
                    .compactMap { $0.number(inTableOf: count) }
                    .sorted()
                t.equal(numbers, Array(1...count), "\(count) handed")
                t.equal(Set(numbers).count, count, "\(count) handed: two seats shared a number")
            }
        }

        t.test("the small blind is always 1 and the button is always last") {
            for count in 2...9 {
                t.equal(Position.button.number(inTableOf: count), count,
                        "\(count) handed: the button has to be the last number")
                guard count > 2 else { continue }
                t.equal(Position.smallBlind.number(inTableOf: count), 1, "\(count) handed")
                t.equal(Position.bigBlind.number(inTableOf: count), 2, "\(count) handed")
            }
        }

        t.test("the numbering follows the order the seats are named in") {
            // The one place the two can disagree is heads-up, and that is deliberate — see below.
            for count in 3...9 {
                let named = Position.names(forPlayerCount: count)
                let byNumber = named.sorted { ($0.number(inTableOf: count) ?? 0) < ($1.number(inTableOf: count) ?? 0) }
                t.equal(byNumber, named, "\(count) handed: numbering and naming disagree")
            }
        }

        t.test("heads-up numbers the button last rather than following the blind") {
            // Two-handed the button posts the small blind. Numbering by the blind would make the
            // button seat 1, which would mean the number went down as the seat got better — the
            // opposite of what it means at every other table size.
            t.equal(Position.bigBlind.number(inTableOf: 2), 1)
            t.equal(Position.button.number(inTableOf: 2), 2)
            t.equal(Position.smallBlind.number(inTableOf: 2), nil,
                    "nobody is called the small blind heads-up")
        }

        t.test("a seat that is not at the table has no number") {
            t.equal(Position.lojack.number(inTableOf: 6), nil, "six handed has no lojack")
            t.equal(Position.underTheGun.number(inTableOf: 3), nil, "three handed is blinds and button")
            t.equal(Position.button.number(inTableOf: 1), nil)
            // There are nine seat names, so a tenth seat cannot be numbered without inventing one.
            // Saying nothing is better than saying "seat 9 of 10" and being wrong about it.
            t.equal(Position.button.number(inTableOf: 10), nil, "ten seats, nine names")
            t.equal(Position.button.badge(inTableOf: 10), "BTN")
        }

        t.test("the same name is a different number at a different table size") {
            // Which is the whole reason a saved hand has to remember how many were dealt in.
            t.equal(Position.cutoff.number(inTableOf: 9), 8)
            t.equal(Position.cutoff.number(inTableOf: 6), 5)
            t.notEqual(Position.cutoff.seatPhrase(inTableOf: 9), Position.cutoff.seatPhrase(inTableOf: 6))
        }

        t.test("what the player actually reads") {
            t.equal(Position.underTheGunPlus1.seatPhrase(inTableOf: 9), "seat 4 of 9")
            t.equal(Position.underTheGunPlus1.described(inTableOf: 9), "seat 4 of 9 (under the gun + 1)")
            t.equal(Position.button.badge(inTableOf: 9), "9 BTN")
            // A seat that does not exist at that size falls back to the name rather than to "seat 0".
            t.equal(Position.lojack.seatPhrase(inTableOf: 6), "lojack")
            t.equal(Position.lojack.badge(inTableOf: 6), "LJ")
        }

        t.test("the note says how many people are still to speak") {
            t.expect(Position.underTheGun.actOrderNote(inTableOf: 9).contains("6 players act after you"),
                     "under the gun nine handed has six behind: \(Position.underTheGun.actOrderNote(inTableOf: 9))")
            t.expect(Position.cutoff.actOrderNote(inTableOf: 9).hasPrefix("One player acts after you"),
                     "the cutoff has exactly one behind it")
            t.expect(Position.button.actOrderNote(inTableOf: 9).contains("Nobody acts after you"),
                     "the button has nobody behind it")
            t.expect(Position.smallBlind.actOrderNote(inTableOf: 9).contains("before you had seen your cards"),
                     "the blinds should say why they lose")
            t.expect(!Position.button.actOrderNote(inTableOf: 9).isEmpty)
            t.equal(Position.lojack.actOrderNote(inTableOf: 6), "",
                    "a seat that is not at the table says nothing rather than something wrong")
        }

        t.test("the engine still names seats by case, whatever the numbering says") {
            // The numbering is display only. If this ever fails, the ranges have started reading it.
            let e = HandEngine(seats: (0..<9).map { SeatState(seat: $0, playerID: "p\($0)", stack: 1000) },
                               config: HandConfig(level: BlindLevel(smallBlind: 25, bigBlind: 50),
                                                  buttonSeat: 0, seed: 7))
            let map = e.positions()
            t.equal(map[0], .button)
            t.equal(map[0]?.number(inTableOf: map.count), 9)
            t.equal(map[1], .smallBlind)
            t.equal(map[1]?.number(inTableOf: map.count), 1)
        }
    }

    t.suite("the table picture") { t in

        // The drawing cannot be tested, but the arithmetic under it can, and it is the part that
        // goes wrong quietly: nine seats crowded onto a phone-sized ring either overlap each other
        // or hang off the edge of the space the diagram said it needed.
        t.test("seats never collide, at any table size or text size") {
            for side in [CGFloat(96), 148, 204] {
                for count in 2...9 {
                    let ring = SeatRing(side: side, count: count)
                    let points = ring.points
                    t.equal(points.count, count, "\(count) handed at \(side)")
                    for (a, first) in points.enumerated() {
                        for second in points[(a + 1)...] {
                            let gap = hypot(first.x - second.x, first.y - second.y)
                            t.expect(gap >= ring.footprint,
                                     "\(count) handed at \(side): two seats are \(gap) apart and need \(ring.footprint)")
                        }
                    }
                }
            }
        }

        t.test("no seat hangs off the edge of the picture") {
            for side in [CGFloat(96), 148, 204] {
                let ring = SeatRing(side: side, count: 9)
                // The badge on the dealer and the blinds sticks out further than the counter does.
                let reach = ring.footprint / 2 + ring.dot * 0.2
                for point in ring.points {
                    t.expect(abs(point.x) + reach <= ring.side / 2 + 0.5,
                             "a seat runs \(abs(point.x) + reach) wide of a \(ring.side / 2) half-width")
                    t.expect(abs(point.y) + reach <= ring.height / 2 + 0.5,
                             "a seat runs \(abs(point.y) + reach) below a \(ring.height / 2) half-height")
                }
            }
        }

        t.test("seat 1 sits at the top and the numbers run clockwise") {
            let ring = SeatRing(side: 148, count: 9)
            let points = ring.points
            t.close(Double(points[0].x), 0, 0.001, "seat 1 belongs at the top")
            t.lessThan(Double(points[0].y), 0.0)
            t.greaterThan(Double(points[1].x), 0.0, "seat 2 is the next one clockwise")
            t.lessThan(Double(points[8].x), 0.0, "the dealer sits back the other way from seat 1")
        }
    }

    t.suite("seat numbers in the history") { t in

        func hand(position: String, tableSize: Int?, net: Int = 0) -> HandRecord {
            HandRecord(sessionID: "s", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
                       playedAt: Date(timeIntervalSince1970: 1_780_000_000), handNumber: 1,
                       mode: "Tournament", bigBlind: 100, position: position,
                       holeCards: "Ah Kd", board: "", netChips: net, wentToShowdown: false,
                       sawFlop: false, putMoneyInPreflop: false, raisedPreflop: false,
                       verdicts: [], tableSize: tableSize)
        }

        t.test("a hand written down before seats were numbered still reads") {
            // The field is optional for exactly this reason: a required one would make the loader
            // drop every line ever written, silently.
            let old = hand(position: "CO", tableSize: nil)
            t.equal(old.seatsAtTable, 9, "an unrecorded table size reads as the full table the app deals")
            t.equal(old.seatPhrase, "seat 8 of 9")
        }

        t.test("a short table numbers its own seats") {
            t.equal(hand(position: "CO", tableSize: 6).seatPhrase, "seat 5 of 6")
            t.equal(hand(position: "BTN", tableSize: 6).seatDescription, "seat 6 of 6 (the button)")
        }

        t.test("an unrecognised seat falls back to what was written down") {
            let odd = hand(position: "MP2", tableSize: 9)
            t.equal(odd.seat, nil)
            t.equal(odd.seatPhrase, "MP2", "a renamed seat should still print something true")
        }

        t.test("a saved hand carries its table size to disk and back") {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("poker-seats-\(UUID().uuidString).jsonl")
            defer { try? FileManager.default.removeItem(at: url) }
            let store = HistoryStore(url: url)
            store.append(hand(position: "CO", tableSize: 6))
            t.equal(HistoryStore(url: url).hands.first?.tableSize, 6)

            // And a line from before the field existed still loads.
            let older = #"{"sessionID":"s","sessionStarted":"2026-06-01T00:00:00Z","playedAt":"2026-06-01T00:00:00Z","handNumber":2,"mode":"Tournament","bigBlind":100,"position":"BTN","holeCards":"Ah Kd","board":"","netChips":0,"wentToShowdown":false,"sawFlop":false,"putMoneyInPreflop":false,"raisedPreflop":false,"verdicts":[]}"#
            try! (older + "\n").data(using: .utf8)!.write(to: url, options: .atomic)
            let reread = HistoryStore(url: url)
            t.equal(reread.hands.count, 1, "an older line was thrown away")
            t.equal(reread.hands.first?.seatPhrase, "seat 9 of 9")
        }

        t.test("the by-seat breakdown leads with the number") {
            let analysis = GameAnalysis(of: [
                hand(position: "BB", tableSize: 9, net: -500),
                hand(position: "CO", tableSize: 6, net: 300),
                hand(position: "CO", tableSize: 6, net: 300),
                hand(position: "CO", tableSize: 9, net: 300),
                hand(position: "BTN", tableSize: 9, net: 900),
            ])
            let byName = Dictionary(uniqueKeysWithValues: analysis.byPosition.map { ($0.position, $0) })
            t.equal(byName["BB"]?.seatNumber, 2)
            t.equal(byName["BB"]?.seatPhrase, "seat 2 of 9")
            t.equal(byName["BTN"]?.seatNumber, 9)
            t.equal(byName["CO"]?.tableSize, 6, "the size those hands were mostly played at")
            t.equal(byName["CO"]?.seatPhrase, "seat 5 of 6")
            t.equal(byName["CO"]?.fullName, "cutoff", "the old name stays alongside the number")
            t.expect(byName["BB"]?.actOrderNote.isEmpty == false, "a row should explain itself")
            t.equal(analysis.byPosition.map(\.position), ["BB", "CO", "BTN"],
                    "rows still run the way the seats sit")
        }

        t.test("a played session writes down how many were dealt in") {
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 91, history: store)
            session.start(.tournament)
            var guardRail = 0
            while session.isPlaying, session.handNumber <= 6 {
                guardRail += 1
                precondition(guardRail < 20_000)
                if let legal = session.legal {
                    session.act(legal.canCheck ? .check : .fold)
                } else if session.review != nil, session.engine?.isComplete == true {
                    if session.handNumber >= 6 { break }
                    session.continueAfterReview()
                } else {
                    break
                }
            }
            t.expect(!store.hands.isEmpty, "no hands were recorded")
            t.expect(store.hands.allSatisfy { ($0.tableSize ?? 0) >= 2 },
                     "a hand was recorded without the table size")
            t.expect(store.hands.allSatisfy { $0.seat?.number(inTableOf: $0.seatsAtTable) != nil },
                     "a recorded seat could not be numbered")
            t.equal(store.hands.first?.tableSize, 9, "tables are nine handed")
        }
    }
}
