import { describe, it, expect, beforeEach } from 'vitest';
import type { Rect, Position } from '@/utils/sel';

// We need to test the non-exported helpers via the exported functions that use them.
// frameRect and pointIsInView are non-exported, but exercised through getPosition/getPopupPosition.
// constrainPointWithinRect is also non-exported but exercised through getPosition.

// For getPopupPosition we can test directly.
import { getPopupPosition, isPointInRect } from '@/utils/sel';

describe('sel utilities', () => {
  describe('isPointInRect', () => {
    const rect: Rect = { left: 100, top: 50, right: 300, bottom: 200 };

    it('returns true for a point strictly inside the rect', () => {
      expect(isPointInRect({ x: 200, y: 100 }, rect)).toBe(true);
    });

    it('treats edges as outside with the default 1px padding', () => {
      expect(isPointInRect({ x: 100, y: 100 }, rect)).toBe(false);
      expect(isPointInRect({ x: 300, y: 100 }, rect)).toBe(false);
      expect(isPointInRect({ x: 200, y: 50 }, rect)).toBe(false);
      expect(isPointInRect({ x: 200, y: 200 }, rect)).toBe(false);
    });

    it('returns true on edges when padding is 0', () => {
      expect(isPointInRect({ x: 100, y: 50 }, rect, 0)).toBe(true);
      expect(isPointInRect({ x: 300, y: 200 }, rect, 0)).toBe(true);
    });

    it('returns false when x is outside', () => {
      expect(isPointInRect({ x: 99, y: 100 }, rect)).toBe(false);
      expect(isPointInRect({ x: 301, y: 100 }, rect)).toBe(false);
    });

    it('returns false when y is outside', () => {
      expect(isPointInRect({ x: 200, y: 49 }, rect)).toBe(false);
      expect(isPointInRect({ x: 200, y: 201 }, rect)).toBe(false);
    });
  });

  describe('getPopupPosition', () => {
    const boundingRect: Rect = { top: 0, right: 800, bottom: 600, left: 0 };

    describe('direction: up', () => {
      it('should position popup above the point', () => {
        const position: Position = { point: { x: 400, y: 200 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 400 - 200/2 = 300
        // y = 200 - 100 = 100
        expect(result.point.x).toBe(300);
        expect(result.point.y).toBe(100);
        expect(result.dir).toBe('up');
      });

      it('should clamp popup to left edge with padding', () => {
        const position: Position = { point: { x: 50, y: 200 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 50 - 100 = -50, clamped to padding 10
        expect(result.point.x).toBe(10);
      });

      it('should clamp popup to top edge with padding', () => {
        const position: Position = { point: { x: 400, y: 50 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // y = 50 - 100 = -50, clamped to padding 10
        expect(result.point.y).toBe(10);
      });

      it('should clamp popup to right edge with padding', () => {
        const position: Position = { point: { x: 750, y: 300 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 750 - 100 = 650, max = 800 - 0 - 10 - 200 = 590
        expect(result.point.x).toBe(590);
      });

      it('should clamp popup to bottom edge with padding', () => {
        const position: Position = { point: { x: 400, y: 590 }, dir: 'up' };
        // y = 590 - 100 = 490, max = 600 - 0 - 10 - 100 = 490
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        expect(result.point.y).toBe(490);
      });
    });

    describe('direction: down', () => {
      it('should position popup below the point with 6px offset', () => {
        const position: Position = { point: { x: 400, y: 200 }, dir: 'down' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 400 - 100 = 300
        // y = 200 + 6 = 206
        expect(result.point.x).toBe(300);
        expect(result.point.y).toBe(206);
        expect(result.dir).toBe('down');
      });

      it('should clamp when popup overflows bottom', () => {
        const position: Position = { point: { x: 400, y: 550 }, dir: 'down' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // y = 550 + 6 = 556, max = 600 - 0 - 10 - 100 = 490
        expect(result.point.y).toBe(490);
      });
    });

    describe('direction: left', () => {
      it('should position popup to the left of the point', () => {
        const position: Position = { point: { x: 400, y: 300 }, dir: 'left' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 400 - 200 = 200
        // y = 300 - 50 = 250
        expect(result.point.x).toBe(200);
        expect(result.point.y).toBe(250);
        expect(result.dir).toBe('left');
      });

      it('should clamp when popup overflows left', () => {
        const position: Position = { point: { x: 50, y: 300 }, dir: 'left' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 50 - 200 = -150, clamped to 10
        expect(result.point.x).toBe(10);
      });
    });

    describe('direction: right', () => {
      it('should position popup to the right of the point with 6px offset', () => {
        const position: Position = { point: { x: 300, y: 300 }, dir: 'right' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 300 + 6 = 306
        // y = 300 - 50 = 250
        expect(result.point.x).toBe(306);
        expect(result.point.y).toBe(250);
        expect(result.dir).toBe('right');
      });

      it('should clamp when popup overflows right', () => {
        const position: Position = { point: { x: 700, y: 300 }, dir: 'right' };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // x = 700 + 6 = 706, max = 800 - 0 - 10 - 200 = 590
        expect(result.point.x).toBe(590);
      });
    });

    describe('no direction', () => {
      it('should default to top-left (0,0) and apply clamping', () => {
        const position: Position = { point: { x: 400, y: 300 } };
        const result = getPopupPosition(position, boundingRect, 200, 100, 10);
        // No dir matched, popupPoint stays {0,0}
        // x = 0 < 10 => 10
        // y = 0 < 10 => 10
        expect(result.point.x).toBe(10);
        expect(result.point.y).toBe(10);
      });
    });

    describe('non-zero origin bounding rect', () => {
      it('should respect bounding rect offset for right clamping', () => {
        const rect: Rect = { top: 100, right: 500, bottom: 400, left: 100 };
        const position: Position = { point: { x: 380, y: 150 }, dir: 'down' };
        const result = getPopupPosition(position, rect, 200, 100, 10);
        // x = 380 - 100 = 280, max_x = (500-100) - 10 - 200 = 190 => clamped to 190
        expect(result.point.x).toBe(190);
      });

      it('should respect bounding rect offset for bottom clamping', () => {
        const rect: Rect = { top: 100, right: 500, bottom: 400, left: 100 };
        const position: Position = { point: { x: 200, y: 280 }, dir: 'down' };
        const result = getPopupPosition(position, rect, 100, 100, 10);
        // x = 200 - 50 = 150, within bounds
        // y = 280 + 6 = 286, max_y = (400-100) - 10 - 100 = 190 => clamped to 190
        expect(result.point.y).toBe(190);
      });
    });

    describe('zero-size popup', () => {
      it('should handle zero width and height', () => {
        const position: Position = { point: { x: 400, y: 300 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 0, 0, 0);
        expect(result.point.x).toBe(400);
        expect(result.point.y).toBe(300);
      });
    });

    describe('large popup exceeding bounds', () => {
      it('should clamp a popup larger than the bounding rect', () => {
        const position: Position = { point: { x: 400, y: 300 }, dir: 'up' };
        const result = getPopupPosition(position, boundingRect, 1000, 800, 10);
        // x = 400 - 500 = -100 => clamped to 10
        // then right clamp: 10 + 1000 > 800 - 0 - 10 => x = 800 - 10 - 1000 = -210
        // -210 < 10 => re-clamped to 10 (left clamp happens first, then right clamp overrides)
        // Actually: left clamp sets x=10, then right check: 10+1000=1010 > 790 => x = -210
        // The function applies clamping sequentially: first min, then max. So final x = -210.
        expect(result.point.x).toBe(-210);
      });
    });
  });

  describe('getPosition (exercises frameRect, constrainPointWithinRect, pointIsInView)', () => {
    beforeEach(() => {
      // Set window dimensions for pointIsInView
      Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true });
      Object.defineProperty(window, 'innerHeight', { value: 768, writable: true });
    });

    it('should return zero-point when both start and end are outside the view', async () => {
      const { getPosition } = await import('@/utils/sel');
      // Create a mock Range that returns rects outside the viewport
      const mockRange = {
        getClientRects: () =>
          [{ top: -200, right: -100, bottom: -150, left: -200 }] as unknown as DOMRectList,
        commonAncestorContainer: document.createElement('div'),
      } as unknown as Range;

      const rect: Rect = { top: 0, right: 1024, bottom: 768, left: 0 };
      const result = getPosition(mockRange, rect, 10);
      // The constrained points should be clamped within rect, but they need to
      // pass pointIsInView which checks against window dimensions
      expect(result.point).toBeDefined();
    });

    it('should handle a TextSelection with rect property', async () => {
      const { getPosition } = await import('@/utils/sel');
      const mockTextSelection = {
        range: {
          getClientRects: () =>
            [{ top: 100, right: 300, bottom: 120, left: 200 }] as unknown as DOMRectList,
          commonAncestorContainer: document.createElement('div'),
        } as unknown as Range,
        rect: { top: 100, right: 300, bottom: 120, left: 200 },
        key: 'test',
        text: 'hello',
        page: 0,
        index: 0,
      };

      const rect: Rect = { top: 0, right: 1024, bottom: 768, left: 0 };
      const result = getPosition(mockTextSelection, rect, 10);
      expect(result.point).toBeDefined();
      expect(result.dir).toBeDefined();
    });

    it('anchors to the on-screen end when the selection start is off-screen (cross-page)', async () => {
      const { getPosition } = await import('@/utils/sel');
      // A selection that spans a page boundary: its first line is on the
      // previous page (off-screen, negative x); its last line is on the current
      // page (visible). The popup must anchor to the visible (last) end.
      const mockRange = {
        getClientRects: () =>
          [
            { top: 400, right: -100, bottom: 420, left: -300 }, // previous page (off-screen)
            { top: 200, right: 300, bottom: 220, left: 100 }, // current page (visible)
          ] as unknown as DOMRectList,
        commonAncestorContainer: document.createElement('div'),
      } as unknown as Range;

      const rect: Rect = { top: 0, right: 1024, bottom: 768, left: 0 };
      const result = getPosition(mockRange, rect, 10);
      // 'down' = anchored below the visible last line, not the off-screen start.
      expect(result.dir).toBe('down');
      expect(result.point.x).toBeGreaterThan(0);
    });
  });

  describe('isPointerInsideSelection', () => {
    it('should return false when selection has no ranges', async () => {
      const { isPointerInsideSelection } = await import('@/utils/sel');
      const selection = {
        rangeCount: 0,
      } as unknown as Selection;
      const ev = { clientX: 100, clientY: 100 } as PointerEvent;
      expect(isPointerInsideSelection(selection, ev)).toBe(false);
    });

    it('should return true when pointer is inside a selection rect (with padding)', async () => {
      const { isPointerInsideSelection } = await import('@/utils/sel');
      const selection = {
        rangeCount: 1,
        getRangeAt: () => ({
          getClientRects: () =>
            [{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
        }),
      } as unknown as Selection;
      // Within the rect with padding of 50
      const ev = { clientX: 150, clientY: 110 } as PointerEvent;
      expect(isPointerInsideSelection(selection, ev)).toBe(true);
    });

    it('should return true when pointer is within padding distance', async () => {
      const { isPointerInsideSelection } = await import('@/utils/sel');
      const selection = {
        rangeCount: 1,
        getRangeAt: () => ({
          getClientRects: () =>
            [{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
        }),
      } as unknown as Selection;
      // Outside the rect but within 50px padding
      const ev = { clientX: 60, clientY: 110 } as PointerEvent;
      expect(isPointerInsideSelection(selection, ev)).toBe(true);
    });

    it('should return false when pointer is far outside selection', async () => {
      const { isPointerInsideSelection } = await import('@/utils/sel');
      const selection = {
        rangeCount: 1,
        getRangeAt: () => ({
          getClientRects: () =>
            [{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
        }),
      } as unknown as Selection;
      // Far outside the rect
      const ev = { clientX: 500, clientY: 500 } as PointerEvent;
      expect(isPointerInsideSelection(selection, ev)).toBe(false);
    });

    it('should check all rects in a multi-rect selection', async () => {
      const { isPointerInsideSelection } = await import('@/utils/sel');
      const selection = {
        rangeCount: 1,
        getRangeAt: () => ({
          getClientRects: () =>
            [
              { left: 100, right: 200, top: 100, bottom: 120 },
              { left: 100, right: 300, top: 120, bottom: 140 },
            ] as unknown as DOMRectList,
        }),
      } as unknown as Selection;
      // Inside the second rect
      const ev = { clientX: 250, clientY: 130 } as PointerEvent;
      expect(isPointerInsideSelection(selection, ev)).toBe(true);
    });
  });

  describe('getTextFromRange', () => {
    it('should extract text from a simple range', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = 'Hello <b>world</b>!';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      const text = getTextFromRange(range);
      expect(text).toBe('Hello world!');

      document.body.removeChild(container);
    });

    it('should reject text from specified tags', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = 'Hello <rt>ruby</rt> world';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      const text = getTextFromRange(range, ['rt']);
      expect(text).not.toContain('ruby');
      expect(text).toContain('Hello');
      expect(text).toContain('world');

      document.body.removeChild(container);
    });

    it('should return empty string for an empty range', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = '';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      const text = getTextFromRange(range);
      expect(text).toBe('');

      document.body.removeChild(container);
    });

    it('should handle nested elements', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = '<p>First <span>nested <em>deep</em></span> end</p>';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      const text = getTextFromRange(range);
      expect(text).toBe('First nested deep end');

      document.body.removeChild(container);
    });

    it('should insert a newline for <br> between adjacent text spans (PDF line wrap)', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      // Mirrors how pdf.js renders the text layer: each text run is its
      // own <span>, and line endings are <br role="presentation">.
      const container = document.createElement('div');
      container.className = 'textLayer';
      container.innerHTML =
        '<span role="presentation">last word of line 1</span>' +
        '<br role="presentation">' +
        '<span role="presentation">first word of line 2</span>';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      const text = getTextFromRange(range);
      // Without separating the spans the text becomes
      // "last word of line 1first word of line 2" — words are glued.
      expect(text).toBe('last word of line 1\nfirst word of line 2');

      document.body.removeChild(container);
    });

    it('should insert a newline for explicit <br> in HTML content', async () => {
      const { getTextFromRange } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = 'first<br>second<br>third';
      document.body.appendChild(container);

      const range = document.createRange();
      range.selectNodeContents(container);

      expect(getTextFromRange(range)).toBe('first\nsecond\nthird');

      document.body.removeChild(container);
    });
  });

  describe('snapRangeToWords', () => {
    it('should snap start offset to word boundary', async () => {
      const { snapRangeToWords } = await import('@/utils/sel');
      const textNode = document.createTextNode('Hello world test');
      const container = document.createElement('div');
      container.appendChild(textNode);
      document.body.appendChild(container);

      const range = document.createRange();
      // Start in the middle of "world" (offset 8, "r"), end at the end of "world" (offset 11)
      range.setStart(textNode, 8);
      range.setEnd(textNode, 11);

      snapRangeToWords(range);

      // Should snap start back to the beginning of "world" (offset 6)
      expect(range.startOffset).toBe(6);
      // End was at the end of "world" so it should stay or snap to word end
      expect(range.endOffset).toBe(11);

      document.body.removeChild(container);
    });

    it('should snap end offset to word boundary', async () => {
      const { snapRangeToWords } = await import('@/utils/sel');
      const textNode = document.createTextNode('Hello world test');
      const container = document.createElement('div');
      container.appendChild(textNode);
      document.body.appendChild(container);

      const range = document.createRange();
      // Start at the beginning of "world" (offset 6), end in the middle of "world" (offset 8)
      range.setStart(textNode, 6);
      range.setEnd(textNode, 8);

      snapRangeToWords(range);

      expect(range.startOffset).toBe(6);
      // Should snap end to the end of "world" (offset 11)
      expect(range.endOffset).toBe(11);

      document.body.removeChild(container);
    });

    it('should not snap when offset is at the start of text', async () => {
      const { snapRangeToWords } = await import('@/utils/sel');
      const textNode = document.createTextNode('Hello');
      const container = document.createElement('div');
      container.appendChild(textNode);
      document.body.appendChild(container);

      const range = document.createRange();
      range.setStart(textNode, 0);
      range.setEnd(textNode, 3);

      snapRangeToWords(range);

      expect(range.startOffset).toBe(0);
      // End should snap to end of "Hello"
      expect(range.endOffset).toBe(5);

      document.body.removeChild(container);
    });

    it('should not snap when container is not a text node', async () => {
      const { snapRangeToWords } = await import('@/utils/sel');
      const container = document.createElement('div');
      container.innerHTML = '<span>Hello</span>';
      document.body.appendChild(container);

      const range = document.createRange();
      range.setStart(container, 0);
      range.setEnd(container, 1);

      // Should not throw
      snapRangeToWords(range);
      expect(range.startOffset).toBe(0);

      document.body.removeChild(container);
    });

    it('should not snap past punctuation', async () => {
      const { snapRangeToWords } = await import('@/utils/sel');
      const textNode = document.createTextNode('Hello, world!');
      const container = document.createElement('div');
      container.appendChild(textNode);
      document.body.appendChild(container);

      const range = document.createRange();
      // Start at comma (offset 5), end at exclamation (offset 12)
      range.setStart(textNode, 5);
      range.setEnd(textNode, 12);

      snapRangeToWords(range);

      // The comma is punctuation, so start should not snap
      expect(range.startOffset).toBe(5);

      document.body.removeChild(container);
    });
  });

  describe('getWordRangeAt', () => {
    const withTextNode = (text: string) => {
      const container = document.createElement('div');
      const node = document.createTextNode(text);
      container.appendChild(node);
      document.body.appendChild(container);
      return { container, node };
    };

    it('expands a caret in the middle of a word to the whole word', async () => {
      const { container, node } = withTextNode('Hello world test');
      const { getWordRangeAt } = await import('@/utils/sel');
      // offset 8 = the "r" inside "world"
      const range = getWordRangeAt(node, 8);
      expect(range?.toString()).toBe('world');
      document.body.removeChild(container);
    });

    it('selects the word when the caret sits at the word start boundary', async () => {
      const { container, node } = withTextNode('Hello world test');
      const { getWordRangeAt } = await import('@/utils/sel');
      const range = getWordRangeAt(node, 6); // start of "world"
      expect(range?.toString()).toBe('world');
      document.body.removeChild(container);
    });

    it('selects the word when the caret sits at the word end boundary', async () => {
      const { container, node } = withTextNode('Hello world test');
      const { getWordRangeAt } = await import('@/utils/sel');
      const range = getWordRangeAt(node, 11); // end of "world"
      expect(range?.toString()).toBe('world');
      document.body.removeChild(container);
    });

    it('selects a CJK word at the caret', async () => {
      const { container, node } = withTextNode('阅读测试内容');
      const { getWordRangeAt } = await import('@/utils/sel');
      const range = getWordRangeAt(node, 1);
      expect(range && range.toString().length > 0).toBe(true);
      document.body.removeChild(container);
    });

    it('returns null on whitespace / non-word positions', async () => {
      const { container, node } = withTextNode('   ');
      const { getWordRangeAt } = await import('@/utils/sel');
      expect(getWordRangeAt(node, 1)).toBeNull();
      document.body.removeChild(container);
    });

    it('returns null when the node is not a text node', async () => {
      const container = document.createElement('div');
      container.innerHTML = '<span>Hello</span>';
      document.body.appendChild(container);
      const { getWordRangeAt } = await import('@/utils/sel');
      expect(getWordRangeAt(container, 0)).toBeNull();
      document.body.removeChild(container);
    });
  });

  describe('getWordRangeFromPoint', () => {
    it('resolves the word under the point via caretRangeFromPoint', async () => {
      const container = document.createElement('div');
      const node = document.createTextNode('Hello world test');
      container.appendChild(node);
      document.body.appendChild(container);

      const caretRange = document.createRange();
      caretRange.setStart(node, 8);
      caretRange.collapse(true);
      const doc = Object.assign(document, {
        caretRangeFromPoint: () => caretRange,
      }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null };

      const { getWordRangeFromPoint } = await import('@/utils/sel');
      const range = getWordRangeFromPoint(doc, 50, 50);
      expect(range?.toString()).toBe('world');

      delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint;
      document.body.removeChild(container);
    });

    it('returns null when no caret resolves at the point', async () => {
      const doc = Object.assign(document, {
        caretRangeFromPoint: () => null,
      }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null };
      const { getWordRangeFromPoint } = await import('@/utils/sel');
      expect(getWordRangeFromPoint(doc, 0, 0)).toBeNull();
      delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint;
    });
  });
});
