import { describe, it, expect, vi, beforeEach } from 'vitest'

// Mock persistence + File System Access so we exercise the in-memory store logic.
vi.mock('./attachedFilesDB', () => ({
	putItem: vi.fn(async () => {}),
	deleteItem: vi.fn(async () => {}),
	getItemsForSession: vi.fn(async () => []),
	ensurePersistentStorage: vi.fn(async () => {})
}))

const enumerateDirMock = vi.fn<(h: unknown) => Promise<{ file: File; path: string }[]>>()
vi.mock('./fsAccess', () => ({
	enumerateDir: (h: unknown) => enumerateDirMock(h),
	isIgnoredPath: (p: string) =>
		p.split('/').some((s) => s.startsWith('.') || ['node_modules', 'dist', '.git'].includes(s)),
	queryReadPermission: vi.fn(async () => 'granted'),
	requestReadPermission: vi.fn(async () => 'granted')
}))

// buildLineIndex is real by default; a single test flips to 'manual' to control
// completion ordering and exercise the stale-index race guard.
type BuildResult = { lineIndex: number[]; lineCount: number }
const buildDeferreds: Array<{ file: Blob; resolve: (r: BuildResult) => void }> = []
let buildMode: 'real' | 'manual' = 'real'
vi.mock('./fileEngine', async (importOriginal) => {
	const actual = await importOriginal<typeof import('./fileEngine')>()
	return {
		...actual,
		buildLineIndex: (file: Blob) =>
			buildMode === 'real'
				? actual.buildLineIndex(file)
				: new Promise<BuildResult>((resolve) => buildDeferreds.push({ file, resolve }))
	}
})

import { AttachedFilesStore } from './attachedFiles.svelte'
import { attachedTextFileId } from '../textFileUtils'

function file(name: string, content: string, lastModified = 1): File {
	return new File([content], name, { type: 'text/plain', lastModified })
}

/** A message attachment as the transcript carries it: name + content + stable id. */
function mf(name: string, content: string): { name: string; content: string; id: string } {
	return { name, content, id: attachedTextFileId(name, content) }
}

const dir = { kind: 'directory', name: 'proj' } as unknown as FileSystemDirectoryHandle

async function settle(store: AttachedFilesStore) {
	for (let i = 0; i < 100 && store.list().some((f) => f.status === 'indexing'); i++) {
		await new Promise((r) => setTimeout(r, 2))
	}
}

const names = (store: AttachedFilesStore) =>
	store
		.list()
		.map((f) => f.name)
		.sort()

describe('AttachedFilesStore', () => {
	let store: AttachedFilesStore
	beforeEach(async () => {
		store = new AttachedFilesStore()
		await store.restore('s1', false)
	})

	it('links and indexes individual files as snapshots', async () => {
		await store.addFiles([file('a.txt', 'one\ntwo\n')])
		await settle(store)
		const f = store.get('a.txt')
		expect(f?.status).toBe('ready')
		expect(f?.lineCount).toBe(2)
		expect(f?.handle).toBeUndefined() // files never carry a handle
	})

	it('removes a file', async () => {
		await store.addFiles([file('a.txt', 'x')])
		store.removeFile('a.txt')
		expect(store.count).toBe(0)
	})

	it('links a folder via a directory handle (enumerating it internally)', async () => {
		enumerateDirMock.mockResolvedValue([
			{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' },
			{ file: file('old.ts', 'y\n'), path: 'proj/old.ts' }
		])
		await store.addFolder(dir)
		await settle(store)
		expect(enumerateDirMock).toHaveBeenCalledWith(dir)
		expect(names(store)).toEqual(['proj/app.ts', 'proj/old.ts'])
		expect(store.get('proj/app.ts')?.folder).toBe('proj')
	})

	it('refreshFolders detects rename, add, edit, and delete', async () => {
		enumerateDirMock.mockResolvedValue([
			{ file: file('app.ts', 'x\n', 1), path: 'proj/app.ts' },
			{ file: file('old.ts', 'y\n', 1), path: 'proj/old.ts' }
		])
		await store.addFolder(dir)
		await settle(store)

		// On disk: app.ts edited (mtime bumped), old.ts renamed → new.ts, readme.md added.
		enumerateDirMock.mockResolvedValue([
			{ file: file('app.ts', 'x\nedited\n', 2), path: 'proj/app.ts' },
			{ file: file('new.ts', 'y\n', 1), path: 'proj/new.ts' },
			{ file: file('readme.md', '# hi\n', 1), path: 'proj/readme.md' }
		])
		await store.refreshFolders()
		await settle(store)

		// old.ts dropped (renamed away); new.ts + readme.md added; app.ts kept.
		expect(names(store)).toEqual(['proj/app.ts', 'proj/new.ts', 'proj/readme.md'])
		// edited file re-indexed to its new content (2 lines).
		expect(store.get('proj/app.ts')?.status).toBe('ready')
		expect(store.get('proj/app.ts')?.lineCount).toBe(2)
	})

	it('exposes folders and standalone as structured views', async () => {
		enumerateDirMock.mockResolvedValue([
			{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' },
			{ file: file('b.ts', 'y\n'), path: 'proj/sub/b.ts' }
		])
		await store.addFolder(dir)
		await store.addFiles([file('solo.txt', 'one\n')])
		await settle(store)

		expect(store.folders.map((f) => f.name)).toEqual(['proj'])
		expect(store.folders[0].status).toBe('ready')
		expect(store.folders[0].files.map((f) => f.relPath).sort()).toEqual([
			'proj/app.ts',
			'proj/sub/b.ts'
		])
		expect(store.standalone.map((f) => f.name)).toEqual(['solo.txt'])
		expect(store.lockedCount).toBe(0)
	})

	it('a locked folder surfaces as one folder with no files', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			{
				id: 'src1',
				sessionId: 's1',
				kind: 'dir-handle',
				name: 'proj',
				folder: 'proj',
				handle: dir,
				addedAt: 0
			}
		])
		const { queryReadPermission } = await import('./fsAccess')
		;(queryReadPermission as ReturnType<typeof vi.fn>).mockResolvedValueOnce('prompt')

		const s2 = new AttachedFilesStore()
		await s2.restore('s1', true)

		expect(s2.folders).toEqual([{ name: 'proj', status: 'locked', files: [] }])
		expect(s2.standalone).toEqual([])
		expect(s2.lockedCount).toBe(1)
	})

	it('re-picking a locked folder relinks it instead of silently no-oping', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			{
				id: 'src1',
				sessionId: 's1',
				kind: 'dir-handle',
				name: 'proj',
				folder: 'proj',
				handle: dir,
				addedAt: 0
			}
		])
		const { queryReadPermission } = await import('./fsAccess')
		;(queryReadPermission as ReturnType<typeof vi.fn>).mockResolvedValueOnce('prompt')
		const s2 = new AttachedFilesStore()
		await s2.restore('s1', true)
		expect(s2.folders[0]?.status).toBe('locked')

		enumerateDirMock.mockResolvedValue([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		const result = await s2.addFolder(dir)
		await settle(s2)
		expect(result.added).toEqual(['proj/app.ts'])
		expect(s2.folders).toHaveLength(1)
		expect(s2.folders[0].status).toBe('ready')
	})

	it('rejects linking a second folder with the same name (visible, not silent)', async () => {
		enumerateDirMock.mockResolvedValue([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		await store.addFolder(dir)
		await settle(store)
		const result = await store.addFolder(dir)
		expect(result.added).toEqual([])
		expect(result.rejected[0]?.reason).toMatch(/already linked/)
		expect(store.folders).toHaveLength(1)
	})

	it('regrant keeps the folder visible as unavailable when enumeration fails', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			{
				id: 'src1',
				sessionId: 's1',
				kind: 'dir-handle',
				name: 'proj',
				folder: 'proj',
				handle: dir,
				addedAt: 0
			}
		])
		const { queryReadPermission } = await import('./fsAccess')
		;(queryReadPermission as ReturnType<typeof vi.fn>).mockResolvedValueOnce('prompt')
		const s2 = new AttachedFilesStore()
		await s2.restore('s1', true)
		expect(s2.lockedCount).toBe(1)

		// Permission re-granted, but the directory is gone from disk.
		enumerateDirMock.mockRejectedValueOnce(new Error('directory removed'))
		await s2.regrantLocked()
		expect(s2.folders).toEqual([{ name: 'proj', status: 'unavailable', files: [] }])
	})

	it('regrant of an empty folder keeps it linked and refreshing (not unlinked)', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			{
				id: 'src1',
				sessionId: 's1',
				kind: 'dir-handle',
				name: 'proj',
				folder: 'proj',
				handle: dir,
				addedAt: 0
			}
		])
		const { queryReadPermission } = await import('./fsAccess')
		;(queryReadPermission as ReturnType<typeof vi.fn>).mockResolvedValueOnce('prompt')
		const s2 = new AttachedFilesStore()
		await s2.restore('s1', true)
		expect(s2.lockedCount).toBe(1)

		// Access re-granted, but the folder is currently empty — it must stay linked (ready
		// placeholder), not vanish when the locked placeholder is dropped.
		enumerateDirMock.mockResolvedValueOnce([])
		await s2.regrantLocked()
		expect(s2.folders).toEqual([{ name: 'proj', status: 'ready', files: [] }])
		expect(s2.lockedCount).toBe(0)

		// A file added afterward is picked up — the handle survived.
		enumerateDirMock.mockResolvedValueOnce([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		await s2.refreshFolders()
		await settle(s2)
		expect(s2.folders[0].files.map((f) => f.relPath)).toEqual(['proj/app.ts'])
	})

	it('removeFolder drops all of a folder’s files', async () => {
		enumerateDirMock.mockResolvedValue([
			{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' },
			{ file: file('b.ts', 'y\n'), path: 'proj/b.ts' }
		])
		await store.addFolder(dir)
		store.removeFolder('proj')
		expect(store.count).toBe(0)
	})

	it('snapshots a folder via addFiles (paths), grouping it and persisting folder + relPath', async () => {
		const { putItem } = await import('./attachedFilesDB')
		// A persisted (non-transient) session writes through to IndexedDB immediately.
		const s = new AttachedFilesStore()
		await s.restore('s1', true)
		await s.addFiles([
			{ file: file('a.ts', 'x\n'), path: 'proj/a.ts' },
			{ file: file('b.ts', 'y\n'), path: 'proj/sub/b.ts' }
		])
		await settle(s)
		expect(s.folders.map((f) => f.name)).toEqual(['proj'])
		expect(s.folders[0].files.map((f) => f.relPath).sort()).toEqual(['proj/a.ts', 'proj/sub/b.ts'])
		expect(s.standalone).toEqual([])
		const rec = (putItem as ReturnType<typeof vi.fn>).mock.calls
			.map((c) => c[0])
			.find((r) => r.name === 'proj/a.ts')
		expect(rec).toMatchObject({ kind: 'snapshot', folder: 'proj', relPath: 'proj/a.ts' })
	})

	it('keeps same-basename files from different folder subdirs (dedup by path, not basename)', async () => {
		// Two distinct files with the same basename, size and lastModified, different subdirs.
		const res = await store.addFiles([
			{ file: file('index.ts', 'a\n', 5), path: 'proj/a/index.ts' },
			{ file: file('index.ts', 'a\n', 5), path: 'proj/b/index.ts' }
		])
		await settle(store)
		expect(res.added.sort()).toEqual(['proj/a/index.ts', 'proj/b/index.ts'])
		expect(store.folders[0].files.map((f) => f.relPath).sort()).toEqual([
			'proj/a/index.ts',
			'proj/b/index.ts'
		])
	})

	it('skips junk paths (node_modules/.git/dotfiles) inside a snapshotted folder', async () => {
		const res = await store.addFiles([
			{ file: file('a.ts', 'x\n'), path: 'proj/a.ts' },
			{ file: file('dep.js', 'z\n'), path: 'proj/node_modules/dep.js' },
			{ file: file('cfg', 'w\n'), path: 'proj/.git/config' }
		])
		await settle(store)
		expect(res.added).toEqual(['proj/a.ts'])
		expect(store.folders[0].files).toHaveLength(1)
	})

	it('keeps an explicitly attached standalone dotfile (filter is folder-only)', async () => {
		const res = await store.addFiles([file('.env', 'SECRET=1\n')])
		await settle(store)
		expect(res.added).toEqual(['.env'])
		expect(store.standalone.map((f) => f.name)).toEqual(['.env'])
	})

	it('restores a snapshot folder grouped from its persisted folder/relPath', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			{
				id: 's-a',
				sessionId: 's1',
				kind: 'snapshot',
				name: 'proj/a.ts',
				folder: 'proj',
				relPath: 'proj/a.ts',
				blob: file('a.ts', 'x\n'),
				addedAt: 0
			},
			{
				id: 's-b',
				sessionId: 's1',
				kind: 'snapshot',
				name: 'proj/b.ts',
				folder: 'proj',
				relPath: 'proj/b.ts',
				blob: file('b.ts', 'y\n'),
				addedAt: 0
			}
		])
		const s2 = new AttachedFilesStore()
		await s2.restore('s1', true)
		await settle(s2)
		expect(s2.folders.map((f) => f.name)).toEqual(['proj'])
		expect(s2.folders[0].files).toHaveLength(2)
		expect(s2.standalone).toEqual([])
	})

	it('imposes no file-count cap on a folder', async () => {
		enumerateDirMock.mockResolvedValue(
			Array.from({ length: 150 }, (_, i) => ({
				file: file(`f${i}.ts`, 'x\n'),
				path: `proj/f${i}.ts`
			}))
		)
		await store.addFolder(dir)
		await settle(store)
		expect(store.folders[0].files.length).toBe(150)
	})

	it('removeFolder deletes every snapshot record from storage (persisted session)', async () => {
		const { deleteItem } = await import('./attachedFilesDB')
		const s = new AttachedFilesStore()
		await s.restore('s1', true)
		await s.addFiles([
			{ file: file('a.ts', 'x\n'), path: 'proj/a.ts' },
			{ file: file('b.ts', 'y\n'), path: 'proj/sub/b.ts' }
		])
		await settle(s)
		const ids = s
			.list()
			.filter((f) => f.folder === 'proj')
			.map((f) => f.sourceId)
		expect(ids.length).toBe(2)
		;(deleteItem as ReturnType<typeof vi.fn>).mockClear()
		s.removeFolder('proj')
		expect(s.count).toBe(0)
		const deleted = (deleteItem as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
		for (const id of ids) expect(deleted).toContain(id)
	})

	it('a stale index completion does not corrupt a re-added same-named file', async () => {
		buildMode = 'manual'
		try {
			const A = file('a.txt', 'AAA\n')
			const B = file('a.txt', 'BBB\nBBB\nBBB\n')
			await store.addFiles([A]) // row 'a.txt' (file A) → buildLineIndex(A) pending
			store.removeFile('a.txt')
			await store.addFiles([B]) // new row 'a.txt' (file B) → buildLineIndex(B) pending

			// The old (stale) index for A resolves last — it must NOT touch the row now holding B.
			buildDeferreds.find((d) => d.file === A)!.resolve({ lineIndex: [0], lineCount: 99 })
			await Promise.resolve()
			expect(store.get('a.txt')?.status).toBe('indexing')
			expect(store.get('a.txt')?.lineCount).not.toBe(99)

			// B's own index applies normally.
			buildDeferreds.find((d) => d.file === B)!.resolve({ lineIndex: [0, 4, 8], lineCount: 3 })
			await Promise.resolve()
			expect(store.get('a.txt')?.status).toBe('ready')
			expect(store.get('a.txt')?.lineCount).toBe(3)
		} finally {
			buildMode = 'real'
			buildDeferreds.length = 0
		}
	})

	it('keeps a session row indexable when a same-named message file registers mid-index', async () => {
		buildMode = 'manual'
		try {
			const S = file('notes.md', 'session\n')
			await store.addFiles([S]) // session row 'notes.md' (file S) → index pending

			// A same-named message attachment rebuilds while S is still indexing —
			// neither row is renamed, and neither index is stranded.
			store.syncMessageScoped([mf('notes.md', 'message\n')])

			for (const d of [...buildDeferreds]) d.resolve({ lineIndex: [0], lineCount: 1 })
			await settle(store)

			// Both rows ready: the message row via its id, the session row via name.
			const messageRow = store.resolve(attachedTextFileId('notes.md', 'message\n'))
			expect(messageRow?.messageScoped).toBe(true)
			expect(messageRow?.status).toBe('ready')
			const sessionRow = store.files.find((f) => f.name === 'notes.md' && !f.messageScoped)
			expect(sessionRow?.status).toBe('ready')
		} finally {
			buildMode = 'real'
			buildDeferreds.length = 0
		}
	})

	it('removeFolder deletes the live folder record from storage (persisted session)', async () => {
		const { deleteItem } = await import('./attachedFilesDB')
		const s = new AttachedFilesStore()
		await s.restore('s1', true)
		enumerateDirMock.mockResolvedValue([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		await s.addFolder(dir)
		await settle(s)
		const sourceId = s.list().find((f) => f.folder === 'proj')?.sourceId
		;(deleteItem as ReturnType<typeof vi.fn>).mockClear()
		s.removeFolder('proj')
		expect(s.count).toBe(0)
		expect((deleteItem as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])).toContain(sourceId)
	})

	it('an emptied live folder stays visible and refreshes when files return', async () => {
		enumerateDirMock.mockResolvedValue([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		await store.addFolder(dir)
		await settle(store)
		expect(store.folders.map((f) => f.name)).toEqual(['proj'])

		// Folder emptied on disk → the last child is removed but the folder persists (placeholder).
		enumerateDirMock.mockResolvedValue([])
		await store.refreshFolders()
		await settle(store)
		expect(store.folders).toEqual([{ name: 'proj', status: 'ready', files: [] }])
		expect(store.get('proj/app.ts')).toBeUndefined()
		expect(store.readyFiles()).toEqual([]) // placeholder is never a tool target

		// A file added back on disk is picked up — the live source survived the empty state.
		enumerateDirMock.mockResolvedValue([{ file: file('new.ts', 'y\n'), path: 'proj/new.ts' }])
		await store.refreshFolders()
		await settle(store)
		expect(store.folders[0].files.map((f) => f.relPath)).toEqual(['proj/new.ts'])
	})

	it('an empty-folder placeholder does not collide with a same-named standalone file', async () => {
		enumerateDirMock.mockResolvedValue([]) // empty folder "proj" → creates a placeholder named "proj"
		await store.addFolder(dir)
		await settle(store)

		// A standalone file literally named "proj" must NOT be deduped by the placeholder.
		const res = await store.addFiles([file('proj', 'hello\n')])
		await settle(store)
		expect(res.added).toEqual(['proj'])
		expect(store.standalone.map((f) => f.name)).toEqual(['proj'])
		expect(store.folders.map((f) => f.name)).toEqual(['proj'])

		// Removing that standalone leaves the folder's placeholder intact.
		store.removeFile('proj')
		expect(store.standalone).toEqual([])
		expect(store.folders).toEqual([{ name: 'proj', status: 'ready', files: [] }])
	})

	it('links an initially empty live folder (kept visible, persisted, refreshes)', async () => {
		const { putItem } = await import('./attachedFilesDB')
		const s = new AttachedFilesStore()
		await s.restore('s1', true)
		enumerateDirMock.mockResolvedValue([]) // folder is empty at link time
		const res = await s.addFolder(dir)
		await settle(s)
		expect(res.added).toEqual([])
		expect(s.folders).toEqual([{ name: 'proj', status: 'ready', files: [] }])
		// persisted as a dir-handle so it survives a reload despite being empty
		const persisted = (putItem as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
		expect(persisted.some((r) => r.kind === 'dir-handle' && r.folder === 'proj')).toBe(true)

		// a file added later is picked up — the source existed from the start.
		enumerateDirMock.mockResolvedValue([{ file: file('app.ts', 'x\n'), path: 'proj/app.ts' }])
		await s.refreshFolders()
		await settle(s)
		expect(s.folders[0].files.map((f) => f.relPath)).toEqual(['proj/app.ts'])
	})

	it('a session link and a message attachment share a name independently', async () => {
		await store.addFiles([file('notes.md', 'session content\n')])
		await settle(store)

		store.registerMessageFiles([mf('notes.md', 'message content\n')])
		await settle(store)

		// Two rows, one display name, independently addressable: the message row by
		// its id, the session row by name (its roster handle — a bare-name resolve
		// must return it, never the same-named message attachment shadowing it).
		const messageRow = store.resolve(attachedTextFileId('notes.md', 'message content\n'))
		expect(messageRow?.messageScoped).toBe(true)
		expect(await (messageRow!.file as Blob).text()).toBe('message content\n')
		const sessionRow = store.resolve('notes.md')
		expect(sessionRow?.messageScoped).toBeFalsy()
		expect(await (sessionRow!.file as Blob).text()).toBe('session content\n')

		// A footer removal of the session file must not take the message row along.
		store.removeFile('notes.md')
		expect(store.resolve(messageRow!.id!)?.messageScoped).toBe(true)
		expect(store.standalone).toEqual([])
	})

	it('resolve falls back to a name lookup for legacy references', async () => {
		// Chats persisted before ids existed reference message files by bare name.
		store.registerMessageFiles([mf('notes.md', 'message content\n')])
		await settle(store)
		expect(store.resolve('notes.md')?.messageScoped).toBe(true)
		expect(await (store.resolve('notes.md')!.file as Blob).text()).toBe('message content\n')
	})

	it('resolve accepts the printed composite label verbatim', async () => {
		// Rosters and search hits print `name (file id: x)`; models echo references
		// verbatim, so the printed form must resolve.
		const f = mf('notes.md', 'message content\n')
		store.registerMessageFiles([f])
		await settle(store)
		expect(store.resolve(`notes.md (file id: ${f.id})`)?.id).toBe(f.id)
	})

	it('stores control-char filenames sanitized so the advertised name resolves', async () => {
		// The roster prints sanitized names; the stored name must BE that name or
		// the reference shown to the model would not resolve.
		await store.addFiles([file('a\nb.md', 'controlled\n')])
		await settle(store)
		expect(store.get('a b.md')?.status).toBe('ready')
		expect(store.resolve('a b.md')).toBeDefined()
		expect(store.list().some((f) => f.name.includes('\n'))).toBe(false)
	})

	it('re-linking a control-char filename dedupes against the sanitized stored name', async () => {
		await store.addFiles([file('a\nb.md', 'controlled\n')])
		await settle(store)
		// The duplicate check must compare what is STORED (sanitized), not the raw
		// re-link name — else every control-char file re-links as a "(2)" copy.
		await store.addFiles([file('a\nb.md', 'controlled\n')])
		await settle(store)
		expect(store.standalone.map((f) => f.name)).toEqual(['a b.md'])
	})

	it('links two distinct files whose raw names sanitize identically — equal stats included', async () => {
		// A display-name collision is NOT a duplicate: the re-link identity is the
		// RAW name, so even identical size and mtime cannot collapse two distinct
		// raw names into one row. The second claims a suffixed name.
		await store.addFiles([file('a\nb.md', 'first\n', 1)])
		await settle(store)
		await store.addFiles([file('a\tb.md', 'other\n', 1)]) // same size, same mtime
		await settle(store)
		expect(store.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])

		// Re-link of the suffixed file matches via its raw sourceName.
		await store.addFiles([file('a\tb.md', 'other\n', 1)])
		await settle(store)
		expect(store.standalone).toHaveLength(2)
	})

	it('re-link dedupe survives a reload — raw provenance is persisted', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		const record = (id: string, name: string, sourceName: string, blob: File) => ({
			id,
			sessionId: 's1',
			kind: 'snapshot' as const,
			name,
			sourceName,
			blob,
			size: blob.size,
			lastModified: blob.lastModified,
			addedAt: 1
		})
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			record('r1', 'a b.md', 'a\nb.md', file('a b.md', 'first\n', 1)),
			record('r2', 'a b (2).md', 'a\tb.md', file('a b (2).md', 'other\n', 2))
		])
		const s = new AttachedFilesStore()
		await s.restore('s1', false)
		await settle(s)
		expect(s.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])

		// Re-linking the original file behind the suffixed row must dedupe, not
		// create `a b (3).md` — the raw identity rode the persisted record.
		await s.addFiles([file('a\tb.md', 'other\n', 2)])
		await settle(s)
		expect(s.standalone).toHaveLength(2)
	})

	it('restore claims names, keeping legacy rows that sanitize identically distinct', async () => {
		const { getItemsForSession } = await import('./attachedFilesDB')
		const record = (id: string, name: string) => ({
			id,
			sessionId: 's1',
			kind: 'snapshot' as const,
			name,
			blob: new Blob([`content ${id}\n`]),
			size: 10,
			lastModified: 1,
			addedAt: 1
		})
		;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
			record('r1', 'a\nb.md'),
			record('r2', 'a\tb.md')
		])
		const s = new AttachedFilesStore()
		await s.restore('s1', false)
		await settle(s)
		// Both legacy rows sanitize to `a b.md`; the claim must uniquify so each
		// stays independently visible and resolvable.
		expect(s.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])
		expect(s.resolve('a b.md')).toBeDefined()
		expect(s.resolve('a b (2).md')).toBeDefined()
	})

	it('resolve prefers a literal filename over label interpretation', async () => {
		// A file literally named like a printed label must stay addressable by its
		// exact name — label parsing must not strip it down to the base name.
		await store.addFiles([file('notes (file id: missing)', 'literal\n'), file('notes', 'base\n')])
		await settle(store)
		expect(await (store.resolve('notes (file id: missing)')!.file as Blob).text()).toBe('literal\n')
	})

	it('syncMessageScoped reconciles rows to the transcript references', async () => {
		store.syncMessageScoped([mf('a.md', 'aaa\n'), mf('b.md', 'bbb\n')])
		await settle(store)
		expect(store.messageAttached.map((f) => f.name).sort()).toEqual(['a.md', 'b.md'])

		// A message dropped from the transcript prunes its row; the survivor stays.
		store.syncMessageScoped([mf('a.md', 'aaa\n')])
		await settle(store)
		expect(store.messageAttached.map((f) => f.name)).toEqual(['a.md'])

		store.syncMessageScoped([])
		expect(store.messageAttached).toEqual([])
	})

	it('back-to-back reconciliations commit only the latest set', async () => {
		// Rapid chat switching fires syncs in quick succession; reconciliation is
		// synchronous, so the last call's set simply wins.
		store.syncMessageScoped([mf('old.md', 'OLD sentinel\n')])
		store.syncMessageScoped([mf('new.md', 'NEW sentinel\n')])
		await settle(store)
		expect(store.messageAttached.map((f) => f.name)).toEqual(['new.md'])
	})

	it('syncMessageScoped replaces a stale row under the same name', async () => {
		store.syncMessageScoped([mf('a.md', 'old\n')])
		await settle(store)

		// The transcript's copy changed (edited message): different content means a
		// different id, so the stale row is pruned and the new one registered.
		store.syncMessageScoped([mf('a.md', 'new\n')])
		await settle(store)
		expect(store.messageAttached.map((f) => f.name)).toEqual(['a.md'])
		expect(await (store.get('a.md')!.file as Blob).text()).toBe('new\n')
	})

	it('keeps message-scoped files tool-readable but out of the footer roster', async () => {
		store.registerMessageFiles([mf('notes.md', 'hello\n')])
		await settle(store)

		// Hidden from the session bar, listed for the tools, readable by id.
		expect(store.standalone).toEqual([])
		expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md'])
		expect(store.resolve(attachedTextFileId('notes.md', 'hello\n'))?.status).toBe('ready')

		// Identical re-registration (retry / sync rebuild) reuses the row — same id.
		store.registerMessageFiles([mf('notes.md', 'hello\n')])
		expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md'])

		// A same-named file with DIFFERENT content is another message's attachment:
		// distinct id, its own row, both independently readable under one label.
		store.registerMessageFiles([mf('notes.md', 'other\n')])
		await settle(store)
		expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md', 'notes.md'])
		expect(
			await (store.resolve(attachedTextFileId('notes.md', 'other\n'))!.file as Blob).text()
		).toBe('other\n')
		expect(
			await (store.resolve(attachedTextFileId('notes.md', 'hello\n'))!.file as Blob).text()
		).toBe('hello\n')
	})
})
