import Foundation
import TestKit
import PokerUI
import PokerCoach

/// The basics course. Ken, 2026-08-02: "One area that I don't get is how to determine the size of
/// the bet. Lets create a little focused tutorial on some basic concepts... And lets come up with
/// tutorials that drill each concept."
///
/// The writing itself is swept for jargon and banned phrasing by the glossary suite, which reads
/// `Copy.prose` — `Basics.allText` is in there, so nothing below repeats that. What is here is the
/// part a sweep cannot see: that every lesson is actually complete, and that "Practise this" leads
/// somewhere real rather than to a button that deals ordinary hands and calls it a lesson.
public func basicsTests(_ t: TestRun) {
    t.suite("the basics") { t in

        t.test("every lesson answers a question and answers it first") {
            t.greaterThan(Basics.lessons.count, 7, "a course this short is not worth a screen")
            t.greaterThan(Basics.cashLessons.count, 7, "the cash course is too short for a screen")
            for lesson in Basics.tournamentLessons + Basics.cashLessons + Basics.limitLessons {
                t.expect(lesson.question.hasSuffix("?"),
                         "\(lesson.id) is titled \"\(lesson.question)\", which is not a question")
                t.expect(!lesson.short.isEmpty, "\(lesson.id) has no short answer")
                t.expect(lesson.short.count < 160,
                         "\(lesson.id)'s short answer runs to \(lesson.short.count) characters, "
                         + "which is no longer the short answer")
                t.greaterThan(lesson.paragraphs.count, 1, "\(lesson.id) barely explains itself")
                for para in lesson.paragraphs {
                    t.expect(!para.isEmpty, "\(lesson.id) has an empty paragraph")
                }
            }
        }

        t.test("being told a bet was the wrong size leads to the lesson about bet sizes") {
            // Ken, 2026-08-22: "when you tell someone that their bet is not right, I find that i
            // dont undersand why." This is that complaint, as an assertion.
            //
            // It matters most here of all the habits. `bet-sizing` is deliberately unnameable —
            // the coach may price one bad size but is never allowed to say somebody HAS the habit,
            // because a sweep could not separate a player who does it every time from one who
            // never does. So the review is the only place the rule can ever be explained, and
            // before this it was the one place that did not explain it.
            let lesson = Basics.lesson(spot: .flopDecision, error: .wrongSize)
            t.equal(lesson?.id, "bet-size", "the bet-sizing verdict found no lesson to explain it")
            t.expect(lesson?.worked != nil,
                     "the bet-size lesson lost the arithmetic, which is the part that answers why")
        }

        t.test("every habit with a lesson resolves to a lesson that teaches that habit") {
            // Not to a PARTICULAR lesson: more than one can teach the same habit — "How much
            // should I bet?" and "Why bet the same size with everything?" both belong to
            // bet-sizing. The lookup picks one, and what has to hold is that the one it picks is
            // actually about the habit asked for, not merely that some lesson came back.
            for lesson in Basics.lessons {
                guard let habit = lesson.practises else { continue }
                let found = Basics.lesson(forLeak: habit)
                t.expect(found != nil, "habit \(habit) has lessons but resolves to none")
                t.equal(found?.practises, habit,
                        "asking for \(habit) returned a lesson about \(found?.practises ?? "nothing")")
            }
        }

        t.test("a habit is reachable from the spot and mistake a verdict actually carries") {
            // A lesson linked to a habit no verdict can produce is a link nobody ever follows.
            // This walks in from the verdict end, which is the end a player arrives from.
            for leak in LeakCatalogue.all {
                guard Basics.lesson(forLeak: leak.id) != nil else { continue }
                t.equal(Basics.lesson(spot: leak.spot, error: leak.error)?.practises, leak.id,
                        "\(leak.id) has a lesson that its own spot and mistake cannot reach")
            }
        }

        t.test("a decision with no lesson behind it says nothing rather than guessing") {
            // Most habits have no lesson written yet, and the review has to stay quiet about those
            // rather than offering the nearest one. A wrong explanation is worse than none.
            let unexplained = LeakCatalogue.all.filter {
                Basics.lesson(spot: $0.spot, error: $0.error) == nil
            }
            t.greaterThan(unexplained.count, 0,
                          "if every habit has a lesson, this test has stopped measuring anything")
        }

        t.test("no two lessons share an identifier") {
            t.equal(Set(Basics.lessons.map(\.id)).count, Basics.lessons.count)
        }

        // A rule of thumb without the arithmetic is the thing being complained about, not the fix:
        // "bet two-thirds of the pot" is exactly as useless as saying nothing to somebody who has
        // never seen it done. Every lesson that hands out a number has to show it working.
        t.test("the lessons that give a number show the number working") {
            let mustWork = ["bet-size", "the-price", "open-size", "stack-depth"]
            for id in mustWork {
                guard let lesson = Basics.lessons.first(where: { $0.id == id }) else {
                    t.expect(false, "the course has lost its \(id) lesson"); continue
                }
                guard let worked = lesson.worked else {
                    t.expect(false, "\(id) gives a rule of thumb and never works it through")
                    continue
                }
                t.expect(worked.contains(where: \.isNumber),
                         "\(id)'s worked example contains no numbers")
            }
        }

        t.test("the cash course teaches the cash habits and drops the short stack") {
            let ids = Basics.cashLessons.map(\.id)
            t.expect(!ids.contains("stack-depth"), "nobody at a cash table is fifteen big blinds deep")
            for id in ["money-behind", "one-pair-stack", "re-raised-pots"] {
                t.expect(ids.contains(id), "the cash course is missing \(id)")
            }
            t.equal(Set(ids).count, ids.count, "two cash lessons share an identifier")
            let cashKnown = Set(LeakCatalogue.cash.map(\.id))
            for lesson in Basics.cashLessons {
                guard let target = lesson.practises else { continue }
                t.expect(cashKnown.contains(target),
                         "\(lesson.id) offers practice at \"\(target)\", which the cash coach cannot drill")
            }
            withCashProduct {
                t.equal(Basics.lessons.map(\.id), ids, "the cash product should read the cash course")
                t.equal(Basics.lesson(forLeak: "stacked-off-one-pair")?.id, "one-pair-stack")
            }
        }

        // A lesson that works a number through and gets it wrong is worse than one that gives no
        // number at all, and nothing read the arithmetic until the pot-odds lesson was found saying
        // "one in five" about a price that is one in six and "half the time" about a pot-sized bet
        // that needs one time in three — in all three courses, four lessons from one that had the
        // same sum right. This reads every "pay X to win Y" and checks the fraction and the
        // percentage printed after it against X / (X + Y).
        t.test("every price a lesson works through is the price it says") {
            let words: [String: Int] = ["two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
                                        "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11,
                                        "twelve": 12]
            var pricesChecked = 0
            let courses = Basics.tournamentLessons + Basics.cashLessons + Basics.limitLessons
            let corpus = courses.flatMap(\.allText) + Glossary.terms.map(\.definition)
            for text in corpus {
                let scanner = text as NSString
                let pattern = #"(\d[\d,]*) to win (\d[\d,]*)"#
                let regex = try! NSRegularExpression(pattern: pattern)
                for match in regex.matches(in: text, range: NSRange(location: 0, length: scanner.length)) {
                    let pay = Int(scanner.substring(with: match.range(at: 1)).replacingOccurrences(of: ",", with: ""))!
                    let win = Int(scanner.substring(with: match.range(at: 2)).replacingOccurrences(of: ",", with: ""))!
                    let share = Double(pay) / Double(pay + win)
                    let tailStart = match.range.location + match.range.length
                    let tail = scanner.substring(from: tailStart).prefix(140).lowercased()
                    if let f = try! NSRegularExpression(pattern: #"(one|two) (?:time|times)? ?in (\w+)"#)
                        .firstMatch(in: tail, range: NSRange(location: 0, length: (tail as NSString).length)) {
                        let ns = tail as NSString
                        let numerator = ns.substring(with: f.range(at: 1)) == "one" ? 1 : 2
                        let denominatorWord = ns.substring(with: f.range(at: 2))
                        if let denominator = words[denominatorWord] ?? Int(denominatorWord) {
                            let said = Double(numerator) / Double(denominator)
                            t.close(said, share, 0.02,
                                    "pay \(pay) to win \(win) is \(Int((share * 100).rounded()))%, not "
                                    + "\(numerator) in \(denominator): \(text.prefix(80))")
                            pricesChecked += 1
                        }
                    }
                    if let p = try! NSRegularExpression(pattern: #"about (\d+)%"#)
                        .firstMatch(in: tail, range: NSRange(location: 0, length: (tail as NSString).length)) {
                        let percent = Int((tail as NSString).substring(with: p.range(at: 1)))!
                        t.close(Double(percent) / 100, share, 0.015,
                                "pay \(pay) to win \(win) is \(Int((share * 100).rounded()))%, not \(percent)%")
                        pricesChecked += 1
                    }
                }
            }
            t.greaterThan(pricesChecked, 5, "the courses work through at least six prices; "
                          + "only \(pricesChecked) were found to check")
        }

        t.test("practising a concept leads to a drill that exists") {
            let known = Set(LeakCatalogue.all.map(\.id))
            for lesson in Basics.lessons {
                guard let target = lesson.practises else { continue }
                t.expect(known.contains(target),
                         "\(lesson.id) offers practice at \"\(target)\", which is not a habit the "
                         + "app can drill — the button would open nothing")
            }
        }

        // The point of the request was practice, so a course where most of it is reading has
        // quietly not been built. This is the assertion that says so out loud.
        t.test("most of the course can actually be practised") {
            let practisable = Basics.lessons.filter { $0.practises != nil }
            t.greaterThan(practisable.count * 2, Basics.lessons.count,
                          "only \(practisable.count) of \(Basics.lessons.count) lessons can be "
                          + "practised, so this is a reading list rather than a course")
            // And the one the whole thing was asked for has to be among them.
            t.expect(Basics.lessons.first { $0.id == "bet-size" }?.practises != nil,
                     "the bet-size lesson cannot be practised, which is the one it was asked for")
        }

        // Ken, 2026-08-02: "How do I get to the drills? Is there a way to navigate to them?"
        // There was not — every door was behind a habit the coach had confirmed, which takes
        // hundreds of hands. This is the assertion that a person with an empty history can still
        // reach one, and it is written against the catalogue rather than the screens so it keeps
        // holding if the screens are rearranged.
        t.test("somebody who has played nothing can still find a drill") {
            let drillable = LeakCatalogue.all.filter(\.canBeNamed)
            t.greaterThan(drillable.count, 8,
                          "only \(drillable.count) habits can be drilled, so a list of them is thin")
            for leak in drillable {
                t.expect(!leak.name.isEmpty, "\(leak.id) would show a blank row")
                t.expect(!leak.drillHint.isEmpty,
                         "\(leak.id) says nothing about what it deals, so choosing it is a guess")
            }
            // The practice list is grouped by when in the hand the situation happens. A habit whose
            // spot is in none of those groups would be built and then be invisible.
            let grouped: Set<SpotKind> = [.openingChance, .facingRaise, .facingReraise,
                                          .bigBlindDefence, .shortStackShove, .continuationBet,
                                          .facingContinuationBet, .flopDecision, .turnDecision,
                                          .riverDecision, .facingRiverBet]
            for leak in drillable {
                t.expect(grouped.contains(leak.spot),
                         "\(leak.id) happens at \(leak.spot), which no group on the practice list "
                         + "covers — it would never appear")
            }
        }

        // Ken, 2026-08-02: "Lets make the drills their own menu item. I dont want people to have
        // to dig for it." It is a tab now rather than two taps inside Settings — which puts the
        // bar at seven, and seven only fits because every short name is brief. This is the check
        // that says so, since the failure is a truncated word nobody notices in a screenshot.
        t.test("the screen you were last on comes back") {
            for screen in Screen.allCases {
                t.equal(Screen(rawValue: screen.rawValue), screen,
                        "\"\(screen.rawValue)\" does not round-trip through its own raw value")
            }
            // The table is the exception, on purpose: reopening onto a felt nobody is sitting at
            // would claim a tournament is in progress when it is not.
            t.equal(Screen(rawValue: "My game"), Screen.leaks)
            t.equal(Screen(rawValue: "Drills"), Screen.practice)
        }

        t.test("every screen fits on a phone's tab bar") {
            let names = Screen.allCases.map(\.shortName)
            t.equal(Set(names).count, names.count, "two screens share a tab label")
            t.lessThan(names.count, 8,
                       "\(names.count) tabs is more than a phone can split without truncating")
            for name in names {
                t.expect(!name.isEmpty, "a tab has no label")
                t.lessThan(name.count, 8,
                           "\"\(name)\" is \(name.count) characters, which truncates on a phone "
                           + "at the largest text size")
            }
            t.expect(names.contains("Drills"), "the drills lost their own place on the bar")
        }

        // Caught on a store screenshot reading "1 hands watched". The first hand of a first
        // sitting is the most-seen state this app has and it was the one written ungrammatically.
        t.test("one of something is never called ones") {
            t.equal(Copy.hands(1), "1 hand")
            t.equal(Copy.hands(0), "0 hands")
            t.equal(Copy.hands(2), "2 hands")
            t.equal(Copy.sittings(1), "1 sitting")
            t.equal(Copy.chances(1), "1 chance")
            t.equal(Copy.times(1), "once")
            t.equal(Copy.times(3), "3 times")
            // And nothing the player reads may say it the broken way. This walks the copy the
            // screens actually render, which is the same list the jargon sweep uses.
            // "271 hands saved" is fine: the 1 has to be the whole number, not the end of one.
            for line in Copy.prose {
                t.expect(line.range(of: #"(^|[^0-9])1 (hands|sittings|chances|times)\b"#,
                                    options: .regularExpression) == nil,
                         "a screen says \"\(line)\"")
            }
        }

        t.test("a drill a lesson points at deals that lesson's situation") {
            // Each drill arranges a table aimed at one spot. A lesson about where you sit that
            // borrowed a river drill would read well and teach the wrong thing.
            let expected: [String: SpotKind] = [
                "bet-size": .flopDecision,
                "open-size": .openingChance,
                "position": .openingChance,
                "which-hands": .openingChance,
                "raise-or-fold": .openingChance,
                "stack-depth": .shortStackShove,
                "flop-bet": .continuationBet,
            ]
            for (lessonID, spot) in expected {
                guard let lesson = Basics.lessons.first(where: { $0.id == lessonID }),
                      let target = lesson.practises,
                      let leak = LeakCatalogue.all.first(where: { $0.id == target }) else {
                    t.expect(false, "\(lessonID) no longer points anywhere"); continue
                }
                t.equal(leak.spot, spot,
                        "\(lessonID) practises \(target), which deals \(leak.spot) rather than \(spot)")
            }
        }
    }
}
