import { describe, expect, it } from '@jest/globals';
import { Y } from '@wordpress/sync';
import {
	asHtmlStringIndex,
	asRichTextOffset,
	getYTextByAttributeKey,
	htmlIndexToRichTextOffset as typedHtmlIndexToRichTextOffset,
	richTextOffsetToHtmlIndex as typedRichTextOffsetToHtmlIndex,
} from '../crdt-utils';

function htmlIndexToRichTextOffset( html: string, htmlIndex: number ) {
	return typedHtmlIndexToRichTextOffset(
		html,
		asHtmlStringIndex( htmlIndex )
	);
}

function richTextOffsetToHtmlIndex( html: string, richTextOffset: number ) {
	return typedRichTextOffsetToHtmlIndex(
		html,
		asRichTextOffset( richTextOffset )
	);
}

function createAttachedAttributes(): Y.Map< unknown > {
	const ydoc = new Y.Doc();
	const root = ydoc.getMap( 'test' );
	const attributes = new Y.Map< unknown >();
	root.set( 'attributes', attributes );
	return attributes;
}

describe( 'getYTextByAttributeKey', () => {
	it( 'returns a top-level rich-text attribute', () => {
		const attributes = createAttachedAttributes();
		const text = new Y.Text( 'Top level' );
		attributes.set( 'content', text );

		expect( getYTextByAttributeKey( attributes, 'content' ) ).toBe( text );
	} );

	it( 'returns a nested rich-text attribute by dot path', () => {
		const attributes = createAttachedAttributes();
		const body = new Y.Array< Y.Map< unknown > >();
		const row = new Y.Map< unknown >();
		const cells = new Y.Array< Y.Map< unknown > >();
		const cell = new Y.Map< unknown >();
		const text = new Y.Text( 'Cell text' );

		cell.set( 'content', text );
		cells.push( [ cell ] );
		row.set( 'cells', cells );
		body.push( [ row ] );
		attributes.set( 'body', body );

		expect(
			getYTextByAttributeKey( attributes, 'body.0.cells.0.content' )
		).toBe( text );
	} );

	it( 'returns null for invalid array path segments', () => {
		const attributes = createAttachedAttributes();
		const body = new Y.Array< Y.Map< unknown > >();
		attributes.set( 'body', body );

		expect(
			getYTextByAttributeKey( attributes, 'body.01.cells.0.content' )
		).toBeNull();
		expect(
			getYTextByAttributeKey( attributes, 'body.-1.cells.0.content' )
		).toBeNull();
	} );
} );

describe( 'htmlIndexToRichTextOffset', () => {
	it( 'returns the index unchanged when there are no tags', () => {
		expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 );
	} );

	it( 'returns 0 for index 0', () => {
		expect(
			htmlIndexToRichTextOffset( '<strong>bold</strong> text', 0 )
		).toBe( 0 );
	} );

	it( 'skips a simple opening tag', () => {
		// "<strong>bold</strong> text"
		// HTML index 8 = first char of "bold" (right after <strong>)
		expect(
			htmlIndexToRichTextOffset( '<strong>bold</strong> text', 8 )
		).toBe( 0 );
	} );

	it( 'counts text characters inside a tag', () => {
		// "<strong>bold</strong> text"
		// HTML index 10 = 'l' in "bold" (after "bo")
		// Text: "bold text", offset should be 2
		expect(
			htmlIndexToRichTextOffset( '<strong>bold</strong> text', 10 )
		).toBe( 2 );
	} );

	it( 'skips closing tags', () => {
		// "<strong>bold</strong> text"
		// HTML index 21 = ' ' (the space after </strong>)
		// Text: "bold text", offset should be 4
		expect(
			htmlIndexToRichTextOffset( '<strong>bold</strong> text', 21 )
		).toBe( 4 );
	} );

	it( 'handles text before a tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 3 = 'e' in "some"
		expect(
			htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 3 )
		).toBe( 3 );
	} );

	it( 'handles an index right before a tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 5 = '<' of <strong>
		expect(
			htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 5 )
		).toBe( 5 );
	} );

	it( 'handles an index right after an opening tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 13 = 'w' of "words" (right after <strong>)
		expect(
			htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 13 )
		).toBe( 5 );
	} );

	it( 'handles an index at the end of formatted text', () => {
		// "some <strong>words</strong> test"
		// HTML index 18 = '<' of </strong>
		// Text offset should be 10 ("some words")
		expect(
			htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 18 )
		).toBe( 10 );
	} );

	it( 'handles an index after the closing tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 27 = ' ' after </strong>
		expect(
			htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 27 )
		).toBe( 10 );
	} );

	it( 'handles the end of the string', () => {
		const html = 'some <strong>words</strong> test';
		expect( htmlIndexToRichTextOffset( html, html.length ) ).toBe( 15 );
	} );

	it( 'handles nested tags', () => {
		// "a<strong><em>b</em></strong>c"
		// Text: "abc"
		// HTML index 13 = 'b' (after <strong><em>)
		expect(
			htmlIndexToRichTextOffset( 'a<strong><em>b</em></strong>c', 13 )
		).toBe( 1 );
		// HTML index 28 = 'c' (after </strong>)
		expect(
			htmlIndexToRichTextOffset( 'a<strong><em>b</em></strong>c', 28 )
		).toBe( 2 );
	} );

	it( 'handles tags with attributes', () => {
		// '<a href="https://example.com">link</a> text'
		// HTML index 30 = 'l' in "link"
		expect(
			htmlIndexToRichTextOffset(
				'<a href="https://example.com">link</a> text',
				30
			)
		).toBe( 0 );
	} );

	it( 'handles HTML entity &amp;', () => {
		// "Tom &amp; Jerry"
		// Text: "Tom & Jerry" (11 chars)
		// HTML index 4 = '&' start of &amp;
		expect( htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 4 ) ).toBe( 4 );
		// HTML index 9 = ' ' after &amp;
		expect( htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 9 ) ).toBe( 5 );
	} );

	it( 'handles HTML entity &lt;', () => {
		// "a &lt; b"
		// Text: "a < b" (5 chars)
		// HTML index 2 = '&' start of &lt;
		expect( htmlIndexToRichTextOffset( 'a &lt; b', 2 ) ).toBe( 2 );
		// HTML index 6 = ' ' after &lt;
		expect( htmlIndexToRichTextOffset( 'a &lt; b', 6 ) ).toBe( 3 );
	} );

	it( 'handles numeric character references', () => {
		// "a&#38;b" (&#38; = &)
		// Text: "a&b" (3 chars)
		// HTML index 1 = '&' start of &#38;
		expect( htmlIndexToRichTextOffset( 'a&#38;b', 1 ) ).toBe( 1 );
		// HTML index 6 = 'b'
		expect( htmlIndexToRichTextOffset( 'a&#38;b', 6 ) ).toBe( 2 );
	} );

	// These tests document the behavior when htmlIndex lands inside an
	// HTML tag or entity, possible from non-synced peers. The marker is
	// inserted at the raw index, which may break the HTML, but create()
	// produces a best-effort parse. Pinning the current behavior here so
	// any future changes are intentional.

	it( 'handles an htmlIndex pointing inside an opening tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 7 = 'n' inside <strong>
		// The marker breaks the tag, so create() treats the broken
		// fragments as text. The marker position in the resulting
		// (corrupted) text happens to equal the raw htmlIndex.
		const result = htmlIndexToRichTextOffset(
			'some <strong>words</strong> test',
			7
		);
		expect( typeof result ).toBe( 'number' );
		expect( result ).toBe( 7 );
	} );

	it( 'handles an htmlIndex pointing inside a closing tag', () => {
		// "some <strong>words</strong> test"
		// HTML index 20 = 't' inside </strong>
		// Same as above, the broken closing tag becomes text.
		const result = htmlIndexToRichTextOffset(
			'some <strong>words</strong> test',
			20
		);
		expect( typeof result ).toBe( 'number' );
		expect( result ).toBe( 20 );
	} );

	it( 'handles an htmlIndex pointing inside an entity', () => {
		// "Tom &amp; Jerry"
		// HTML index 6 = 'p' inside &amp;
		// The broken entity is not parsed, so the raw text including
		// the marker is preserved and the position equals htmlIndex.
		const result = htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 6 );
		expect( typeof result ).toBe( 'number' );
		expect( result ).toBe( 6 );
	} );

	it( 'handles self-closing tags like <br />', () => {
		// "line1<br />line2"
		// Gutenberg's rich-text treats <br> as a line separator character,
		// so text = "line1\u2028line2" (11 chars). HTML index 11 = 'l' of
		// "line2" → rich-text offset 6 (after "line1" + line separator).
		expect( htmlIndexToRichTextOffset( 'line1<br />line2', 11 ) ).toBe( 6 );
	} );

	it( 'handles multiple adjacent tags', () => {
		// "<em><strong>text</strong></em>"
		// HTML index 12 = 't' (after <em><strong>)
		expect(
			htmlIndexToRichTextOffset( '<em><strong>text</strong></em>', 12 )
		).toBe( 0 );
	} );

	it( 'handles empty content', () => {
		expect( htmlIndexToRichTextOffset( '', 0 ) ).toBe( 0 );
	} );

	it( 'handles tag attribute containing ">" inside quotes', () => {
		// '<a title="a>b">link</a>'
		// The DOM parser correctly handles > inside quoted attributes.
		// HTML index 15 = 'l' in "link", rich-text offset = 0.
		const html = '<a title="a>b">link</a>';
		const result = htmlIndexToRichTextOffset( html, 15 );
		expect( result ).toBe( 0 );
	} );
} );

describe( 'richTextOffsetToHtmlIndex', () => {
	it( 'returns the offset unchanged when there are no tags', () => {
		expect( richTextOffsetToHtmlIndex( 'hello world', 5 ) ).toBe( 5 );
	} );

	it( 'returns position after the opening tag for offset 0 with tags', () => {
		// Rich-text offset 0 = 'b' → HTML index 8 (after <strong>)
		expect(
			richTextOffsetToHtmlIndex( '<strong>bold</strong> text', 0 )
		).toBe( 8 );
	} );

	it( 'maps offset inside a formatted word', () => {
		// "some <strong>words</strong> test"
		// Rich-text offset 5 = 'w' → HTML index 13 (after <strong>)
		expect(
			richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 5 )
		).toBe( 13 );
	} );

	it( 'maps offset at the middle of a formatted word', () => {
		// Rich-text offset 7 = 'r' in "words" → HTML index 15
		expect(
			richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 7 )
		).toBe( 15 );
	} );

	it( 'maps offset right after a formatted word', () => {
		// Rich-text offset 10 = ' ' after "words" → HTML index 27 (after </strong>)
		expect(
			richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 10 )
		).toBe( 27 );
	} );

	it( 'maps offset before any tags', () => {
		// Rich-text offset 3 = 'e' in "some"
		expect(
			richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 3 )
		).toBe( 3 );
	} );

	it( 'maps offset at end of string', () => {
		const html = 'some <strong>words</strong> test';
		// Rich-text offset 15 = end of "some words test"
		expect( richTextOffsetToHtmlIndex( html, 15 ) ).toBe( html.length );
	} );

	it( 'handles nested formatting', () => {
		// "a<strong><em>b</em></strong>c"
		// Rich-text offset 1 = 'b' → HTML index 13
		expect(
			richTextOffsetToHtmlIndex( 'a<strong><em>b</em></strong>c', 1 )
		).toBe( 13 );
	} );

	it( 'handles tags with attributes', () => {
		// '<a href="https://example.com">link</a> text'
		// Rich-text offset 0 = 'l' → HTML index 30
		expect(
			richTextOffsetToHtmlIndex(
				'<a href="https://example.com">link</a> text',
				0
			)
		).toBe( 30 );
	} );

	it( 'is the inverse of htmlIndexToRichTextOffset for text positions', () => {
		const html = 'some <strong>words</strong> test';
		const textPositions = [ 0, 3, 5, 7, 10, 15 ];

		for ( const textOffset of textPositions ) {
			const htmlIndex = richTextOffsetToHtmlIndex( html, textOffset );
			const roundTripped = htmlIndexToRichTextOffset( html, htmlIndex );
			expect( roundTripped ).toBe( textOffset );
		}
	} );

	it( 'handles empty string', () => {
		expect( richTextOffsetToHtmlIndex( '', 0 ) ).toBe( 0 );
	} );

	it( 'handles HTML entity &amp;', () => {
		// "Tom &amp; Jerry"
		// Text: "Tom & Jerry" (11 chars)
		// Rich-text offset 4 = '&' → HTML index 4 (start of &amp;)
		expect( richTextOffsetToHtmlIndex( 'Tom &amp; Jerry', 4 ) ).toBe( 4 );
		// Rich-text offset 5 = ' ' after '&' → HTML index 9 (after &amp;)
		expect( richTextOffsetToHtmlIndex( 'Tom &amp; Jerry', 5 ) ).toBe( 9 );
	} );

	it( 'handles HTML entity &lt;', () => {
		// "a &lt; b"
		// Text: "a < b" (5 chars)
		// Rich-text offset 2 = '<' → HTML index 2 (start of &lt;)
		expect( richTextOffsetToHtmlIndex( 'a &lt; b', 2 ) ).toBe( 2 );
		// Rich-text offset 3 = ' ' after '<' → HTML index 6 (after &lt;)
		expect( richTextOffsetToHtmlIndex( 'a &lt; b', 3 ) ).toBe( 6 );
	} );

	it( 'handles numeric character references', () => {
		// "a&#38;b" (&#38; = &)
		// Text: "a&b" (3 chars)
		// Rich-text offset 1 = '&' → HTML index 1 (start of &#38;)
		expect( richTextOffsetToHtmlIndex( 'a&#38;b', 1 ) ).toBe( 1 );
		// Rich-text offset 2 = 'b' → HTML index 6 (after &#38;)
		expect( richTextOffsetToHtmlIndex( 'a&#38;b', 2 ) ).toBe( 6 );
	} );

	it( 'handles multiple formatted ranges', () => {
		// "a<strong>b</strong>c<em>d</em>e"
		// Text: "abcde"
		// Offset 1 = 'b' → HTML index 9 (after <strong>)
		expect(
			richTextOffsetToHtmlIndex( 'a<strong>b</strong>c<em>d</em>e', 1 )
		).toBe( 9 );
		// Offset 3 = 'd' → HTML index 24 (after <em>)
		expect(
			richTextOffsetToHtmlIndex( 'a<strong>b</strong>c<em>d</em>e', 3 )
		).toBe( 24 );
	} );
} );

describe( 'round-trip consistency', () => {
	const testCases: [ string, string ][] = [
		[ 'plain text', 'hello world' ],
		[ 'single bold', 'some <strong>words</strong> test' ],
		[ 'nested formatting', 'a<strong><em>bc</em>d</strong>e' ],
		[
			'link with attributes',
			'<a href="https://example.com">link</a> text',
		],
		[ 'multiple ranges', 'a<strong>b</strong>c<em>d</em>e' ],
		[ 'adjacent tags', '<em>a</em><strong>b</strong>' ],
		[ 'entity &amp;', 'Tom &amp; Jerry' ],
		[ 'entity &lt;', 'a &lt; b' ],
		[ 'numeric entity &#38;', 'a&#38;b' ],
	];

	for ( const [ label, html ] of testCases ) {
		it( `round-trips all text positions for: ${ label }`, () => {
			// Determine total text length by finding max valid offset.
			// Walk the HTML and count text chars for total length.
			const totalTextLen = htmlIndexToRichTextOffset( html, html.length );

			for (
				let textOffset = 0;
				textOffset <= totalTextLen;
				textOffset++
			) {
				const htmlIndex = richTextOffsetToHtmlIndex( html, textOffset );
				const roundTripped = htmlIndexToRichTextOffset(
					html,
					htmlIndex
				);
				expect( {
					label,
					textOffset,
					htmlIndex,
					roundTripped,
				} ).toMatchObject( {
					label,
					textOffset,
					roundTripped: textOffset,
				} );
			}
		} );
	}
} );
