// AlasTests/EditorBufferTests.swift
import Testing
import Foundation
import AppKit
@testable import Alas

@MainActor
@Suite(.serialized)
struct EditorBufferTests {
    private func tempWorktree() -> URL {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("alas-buffer-\(UUID().uuidString)")
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }

    private func writeFile(_ root: URL, _ rel: String, _ contents: String, perms: Int = 0o644) throws -> URL {
        let url = root.appendingPathComponent(rel)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        try FileManager.default.setAttributes([.posixPermissions: perms], ofItemAtPath: url.path)
        return url
    }

    @Test func coldLoadCapturesContentMtimeAndPerms() throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "hello\nworld\n", perms: 0o644)
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        #expect(buffer.storage.string == "hello\nworld\n")
        #expect(buffer.originalText == "hello\nworld\n")
        #expect(buffer.lineEnding == .lf)
        #expect(buffer.dirty == false)
        #expect(buffer.permissions == 0o644)
        let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
        let onDisk = attrs[.modificationDate] as? Date
        #expect(buffer.originalMtime == onDisk)
    }

    @Test func coldLoadDetectsCRLF() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "win.txt", "a\r\nb\r\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "win.txt")
        #expect(buffer.lineEnding == .crlf)
        // We canonicalize to LF in memory; save normalizes back.
        #expect(buffer.storage.string == "a\nb\n")
        #expect(buffer.originalText == "a\nb\n")
    }

    @Test func coldLoadOnMissingFileReadsAsErrorAndIsReadOnly() {
        let root = tempWorktree()
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "missing.txt")
        #expect(buffer.storage.string == "(unable to read file)")
        #expect(buffer.readOnly == true)
        #expect(buffer.dirty == false)
    }

    @Test func coldLoadOnNonUTF8FileIsReadOnlyWithClearMessage() throws {
        let root = tempWorktree()
        let url = root.appendingPathComponent("latin1.txt")
        try Data([0x63, 0x61, 0x66, 0xE9]).write(to: url)

        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "latin1.txt")

        #expect(buffer.storage.string == "(read-only: file is not valid UTF-8)")
        #expect(buffer.readOnly == true)
        #expect(buffer.dirty == false)
    }

    @Test func coldLoadResolvesSymlinkToTargetContents() throws {
        let root = tempWorktree()
        let target = root.appendingPathComponent("real.txt")
        try "hello from symlink target\n".write(to: target, atomically: true, encoding: .utf8)

        let linkURL = root.appendingPathComponent("link.txt")
        try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: target)

        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "link.txt")

        #expect(buffer.storage.string == "hello from symlink target\n")
        #expect(buffer.readOnly == true)
        #expect(buffer.dirty == false)
    }

    @Test func saveWritesContentAndClearsDirty() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "hello\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")
        #expect(buffer.dirty == true)
        try buffer.save()
        #expect(buffer.dirty == false)
        let onDisk = try String(contentsOf: root.appendingPathComponent("a.txt"), encoding: .utf8)
        #expect(onDisk == "HELLO\n")
        #expect(buffer.originalText == "HELLO\n")
    }

    @Test func saveAsWritesNewPathAndLeavesOriginalFile() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "hello\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")

        try buffer.saveAs(relativePath: "nested/b.txt")

        #expect(buffer.relativePath == "nested/b.txt")
        #expect(buffer.dirty == false)
        #expect(try String(contentsOf: root.appendingPathComponent("a.txt"), encoding: .utf8) == "hello\n")
        #expect(try String(contentsOf: root.appendingPathComponent("nested/b.txt"), encoding: .utf8) == "HELLO\n")
    }

    @Test func moveToRenamesFileAndKeepsDirtyEdits() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "hello\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")

        try buffer.moveTo(relativePath: "b.txt")

        #expect(buffer.relativePath == "b.txt")
        #expect(buffer.dirty == true)
        #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("a.txt").path))
        #expect(try String(contentsOf: root.appendingPathComponent("b.txt"), encoding: .utf8) == "hello\n")
    }

    @Test func moveToRewritesSnapshotWhenBufferStaysDirty() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "hello\n")
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")
        buffer.snapshotNow()

        try buffer.moveTo(relativePath: "b.txt")

        let snapshot = try store.read(worktreeId: "wt", tabId: "t")
        #expect(snapshot?.relativePath == "b.txt")
        #expect(snapshot?.content == "HELLO\n")
        #expect(buffer.dirty == true)
    }

    @Test func restoreAfterDirtyMoveUsesSnapshotPath() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "hello\n")
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")

        try buffer.moveTo(relativePath: "b.txt")

        let restored = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        #expect(restored.relativePath == "b.txt")
        try restored.save()
        #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("a.txt").path))
        #expect(try String(contentsOf: root.appendingPathComponent("b.txt"), encoding: .utf8) == "HELLO\n")
    }

    @Test func restoreAfterDirtyMoveRefreshesTargetPermissions() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.sh", "#!/bin/sh\necho old\n", perms: 0o644)
        let target = try writeFile(root, "b.sh", "#!/bin/sh\necho old\n", perms: 0o755)
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let attrs = try FileManager.default.attributesOfItem(atPath: target.path)
        let snapshot = EditorBufferStore.Snapshot(
            relativePath: "b.sh",
            content: "#!/bin/sh\necho new\n",
            originalText: "#!/bin/sh\necho old\n",
            originalMtime: (attrs[.modificationDate] as? Date) ?? Date(),
            lineEnding: .lf
        )
        try store.write(snapshot, worktreeId: "wt", tabId: "t")

        let restored = EditorBuffer(worktreeRoot: root, relativePath: "a.sh", store: store, worktreeId: "wt", tabId: "t")
        try restored.save()

        let savedAttrs = try FileManager.default.attributesOfItem(atPath: target.path)
        let perms = (savedAttrs[.posixPermissions] as? NSNumber)?.intValue ?? 0
        #expect(restored.relativePath == "b.sh")
        #expect(perms == 0o755)
    }

    @Test func moveToAllowsCaseOnlyRename() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "case.txt", "hello\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "case.txt")

        try buffer.moveTo(relativePath: "Case.txt")

        #expect(buffer.relativePath == "Case.txt")
        #expect(try String(contentsOf: root.appendingPathComponent("Case.txt"), encoding: .utf8) == "hello\n")
    }

    @Test func moveToRejectsExistingDifferentFile() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "a\n")
        _ = try writeFile(root, "b.txt", "b\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")

        #expect(throws: (any Error).self) {
            try buffer.moveTo(relativePath: "b.txt")
        }

        #expect(buffer.relativePath == "a.txt")
        #expect(try String(contentsOf: root.appendingPathComponent("a.txt"), encoding: .utf8) == "a\n")
        #expect(try String(contentsOf: root.appendingPathComponent("b.txt"), encoding: .utf8) == "b\n")
    }

    @Test func saveCRLFFilePreservesCRLFOnDisk() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "win.txt", "a\r\nb\r\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "win.txt")
        // In-memory storage is canonical LF; user appends a line.
        buffer.storage.replaceCharacters(in: NSRange(location: buffer.storage.length, length: 0), with: "c\n")
        try buffer.save()
        let onDisk = try Data(contentsOf: root.appendingPathComponent("win.txt"))
        #expect(onDisk == Data("a\r\nb\r\nc\r\n".utf8))
        #expect(buffer.dirty == false)
    }

    @Test func savePreservesPosixPermissions() throws {
        let root = tempWorktree()
        let url = try writeFile(root, "exe.sh", "#!/bin/sh\necho hi\n", perms: 0o755)
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "exe.sh")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "# touched\n")
        try buffer.save()
        let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
        let perms = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? 0
        #expect(perms == 0o755)
    }

    @Test func saveOnReadOnlyDirThrowsAndKeepsBufferDirty() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "x")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "more\n")
        try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: root.path)
        defer {
            try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path)
        }
        #expect(throws: (any Error).self) { try buffer.save() }
        #expect(buffer.dirty == true)
        #expect(buffer.originalText == "x")
    }

    @Test func saveRecordingErrorStoresThrownSaveFailure() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "x")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "more\n")
        try FileManager.default.removeItem(at: root)
        #expect(throws: (any Error).self) { try buffer.saveRecordingError() }
        #expect(buffer.lastSaveError?.isEmpty == false)
        #expect(buffer.dirty == true)
    }

    @Test func saveUpdatesMtime() async throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "x")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        let oldMtime = buffer.originalMtime
        // Sleep a tiny bit so HFS/APFS-second-resolution mtimes actually advance.
        try await Task.sleep(nanoseconds: 1_100_000_000)
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "y")
        try buffer.save()
        #expect(buffer.originalMtime > oldMtime)
    }

    @Test func editingFlipsDirtyAndFiresObserver() async {
        let root = tempWorktree()
        _ = try? writeFile(root, "a.txt", "hello\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        var fired = 0
        let token = buffer.onEdit { fired += 1 }
        defer { buffer.removeOnEdit(token) }
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 5), with: "HELLO")
        #expect(buffer.dirty == true)
        #expect(fired == 1)
    }

    @Test func revertReloadsFromDiskAndClearsDirty() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "original\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "junk")
        #expect(buffer.dirty == true)
        buffer.revert()
        #expect(buffer.storage.string == "original\n")
        #expect(buffer.dirty == false)
    }

    @Test func externalChangeWhileCleanReloadsSilently() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        var fired = 0
        let token = buffer.onEdit { fired += 1 }
        defer { buffer.removeOnEdit(token) }
        buffer.startWatching()
        defer { buffer.stopWatching() }
        try await Task.sleep(nanoseconds: 1_100_000_000)
        try "v2\n".write(to: url, atomically: true, encoding: .utf8)
        try await Task.sleep(nanoseconds: 500_000_000)
        #expect(buffer.storage.string == "v2\n")
        #expect(buffer.dirty == false)
        #expect(buffer.conflict == nil)
        #expect(fired == 1)
    }

    @Test func externalRenameReloadsCleanBufferFromMovedFile() async throws {
        let root = tempWorktree()
        let oldURL = try writeFile(root, "a.txt", "v1\n")
        let newURL = root.appendingPathComponent("nested/b.txt")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        var pathChanges: [(String, String)] = []
        buffer.onPathChanged = { oldPath, newPath in pathChanges.append((oldPath, newPath)) }
        buffer.startWatching()
        defer { buffer.stopWatching() }

        try FileManager.default.createDirectory(at: newURL.deletingLastPathComponent(), withIntermediateDirectories: true)
        try FileManager.default.moveItem(at: oldURL, to: newURL)
        let handle = try FileHandle(forWritingTo: newURL)
        try handle.truncate(atOffset: 0)
        try handle.write(contentsOf: Data("v2\n".utf8))
        try handle.close()
        for _ in 0..<20 where buffer.relativePath != "nested/b.txt" {
            try await Task.sleep(nanoseconds: 100_000_000)
        }

        #expect(buffer.relativePath == "nested/b.txt")
        #expect(buffer.storage.string == "v2\n")
        #expect(buffer.originalText == "v2\n")
        #expect(buffer.dirty == false)
        #expect(buffer.conflict == nil)
        #expect(pathChanges.count == 1)
        #expect(pathChanges.first?.0 == "a.txt")
        #expect(pathChanges.first?.1 == "nested/b.txt")
    }

    @Test func externalRenameOfDirtyBufferRaisesConflictWhenMovedFileChanged() async throws {
        let root = tempWorktree()
        let oldURL = try writeFile(root, "a.txt", "v1\n")
        let newURL = root.appendingPathComponent("nested/b.txt")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 2), with: "mine")
        #expect(buffer.dirty == true)
        buffer.startWatching()
        defer { buffer.stopWatching() }

        try FileManager.default.createDirectory(at: newURL.deletingLastPathComponent(), withIntermediateDirectories: true)
        try FileManager.default.moveItem(at: oldURL, to: newURL)
        let handle = try FileHandle(forWritingTo: newURL)
        try handle.truncate(atOffset: 0)
        try handle.write(contentsOf: Data("external\n".utf8))
        try handle.close()
        for _ in 0..<20 where buffer.relativePath != "nested/b.txt" {
            try await Task.sleep(nanoseconds: 100_000_000)
        }

        #expect(buffer.relativePath == "nested/b.txt")
        #expect(buffer.storage.string == "mine\n")
        #expect(buffer.originalText == "v1\n")
        #expect(buffer.dirty == true)
        #expect(buffer.conflict == .changedOnDisk)
    }

    @Test func externalRenameFollowsHiddenFiles() async throws {
        let root = tempWorktree()
        let oldURL = try writeFile(root, ".env", "TOKEN=a\n")
        let newURL = root.appendingPathComponent(".env.local")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: ".env")
        buffer.startWatching()
        defer { buffer.stopWatching() }

        try FileManager.default.moveItem(at: oldURL, to: newURL)
        for _ in 0..<20 where buffer.relativePath != ".env.local" {
            try await Task.sleep(nanoseconds: 100_000_000)
        }

        #expect(buffer.relativePath == ".env.local")
        #expect(buffer.storage.string == "TOKEN=a\n")
        #expect(buffer.conflict == nil)
    }

    @Test func externalChangeWhileDirtyRaisesConflict() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.startWatching()
        defer { buffer.stopWatching() }
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        #expect(buffer.dirty == true)
        try await Task.sleep(nanoseconds: 1_100_000_000)
        try "external\n".write(to: url, atomically: true, encoding: .utf8)
        try await Task.sleep(nanoseconds: 500_000_000)
        #expect(buffer.conflict == .changedOnDisk)
        #expect(buffer.storage.string == "edited v1\n")
        #expect(buffer.dirty == true)
    }

    @Test func resolveConflictByKeepingMineClearsConflictAdvancesMtime() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.startWatching()
        defer { buffer.stopWatching() }
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        try await Task.sleep(nanoseconds: 1_100_000_000)
        try "external\n".write(to: url, atomically: true, encoding: .utf8)
        try await Task.sleep(nanoseconds: 500_000_000)
        let onDiskMtime = (try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate]) as? Date
        buffer.resolveConflictKeepingMine()
        #expect(buffer.conflict == nil)
        #expect(buffer.originalMtime == onDiskMtime)
        #expect(buffer.dirty == true)
    }

    @Test func resolveConflictByReloadingFromDiskReplacesContent() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.startWatching()
        defer { buffer.stopWatching() }
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        buffer.snapshotNow()
        #expect(try store.read(worktreeId: "wt", tabId: "t") != nil)
        try await Task.sleep(nanoseconds: 1_100_000_000)
        try "external\n".write(to: url, atomically: true, encoding: .utf8)
        try await Task.sleep(nanoseconds: 500_000_000)
        buffer.resolveConflictReloadingFromDisk()
        #expect(buffer.conflict == nil)
        #expect(buffer.dirty == false)
        #expect(buffer.storage.string == "external\n")
        #expect(try store.read(worktreeId: "wt", tabId: "t") == nil)
    }

    @Test func deletionOnDiskRaisesDeletedConflict() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        buffer.startWatching()
        defer { buffer.stopWatching() }
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        try FileManager.default.removeItem(at: url)
        try await Task.sleep(nanoseconds: 500_000_000)
        #expect(buffer.conflict == .deletedOnDisk)
        #expect(buffer.dirty == true)
    }

    @Test func snapshotRestoreRoundTrip() async throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "v1\n")
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        buffer.snapshotNow()
        let restored = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        #expect(restored.storage.string == "edited v1\n")
        #expect(restored.dirty == true)
        #expect(restored.originalText == "v1\n")
    }

    @Test func closeSnapshotsThenDiscardsOnSave() async throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "v1\n")
        let storeRoot = tempWorktree()
        let store = EditorBufferStore(rootOverride: storeRoot)
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "x")
        buffer.close()
        #expect(try store.read(worktreeId: "wt", tabId: "t") != nil)
        let again = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        try again.save()
        #expect(try store.read(worktreeId: "wt", tabId: "t") == nil)
    }

    @Test func snapshotNowDiscardsStaleSnapshotWhenBufferIsClean() async throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "v1\n")
        let store = EditorBufferStore(rootOverride: tempWorktree())
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        buffer.snapshotNow()
        #expect(try store.read(worktreeId: "wt", tabId: "t") != nil)

        buffer.storage.setAttributedString(NSAttributedString(string: "v1\n"))
        buffer.snapshotNow()

        #expect(buffer.dirty == false)
        #expect(try store.read(worktreeId: "wt", tabId: "t") == nil)
    }

    @Test func restoreOnDifferentMtimeRaisesConflict() async throws {
        let root = tempWorktree()
        let url = try writeFile(root, "a.txt", "v1\n")
        let storeRoot = tempWorktree()
        let store = EditorBufferStore(rootOverride: storeRoot)
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "edited ")
        buffer.snapshotNow()
        try await Task.sleep(nanoseconds: 1_100_000_000)
        try "v2\n".write(to: url, atomically: true, encoding: .utf8)
        let restored = EditorBuffer(worktreeRoot: root, relativePath: "a.txt", store: store, worktreeId: "wt", tabId: "t")
        restored.startWatching()
        defer { restored.stopWatching() }
        restored.checkForConflictOnRestore()
        #expect(restored.conflict == .changedOnDisk)
    }

    @Test func coordinatorDetachRemovesMountedLayoutManager() throws {
        let root = tempWorktree()
        _ = try writeFile(root, "a.txt", "v1\n")
        let buffer = EditorBuffer(worktreeRoot: root, relativePath: "a.txt")
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: AppState())
        let theme = try ThemeStore().current

        coordinator.attach(
            textView: textView,
            buffer: buffer,
            layoutManager: layoutManager,
            worktreeId: "wt",
            worktreeRoot: root,
            tabId: "t",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme
        )
        #expect(buffer.storage.layoutManagers.contains { $0 === layoutManager })

        coordinator.detach()

        #expect(!buffer.storage.layoutManagers.contains { $0 === layoutManager })
    }

    @Test func coordinatorAppliesMonospacedFontToLoadedContent() throws {
        // Regression: after rebinding, applyBaseStyle was reading
        // `textView.font` whose getter falls back to the system default font
        // (proportional) when the freshly bound storage has no `.font`
        // attribute on char 0. The styled storage ended up with a
        // proportional font for existing content while typing remained
        // monospaced.
        let root = tempWorktree()
        _ = try writeFile(root, "a.swift", "let answer = 42\n")
        let appState = AppState()
        // Use a guaranteed-available system monospace font so the test
        // doesn't depend on whether the bundled Nerd Font is registered
        // in the test process.
        appState.config.code.fontFamily = "Menlo"
        let buffer = appState.tabs.buffer(
            worktreeId: "wt",
            tabId: "tab",
            worktreeRoot: root,
            relativePath: "a.swift"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current

        coordinator.attach(
            textView: textView,
            buffer: buffer,
            layoutManager: layoutManager,
            worktreeId: "wt",
            worktreeRoot: root,
            tabId: "tab",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme
        )

        let appliedFont = buffer.storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont
        #expect(appliedFont != nil)
        #expect(appliedFont?.isFixedPitch == true)
    }

    @Test func coordinatorPathChangeRebindsLayoutManagerToNewBufferStorage() throws {
        // Regression: when the active editor tab switched, the coordinator
        // updated its bookkeeping but never moved the layout manager off the
        // first buffer's NSTextStorage, so the text view kept rendering the
        // first file's contents for every subsequent tab.
        let root = tempWorktree()
        _ = try writeFile(root, "a.md", "alpha\n")
        _ = try writeFile(root, "b.swift", "let beta = 1\n")
        let appState = AppState()
        appState.config.code.fontFamily = "Menlo"
        let bufferA = appState.tabs.buffer(
            worktreeId: "wt",
            tabId: "tab-a",
            worktreeRoot: root,
            relativePath: "a.md"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current

        coordinator.attach(
            textView: textView,
            buffer: bufferA,
            layoutManager: layoutManager,
            worktreeId: "wt",
            worktreeRoot: root,
            tabId: "tab-a",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme
        )
        #expect(bufferA.storage.layoutManagers.contains { $0 === layoutManager })

        coordinator.updateIfNeeded(
            worktreeId: "wt",
            worktreeRoot: root,
            relativePath: "b.swift",
            tabId: "tab-b",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme
        )

        let bufferB = appState.tabs.buffer(
            worktreeId: "wt",
            tabId: "tab-b",
            worktreeRoot: root,
            relativePath: "b.swift"
        )
        #expect(!bufferA.storage.layoutManagers.contains { $0 === layoutManager })
        #expect(bufferB.storage.layoutManagers.contains { $0 === layoutManager })
        // The newly bound storage must come back styled monospaced. The
        // original bug here was that applyBaseStyle resolved the font from
        // textView.font, which after rebinding read char 0 of the new
        // (unstyled) storage and fell back to the proportional system font.
        let fontB = bufferB.storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont
        #expect(fontB?.isFixedPitch == true)
    }

    @Test func coordinatorRebindsActiveExternalEditorWhenLanguageAppears() throws {
        let root = tempWorktree()
        let externalURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("external-language-\(UUID().uuidString).foo")
        try "value\n".write(to: externalURL, atomically: true, encoding: .utf8)
        defer { try? FileManager.default.removeItem(at: externalURL) }

        let appState = AppState()
        let buffer = appState.tabs.externalBuffer(
            worktreeId: "wt",
            tabId: "external-tab",
            absoluteURL: externalURL,
            worktreeRoot: root,
            originatingFileURL: nil,
            language: nil
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current

        coordinator.attach(
            textView: textView,
            buffer: buffer,
            layoutManager: layoutManager,
            worktreeId: "wt",
            worktreeRoot: root,
            tabId: "external-tab",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme,
            externalAbsolutePath: externalURL.path,
            originatingRelativePath: nil
        )
        #expect(textView.indentationMode == .plain)

        appState.lsp.updateRegistry(LanguageServerRegistry(userDefined: [
            LanguageServerConfig(
                language: "foo",
                extensions: ["foo"],
                command: "foo-lsp",
                args: [],
                env: [:],
                rootMarkers: [".git"],
                enabled: true
            )
        ]))

        coordinator.updateIfNeeded(
            worktreeId: "wt",
            worktreeRoot: root,
            relativePath: externalURL.lastPathComponent,
            tabId: "external-tab",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme,
            externalAbsolutePath: externalURL.path,
            originatingRelativePath: nil
        )

        #expect(textView.indentationMode == .bracketAware)
    }

    @Test func coordinatorPathChangeDropsStaleDiagnostics() throws {
        // Regression: runHighlight captures diagnosticsFeature.current at the
        // start of its async task; if we don't reset before the rebind, the
        // task can re-apply the previous file's diagnostics onto the newly
        // bound storage.
        let root = tempWorktree()
        _ = try writeFile(root, "a.swift", "let a = 1\n")
        _ = try writeFile(root, "b.swift", "let b = 2\n")
        let appState = AppState()
        let bufferA = appState.tabs.buffer(
            worktreeId: "wt", tabId: "tab-a",
            worktreeRoot: root, relativePath: "a.swift"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current
        coordinator.attach(
            textView: textView, buffer: bufferA, layoutManager: layoutManager,
            worktreeId: "wt", worktreeRoot: root, tabId: "tab-a",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        // LSPDiagnostic only has a Codable init, so build via JSON.
        let json = #"{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}},"severity":1,"message":"boom"}"#
        let fakeDiag = try JSONDecoder().decode(LSPDiagnostic.self, from: Data(json.utf8))
        coordinator.diagnosticsFeature.apply([fakeDiag], to: bufferA.storage, theme: theme)
        #expect(coordinator.diagnosticsFeature.current.count == 1)

        coordinator.updateIfNeeded(
            worktreeId: "wt", worktreeRoot: root,
            relativePath: "b.swift", tabId: "tab-b",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        #expect(coordinator.diagnosticsFeature.current.isEmpty)
    }

    @Test func coordinatorRebindRestoresCachedDiagnosticsForKnownURI() throws {
        // Regression: the LSP server doesn't replay past publishDiagnostics
        // batches to new subscribers. Without an in-coordinator cache, a
        // tab switch to a previously-open file would lose its squiggles
        // until the server happened to publish again. We cache every batch
        // we see and restore from it on rebind.
        let root = tempWorktree()
        _ = try writeFile(root, "a.swift", "let a = 1\n")
        _ = try writeFile(root, "b.swift", "let b = 2\n")
        let appState = AppState()
        let bufferA = appState.tabs.buffer(
            worktreeId: "wt", tabId: "tab-a",
            worktreeRoot: root, relativePath: "a.swift"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current
        coordinator.attach(
            textView: textView, buffer: bufferA, layoutManager: layoutManager,
            worktreeId: "wt", worktreeRoot: root, tabId: "tab-a",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        // Seed the per-URI cache as if the LSP had published diagnostics
        // for A while we were attached.
        let uriA = root.appendingPathComponent("a.swift").lspURI
        let json = #"{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}},"severity":1,"message":"boom"}"#
        let fakeDiag = try JSONDecoder().decode(LSPDiagnostic.self, from: Data(json.utf8))
        coordinator.lastDiagnosticsByURI[uriA] = [fakeDiag]
        coordinator.diagnosticsFeature.apply([fakeDiag], to: bufferA.storage, theme: theme)
        #expect(bufferA.storage.attribute(.underlineStyle, at: 0, effectiveRange: nil) != nil)

        // Switch to B (no cached diagnostics for B), then back to A.
        coordinator.updateIfNeeded(
            worktreeId: "wt", worktreeRoot: root,
            relativePath: "b.swift", tabId: "tab-b",
            revealLine: nil, revealCharacter: nil, theme: theme
        )
        coordinator.updateIfNeeded(
            worktreeId: "wt", worktreeRoot: root,
            relativePath: "a.swift", tabId: "tab-a",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        // Cached diagnostics for A should be re-applied to its storage —
        // the underline attribute the diagnosticsFeature paints should be
        // present again, even though no LSP server is running in this
        // headless test.
        let underline = bufferA.storage.attribute(.underlineStyle, at: 0, effectiveRange: nil)
        #expect(underline != nil)
    }

    @Test func coordinatorIgnoresDiagnosticsBatchForNonActiveURI() throws {
        // Regression: cancelling the old diagnostics subscription on tab
        // switch doesn't synchronously drain in-flight batches. A batch
        // delivered after the rebind would otherwise pass the captured-URI
        // guard and paint onto the *new* buffer's storage. The fix checks
        // the batch URI against the currently bound buffer's URI.
        let root = tempWorktree()
        _ = try writeFile(root, "a.swift", "let a = 1\n")
        _ = try writeFile(root, "b.swift", "let b = 2\n")
        let appState = AppState()
        let bufferA = appState.tabs.buffer(
            worktreeId: "wt", tabId: "tab-a",
            worktreeRoot: root, relativePath: "a.swift"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current
        coordinator.attach(
            textView: textView, buffer: bufferA, layoutManager: layoutManager,
            worktreeId: "wt", worktreeRoot: root, tabId: "tab-a",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        // Build a batch addressed to file B but feed it to the coordinator
        // while A is still the active buffer — simulating an in-flight
        // batch that beat the rebind.
        let uriB = root.appendingPathComponent("b.swift").lspURI
        let json = #"{"uri":"\#(uriB)","diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}},"severity":1,"message":"boom"}]}"#
        let batch = try JSONDecoder().decode(LSPPublishDiagnosticsParams.self, from: Data(json.utf8))

        coordinator.processDiagnosticsBatch(batch, theme: theme)

        // Cache must be updated regardless of the active buffer …
        #expect(coordinator.lastDiagnosticsByURI[uriB]?.count == 1)
        // … but the batch must not paint onto A's storage.
        #expect(bufferA.storage.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
    }

    @Test func externalBufferLoadsContentsAndIsReadOnly() throws {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("alas-ext-\(UUID().uuidString).h")
        try "external content\n".write(to: url, atomically: true, encoding: .utf8)
        defer { try? FileManager.default.removeItem(at: url) }

        let buffer = EditorBuffer(externalAbsoluteURL: url)
        #expect(buffer.storage.string == "external content\n")
        #expect(buffer.isExternal == true)
        #expect(buffer.dirty == false)
    }

    @Test func externalBufferSaveIsNoOp() throws {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("alas-ext-\(UUID().uuidString).h")
        try "x\n".write(to: url, atomically: true, encoding: .utf8)
        defer { try? FileManager.default.removeItem(at: url) }
        let buffer = EditorBuffer(externalAbsoluteURL: url)
        // Mutate storage as if user typed (test-only — production sets isEditable=false on the view)
        buffer.storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "INJECTED ")
        // save() returns Void and is a no-op for external buffers (readOnly=true guard fires).
        try buffer.save()
        let onDisk = try String(contentsOf: url, encoding: .utf8)
        #expect(onDisk == "x\n")
    }

    @Test func coordinatorPathChangeClearsTextViewUndoStack() throws {
        // Regression: NSTextView's undoManager survives across tab swaps
        // because CenterPaneView reuses the same text view. Without an
        // explicit removeAllActions on rebind, Undo would mutate the wrong
        // buffer's storage.
        let root = tempWorktree()
        _ = try writeFile(root, "a.swift", "let a = 1\n")
        _ = try writeFile(root, "b.swift", "let b = 2\n")
        let appState = AppState()
        let bufferA = appState.tabs.buffer(
            worktreeId: "wt", tabId: "tab-a",
            worktreeRoot: root, relativePath: "a.swift"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 800, height: 600))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), textContainer: textContainer)
        // NSTextView's undoManager comes from the responder chain; in this
        // headless test we don't have a window, so feed one in via a
        // delegate so the rebind path has something concrete to clear.
        let undoOwner = TestUndoOwner()
        textView.delegate = undoOwner
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current
        coordinator.attach(
            textView: textView, buffer: bufferA, layoutManager: layoutManager,
            worktreeId: "wt", worktreeRoot: root, tabId: "tab-a",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        // Stage an undoable action so canUndo flips on; payload is irrelevant.
        textView.undoManager?.registerUndo(withTarget: bufferA) { _ in }
        #expect(textView.undoManager?.canUndo == true)

        coordinator.updateIfNeeded(
            worktreeId: "wt", worktreeRoot: root,
            relativePath: "b.swift", tabId: "tab-b",
            revealLine: nil, revealCharacter: nil, theme: theme
        )

        #expect(textView.undoManager?.canUndo == false)
    }
}

@MainActor
private final class TestUndoOwner: NSObject, NSTextViewDelegate {
    let undoManager = UndoManager()
    func undoManager(for view: NSTextView) -> UndoManager? { undoManager }
}

@Suite("EditorBuffer.languageOverride")
@MainActor
struct EditorBufferLanguageOverrideTests {
    private func buffer(language: String?) -> EditorBuffer {
        let buf = EditorBuffer(worktreeRoot: URL(fileURLWithPath: "/tmp/repo"), relativePath: "main.swift")
        buf.setLanguageForTest(language)
        return buf
    }

    @Test func effectiveLanguageFallsBackToInferred() {
        let buf = buffer(language: "swift")
        #expect(buf.effectiveLanguage == "swift")
    }

    @Test func effectiveLanguageUsesOverrideWhenSet() {
        let buf = buffer(language: "swift")
        buf.languageOverride = "typescript"
        #expect(buf.effectiveLanguage == "typescript")
    }

    @Test func effectiveLanguageClearsBackToInferredOnNilOverride() {
        let buf = buffer(language: "swift")
        buf.languageOverride = "typescript"
        buf.languageOverride = nil
        #expect(buf.effectiveLanguage == "swift")
    }

    /// Verifies the reactive plumbing in `CodeEditorCoordinator`: when a tab
    /// flips its `languageOverride`, the coordinator's `observeEffectiveLanguage`
    /// observer should fire, update `currentLanguage`, and reapply the
    /// indentation mode. Checking `textView.indentationMode` is the cheapest
    /// visible side-effect to assert on (currentLanguage itself is private).
    @Test func coordinatorObserverRefreshesIndentationOnLanguageOverride() async throws {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("alas-lsp-override-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        let fileURL = root.appendingPathComponent("scratch.unknown")
        try "x\n".write(to: fileURL, atomically: true, encoding: .utf8)
        defer { try? FileManager.default.removeItem(at: root) }

        let appState = AppState()
        let buffer = appState.tabs.buffer(
            worktreeId: "wt-override", tabId: "tab-override",
            worktreeRoot: root, relativePath: "scratch.unknown"
        )
        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: NSSize(width: 400, height: 300))
        layoutManager.addTextContainer(textContainer)
        let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), textContainer: textContainer)
        let coordinator = CodeEditorCoordinator(appState: appState)
        let theme = try ThemeStore().current

        coordinator.attach(
            textView: textView,
            buffer: buffer,
            layoutManager: layoutManager,
            worktreeId: "wt-override",
            worktreeRoot: root,
            tabId: "tab-override",
            revealLine: nil,
            revealCharacter: nil,
            theme: theme
        )
        // No registry entry for `.unknown` and no override → currentLanguage nil → plain.
        #expect(textView.indentationMode == .plain)

        buffer.languageOverride = "swift"

        // The override observer dispatches its updates through a `Task { @MainActor in }`
        // so we yield until the indentation flips (or the deadline trips).
        let deadline = Date().addingTimeInterval(2)
        while Date() < deadline, textView.indentationMode != .bracketAware {
            await Task.yield()
        }
        #expect(textView.indentationMode == .bracketAware)
    }
}
