import Foundation
import Testing
@testable import Kanban

/// `FolderWatcher` is the one component here whose correctness is a *timing* claim — "one reload
/// per burst", "nothing mid-bracket", "always one after the bracket" — so these tests are written
/// against a real FSEvents stream on a real temp directory rather than against a fake. A fake
/// would pin the debounce logic and prove nothing about the two things most likely to be wrong:
/// the flags the stream actually sends and the paths it actually reports.
///
/// That makes deadlines the main flakiness risk, and they are handled by asymmetry: **waiting for
/// something is generous** (poll up to seconds — a slow machine must not fail a test), while
/// **waiting for nothing is a fixed quiet period** well past the debounce. Assertions are never
/// weakened to buy stability; the deadlines are.

// MARK: - Support

/// A temp directory per test, and the only place a test touches the filesystem.
@MainActor
private struct WatchFixture {
    let root: URL

    init(create: Bool = true) throws {
        root = FileManager.default.temporaryDirectory
            .appendingPathComponent("FolderWatcherTests-\(UUID().uuidString)", isDirectory: true)
        if create {
            try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        }
    }

    func tearDown() {
        try? FileManager.default.removeItem(at: root)
    }

    /// Writes a file directly — this is a *foreign* write by construction: no Writer, no bracket,
    /// exactly what an editor or an agent does.
    func write(_ relativePath: String, _ text: String = "x") {
        let fileURL = root.appendingPathComponent(relativePath)
        try? FileManager.default.createDirectory(
            at: fileURL.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )
        try? Data(text.utf8).write(to: fileURL)
    }

    func makeDirectory(_ relativePath: String) {
        try? FileManager.default.createDirectory(
            at: root.appendingPathComponent(relativePath, isDirectory: true),
            withIntermediateDirectories: true
        )
    }

    func remove() {
        try? FileManager.default.removeItem(at: root)
    }
}

/// Accumulates what the watcher delivered. Main-actor, like the handler.
@MainActor
private final class EventLog {
    private(set) var events: [WatcherEvent] = []

    var count: Int { events.count }

    func record(_ event: WatcherEvent) {
        events.append(event)
    }

    func reset() {
        events.removeAll()
    }

    var origins: [WatchOrigin] {
        events.compactMap { if case .treeChanged(let origin) = $0 { origin } else { nil } }
    }
}

/// Polls `condition` until it holds or `deadline` elapses. Generous by default: FSEvents delivery
/// is not a bounded-latency promise, and a busy CI machine can take seconds.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async {
    let start = ContinuousClock.now
    while ContinuousClock.now - start < deadline {
        if condition() { return }
        try? await Task.sleep(for: .milliseconds(25))
    }
}

/// A fixed quiet period — the shape every "and then *nothing* else happened" assertion takes.
/// Comfortably past `testDebounce` plus `testLatency` plus FSEvents' own delivery slack.
@MainActor
private func quiet(_ duration: Duration = .milliseconds(500)) async {
    try? await Task.sleep(for: duration)
}

/// Short enough to keep the suite quick, long enough to coalesce a burst of writes on a slow
/// machine. Deliberately larger than `testLatency` by an order of magnitude, so the debounce —
/// not FSEvents — is what does the coalescing under test.
private let testDebounce = Duration.milliseconds(100)
private let testLatency = 0.02

/// Gives the freshly created stream a beat to register with `fseventsd` before a test writes.
/// Without it, the first write of a test can land in the window between `FSEventStreamStart` and
/// the stream actually being live — a real (if rare) source of "the first event never arrived".
@MainActor
private func settle() async {
    try? await Task.sleep(for: .milliseconds(300))
}

/// Waits for the stream to go quiet, then throws away whatever arrived before that.
///
/// **Observed, and the reason this helper exists**: `kFSEventStreamEventIdSinceNow` is not the
/// clean line it reads as. Each test creates its temp directory milliseconds before creating the
/// stream, and `fseventsd` assigns that `mkdir` an event id *after* the stream is already live —
/// so a freshly started watcher reliably sees one `.foreign` delivery it did nothing to earn.
/// This is an artefact of watching a directory that was created a moment ago, not a bug: in the
/// app a stream is created over a board folder that has existed for a while, and a spurious
/// reload on open would cost one value-equal snapshot swap anyway.
///
/// Draining is a *wait for quiet*, not a fixed sleep, so a straggler cannot land just after the
/// reset and pollute the test that follows.
@MainActor
private func drainStartupChurn(
    _ log: EventLog,
    quietFor: Duration = .milliseconds(400),
    deadline: Duration = .seconds(5)
) async {
    let start = ContinuousClock.now
    var lastCount = -1
    var lastChange = ContinuousClock.now
    while ContinuousClock.now - start < deadline {
        if log.count != lastCount {
            lastCount = log.count
            lastChange = ContinuousClock.now
        } else if ContinuousClock.now - lastChange >= quietFor {
            break
        }
        try? await Task.sleep(for: .milliseconds(25))
    }
    log.reset()
}

// MARK: - Tests

@MainActor
@Suite("FolderWatcher")
struct FolderWatcherTests {

    // MARK: Ordinary foreign change

    @Test("A single external write delivers exactly one foreign tree change")
    func singleForeignWrite() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        #expect(watcher.isWatching)
        defer { watcher.stop() }
        await drainStartupChurn(log)

        fixture.write("card.md")

        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])
    }

    @Test("A burst of writes coalesces into one delivery")
    func burstCoalesces() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // Ten files back to back: the shape of an agent filing a batch of cards, or a `git
        // checkout` landing a branch's worth of changes.
        for index in 0..<10 {
            fixture.write("card-\(index).md", "body \(index)")
        }

        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])
    }

    // MARK: Brackets

    @Test("An open bracket suppresses events; closing it delivers one app-mediated reload")
    func bracketSuppressesThenDelivers() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        watcher.beginBracket()
        for index in 0..<3 {
            fixture.write("bracketed-\(index).md")
        }
        // Well past debounce + latency: if a bracket leaked, this is where it would show.
        await quiet(.milliseconds(700))
        #expect(log.events.isEmpty)

        watcher.endBracket()
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.appMediated)])
    }

    @Test("Nested brackets deliver once, at the outermost close")
    func nestedBrackets() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        watcher.beginBracket()
        watcher.beginBracket()
        fixture.write("nested.md")
        await quiet(.milliseconds(400))
        #expect(log.events.isEmpty)

        watcher.endBracket()
        await quiet(.milliseconds(400))
        #expect(log.events.isEmpty, "the inner close is not a close — depth is still 1")

        watcher.endBracket()
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.appMediated)])
    }

    @Test("Closing a bracket that saw no filesystem events still delivers the reload")
    func emptyBracketStillReloads() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // The mandatory post-bracket reload: the bracket's contract is "finish with one full
        // reload", not "finish with one reload if something happened".
        watcher.beginBracket()
        watcher.endBracket()

        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.appMediated)])
    }

    // MARK: Reconciliation and origin merge

    @Test("reconcile() delivers a reconciling reload with no filesystem activity")
    func reconcileWithoutFilesystemActivity() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        watcher.reconcile()

        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.reconciling)])
    }

    @Test("A foreign change folding into a reconcile does not downgrade the origin")
    func originMergeKeepsTheStrongerClaim() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        fixture.write("foreign.md")
        // Inside the debounce window, so the two spans coalesce into one delivery: the merged
        // span is genuinely covered by a reconciling reload, and `reconciling` is the honest
        // label for it.
        watcher.reconcile()

        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.reconciling)])
    }

    // MARK: .git filtering

    @Test("Churn inside .git is ignored; ordinary files still arrive")
    func gitInternalChurnIsFiltered() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        fixture.makeDirectory(".git")
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // What an external `git commit` or `git gc` produces — once what the app's own auto-commit
        // produced too, before it was excised (`strategy/01-git-excision.md`, 2026-08-08): pure
        // history churn that cannot alter the rendered tree.
        fixture.write(".git/index", "fake index")
        fixture.write(".git/objects/ab/cdef", "fake object")
        fixture.write(".git/refs/heads/main", "deadbeef")
        await quiet(.milliseconds(700))
        #expect(log.events.isEmpty)

        // …and the stream is demonstrably still alive, which is the other half of the claim: the
        // filter drops events, it does not stop the watcher.
        fixture.write("card.md")
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])
    }

    @Test("A repo nested inside a card folder is filtered like the root's own")
    func nestedGitChurnIsFiltered() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let cardFolder = "1e9a7c5e-0000-4000-8000-000000000000"
        fixture.makeDirectory("\(cardFolder)/.git")
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // A clone living inside a card folder is a stray (01-storage-format.md) whose internals
        // never render — filtered at any depth, not just the board root's own repo (02, settled).
        fixture.write("\(cardFolder)/.git/index", "fake index")
        fixture.write("\(cardFolder)/.git/refs/heads/main", "deadbeef")
        await quiet(.milliseconds(700))
        #expect(log.events.isEmpty)

        // The nested repo's *working files* are ordinary stray paths and still fire.
        fixture.write("\(cardFolder)/README.md")
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])
    }

    // MARK: .trash coverage

    /// **The trash is watched, unlike `.git`** (01-storage-format.md § Deletion, resettled
    /// 2026-07-28). It is the one hidden folder in a board whose contents are *rendered* — a
    /// materialized container of ordinary cards — so a foreign delete, restore or purge has to
    /// reload the board like any other move. The filter tests for a `.git` component specifically
    /// rather than for a dot prefix, and this is the test that keeps it that way.
    @Test("Changes inside .trash are delivered, unlike .git churn")
    func trashChangesAreDelivered() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        fixture.makeDirectory(".git")
        fixture.makeDirectory(".trash")
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // What another device's (or an agent's) delete looks like from here: a card folder
        // appearing inside the container.
        let card = "2b7c9d10-0000-4000-8000-000000000000"
        fixture.write(".trash/\(card)/index.md", "---\nschema: 1\norder: -1024\n---\n")
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])

        // …and a purge of it is a change too, while `.git` in the same board stays filtered.
        log.reset()
        fixture.write(".git/index", "fake index")
        try? FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash/\(card)"))
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)])
    }

    // MARK: Root identity

    @Test("Deleting the watched root delivers rootChanged and tears the stream down")
    func rootDeletionIsReported() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        fixture.remove()

        await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
        #expect(log.events.contains(.rootChanged))
        // The stream is gone with the root it was watching — recovery is a *fresh* stream via
        // `reattach(to:)`, never a resumed one.
        #expect(!watcher.isWatching)
    }

    @Test("A root vanish mid-bracket is owned by the root-change path; endBracket() is a no-op")
    func rootVanishMidBracketSkipsThePostBracketReload() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        watcher.beginBracket()
        fixture.remove()
        // `.rootChanged` bypasses the bracket — the consumer must know *now*.
        await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
        #expect(log.events == [.rootChanged])
        #expect(!watcher.isWatching)

        // The bracket's mandatory final reload is skipped: the stream is gone, and the
        // root-change path owns recovery from here (02, settled) — `reattach(to:)` or the
        // vanished-root lock, each ending in a reload of its own.
        watcher.endBracket()
        await quiet(.milliseconds(700))
        #expect(log.events == [.rootChanged])
    }

    // MARK: Stop

    @Test("Nothing is delivered after stop()")
    func stopIsFinal() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        await drainStartupChurn(log)

        watcher.stop()
        #expect(!watcher.isWatching)

        fixture.write("after-stop.md")
        await quiet(.milliseconds(800))
        #expect(log.events.isEmpty)
    }

    @Test("stop() cancels a delivery that was already pending")
    func stopCancelsPendingDelivery() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: .milliseconds(600), latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        await drainStartupChurn(log)

        // Armed but not yet fired — the window where a debounce could outlive its watcher.
        watcher.reconcile()
        watcher.stop()

        await quiet(.milliseconds(900))
        #expect(log.events.isEmpty)
    }

    // MARK: Reattach

    @Test("reattach() follows the root: one reconciling reload, then the new tree, never the old")
    func reattachFollowsTheRoot() async throws {
        let original = try WatchFixture()
        defer { original.tearDown() }
        let destination = try WatchFixture()
        defer { destination.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // The rename-absorption path: the consumer re-resolved its bookmark to a new location.
        watcher.reattach(to: destination.root)
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.reconciling)])
        #expect(watcher.isWatching)
        await drainStartupChurn(log)

        destination.write("moved-card.md")
        await waitUntil { log.count >= 1 }
        await quiet()
        #expect(log.events == [.treeChanged(.foreign)], "the new root is live")

        // The old location is no longer anyone's board.
        original.write("stale-card.md")
        await quiet(.milliseconds(800))
        #expect(log.count == 1, "the old root is not watched by anything any more")
    }

    @Test("reattach() works after the root vanished from under the watcher")
    func reattachAfterRootChanged() async throws {
        let original = try WatchFixture()
        defer { original.tearDown() }
        let destination = try WatchFixture()
        defer { destination.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }
        #expect(watcher.start())
        defer { watcher.stop() }
        await drainStartupChurn(log)

        original.remove()
        await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
        #expect(!watcher.isWatching)

        // The stream is torn down, and a fresh one attaches cleanly — the "streams die and are
        // recreated, not merely kept" rule, exercised end to end.
        watcher.reattach(to: destination.root)
        #expect(watcher.isWatching)
        await waitUntil { log.origins.contains(.reconciling) }
        #expect(log.origins.contains(.reconciling))
        await settle()

        let countAfterReattach = log.count
        destination.write("card.md")
        await waitUntil { log.count > countAfterReattach }
        #expect(log.count > countAfterReattach)
    }

    // MARK: Nonexistent root

    @Test("Starting on a path that does not exist is not an error, and the stream stays honest")
    func startOnNonexistentPath() async throws {
        let fixture = try WatchFixture(create: false)
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }

        // **Observed FSEvents behaviour, not an aspiration**: `FSEventStreamCreate` and
        // `FSEventStreamStart` both succeed for a path that does not exist — FSEvents watches a
        // path, not an inode, and is perfectly willing to watch one that is not there yet. So
        // `start()` returns `true` and `isWatching` is `true`: the honest answer, because the
        // stream really is live. Existence checking belongs to the caller (the open path already
        // does it); this type's contract is only that it never crashes and never lies about
        // whether it is watching.
        let started = watcher.start()
        #expect(started, "FSEvents watches a path, not an inode — a missing one is fine by it")
        #expect(watcher.isWatching == started)
        defer { watcher.stop() }
        await drainStartupChurn(log)

        // And the path *appearing* is itself a root change under `WatchRoot` — so a watcher
        // started early does not go deaf, it reports the identity change and hands the consumer
        // its ordinary re-resolve-and-`reattach(to:)` job.
        try FileManager.default.createDirectory(at: fixture.root, withIntermediateDirectories: true)
        await waitUntil(.seconds(10)) { !log.events.isEmpty }
        #expect(log.events == [.rootChanged])
        #expect(!watcher.isWatching, "a root change tears the stream down, appearing or vanishing")
    }

    @Test("Every method is safe on a watcher whose stream was never started")
    func methodsAreSafeWithoutAStream() async throws {
        let fixture = try WatchFixture()
        defer { fixture.tearDown() }
        let log = EventLog()
        let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
            log.record($0)
        }

        // The degraded-but-alive contract: a failed `start()` must not turn every later call into
        // a crash. Brackets and reconciles still behave; they simply have no stream prompting
        // them.
        #expect(!watcher.isWatching)
        watcher.endBracket()  // unbalanced: ignored, not a trap
        watcher.beginBracket()
        watcher.endBracket()
        await waitUntil { log.count >= 1 }
        #expect(log.events == [.treeChanged(.appMediated)])

        watcher.stop()
        watcher.stop()
        #expect(!watcher.isWatching)
    }
}
