import { describe, expect, it } from 'vitest'
import { createTLSchema } from '../createTLSchema'
import { toRichText } from '../misc/TLRichText'
import {
	commentReactionRecordConfig,
	commentRecordConfig,
	commentSchemaRecords,
	commentThreadRecordConfig,
	createComment,
	createCommentId,
	createCommentReaction,
	createCommentReactionId,
	createCommentThread,
	createCommentThreadId,
	isCommentId,
	isCommentReactionId,
	isCommentThreadId,
	TLCommentAnchor,
} from './TLComment'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'

const pageId = 'page:page1' as TLPageId
const shapeId = 'shape:box1' as TLShapeId

const anchors: TLCommentAnchor[] = [
	{ type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
	{ type: 'point', x: 100, y: 200 },
	{ type: 'region', x: 0, y: 0, w: 300, h: 150 },
	{ type: 'page' },
]

describe('TLCommentThread', () => {
	it.each(anchors)('creates a valid thread anchored to %j', (anchor) => {
		const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 })
		expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread)
		expect(thread).toMatchObject({
			typeName: 'comment-thread',
			pageId,
			anchor,
			createdAt: 1000,
			resolved: null,
			meta: {},
		})
	})

	it('validates resolution state', () => {
		const thread = createCommentThread({
			pageId,
			anchor: { type: 'page' },
			createdBy: 'user1',
			now: 1000,
		})
		const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } }
		expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved)
		expect(() =>
			commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } })
		).toThrow()
	})

	it('rejects unknown anchor kinds and malformed anchors', () => {
		const thread = createCommentThread({
			pageId,
			anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
			createdBy: 'user1',
			now: 1000,
		})
		expect(() =>
			commentThreadRecordConfig.validator.validate({
				...thread,
				anchor: { type: 'volume', level: 11 },
			})
		).toThrow()
		expect(() =>
			commentThreadRecordConfig.validator.validate({
				...thread,
				// shape anchors must reference a shape id
				anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false },
			})
		).toThrow()
	})
})

describe('TLComment', () => {
	const threadId = createCommentThreadId('thread1')

	it('creates a valid comment with a rich text body', () => {
		const comment = createComment({
			threadId,
			pageId,
			authorId: 'user1',
			body: toRichText('hello'),
			now: 1000,
		})
		expect(commentRecordConfig.validator.validate(comment)).toEqual(comment)
		expect(comment).toMatchObject({
			typeName: 'comment',
			threadId,
			pageId,
			createdAt: 1000,
			editedAt: null,
			isDeleted: false,
		})
	})

	it('validates soft-deletion state', () => {
		const comment = createComment({
			threadId,
			pageId,
			authorId: 'user1',
			body: toRichText('hello'),
			now: 1000,
		})
		const deleted = { ...comment, isDeleted: true }
		expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted)
		expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow()
	})

	it('accepts an editedAt timestamp after edits', () => {
		const comment = createComment({
			threadId,
			pageId,
			authorId: 'user1',
			body: toRichText('hello'),
			now: 1000,
		})
		const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') }
		expect(commentRecordConfig.validator.validate(edited)).toEqual(edited)
	})

	it('rejects a threadId that is not a comment-thread id', () => {
		const comment = createComment({
			threadId,
			pageId,
			authorId: 'user1',
			body: toRichText('hello'),
			now: 1000,
		})
		expect(() =>
			commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' })
		).toThrow()
	})
})

describe('schema registration', () => {
	it('registers both record types via commentSchemaRecords', () => {
		const schema = createTLSchema({ records: commentSchemaRecords })
		// custom record types aren't part of the default TLRecord union, so index untyped
		const types = schema.types as Record<string, { scope: string } | undefined>
		expect(types['comment-thread']?.scope).toBe('document')
		expect(types['comment']?.scope).toBe('document')
	})
})

describe('isCommentThreadId / isCommentId', () => {
	it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => {
		expect(isCommentThreadId(createCommentThreadId())).toBe(true)
		expect(isCommentThreadId(createCommentId())).toBe(false)
		expect(isCommentThreadId('shape:box1')).toBe(false)
		expect(isCommentThreadId('not-an-id')).toBe(false)
	})

	it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => {
		expect(isCommentId(createCommentId())).toBe(true)
		expect(isCommentId(createCommentThreadId())).toBe(false)
		expect(isCommentId('shape:box1')).toBe(false)
		expect(isCommentId('not-an-id')).toBe(false)
	})
})

describe('comment-thread migrations', () => {
	const migration = (commentThreadRecordConfig.migrations as any).sequence.find(
		(m: any) => m.id === 'com.tldraw.comment-thread/2'
	) as { up(r: any): any; down(r: any): any }

	it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => {
		expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({
			anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
		})
	})

	it('leaves shape anchors that already have x/y unchanged', () => {
		const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true }
		expect(migration.up({ anchor })).toEqual({ anchor })
	})

	it('leaves non-shape anchors untouched', () => {
		const anchor = { type: 'point', x: 5, y: 6 }
		expect(migration.up({ anchor })).toEqual({ anchor })
	})

	it('down-migrates by dropping x/y', () => {
		expect(
			migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } })
		).toEqual({
			anchor: { type: 'shape', shapeId },
		})
	})
})

describe('comment-reaction record', () => {
	it('createCommentReaction derives its id from the comment, user, and emoji', () => {
		const commentId = createCommentId('c1')
		const make = (emoji: string) =>
			createCommentReaction({
				commentId,
				threadId: createCommentThreadId('t1'),
				pageId,
				userId: 'user1',
				emoji,
			})
		// same emoji → same record (re-picking toggles it); different emoji → its own record (multi)
		expect(make('👍').id).toBe(make('👍').id)
		expect(make('👍').id).not.toBe(make('🎉').id)
		expect(isCommentReactionId(make('👍').id)).toBe(true)
	})

	it('validates a well-formed reaction record', () => {
		const reaction = createCommentReaction({
			commentId: createCommentId('c1'),
			threadId: createCommentThreadId('t1'),
			pageId,
			userId: 'user1',
			emoji: '👍',
		})
		expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction)
	})

	it('rejects an empty or oversized emoji token', () => {
		const reaction = createCommentReaction({
			commentId: createCommentId('c1'),
			threadId: createCommentThreadId('t1'),
			pageId,
			userId: 'user1',
			emoji: '👍',
		})
		// the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id
		expect(() =>
			commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' })
		).toThrow()
		expect(() =>
			commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) })
		).toThrow()
	})

	it('createCommentReactionId is injective when a part contains a colon', () => {
		// two different triples that a naive slice+join would collapse onto one id:
		// comment "foo" + user "bar:baz" + 👍   vs   comment "foo:bar" + user "baz" + 👍
		const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍')
		const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍')
		expect(a).not.toBe(b)
		expect(isCommentReactionId(a)).toBe(true)
		expect(isCommentReactionId(b)).toBe(true)
	})
})
