/**
 * Unit tests for Web Editor V2 Selection Engine.
 *
 * These tests focus on deterministic scoring and selection behavior.
 * jsdom has no real layout engine, so we mock:
 * - document.elementsFromPoint / document.elementFromPoint
 * - element.getBoundingClientRect()
 * - window.getComputedStyle()
 */

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
  createSelectionEngine,
  type Modifiers,
  type SelectionCandidate,
  type SelectionEngine,
} from '@/entrypoints/web-editor/selection/selection-engine';

import type { RestoreFn, StyleOverrides } from './test-utils/dom';
import {
  createMockEvent,
  installDomMocks,
  mockBoundingClientRect,
  mockViewport,
} from './test-utils/dom';

// =============================================================================
// Test Utilities
// =============================================================================

const NO_MODIFIERS: Modifiers = { alt: false, shift: false, ctrl: false, meta: false };

/**
 * Check if an element is part of the editor overlay.
 * In tests, elements with data-overlay="true" are considered overlay elements.
 */
function isOverlayElement(node: unknown): boolean {
  return node instanceof Element && node.getAttribute('data-overlay') === 'true';
}

/**
 * Find a candidate by element in the candidates array.
 */
function getCandidate(
  candidates: SelectionCandidate[],
  element: Element,
): SelectionCandidate | undefined {
  return candidates.find((c) => c.element === element);
}

/**
 * Check if any of the candidate's reasons contain a specific substring.
 */
function hasReason(candidate: SelectionCandidate | undefined, substring: string): boolean {
  return candidate?.reasons.some((r) => r.includes(substring)) ?? false;
}

// =============================================================================
// Test Setup
// =============================================================================

let restores: RestoreFn[] = [];
let engine: SelectionEngine | null = null;

beforeEach(() => {
  restores = [];
  document.body.innerHTML = '';
});

afterEach(() => {
  engine?.dispose();
  engine = null;
  for (let i = restores.length - 1; i >= 0; i--) {
    restores[i]!();
  }
  restores = [];
});

// =============================================================================
// getCandidatesAtPoint Tests
// =============================================================================

describe('selection-engine: getCandidatesAtPoint', () => {
  it('returns empty array when no elements are hit', () => {
    restores.push(
      installDomMocks({
        elementsFromPoint: () => [],
        getComputedStyle: () => ({}),
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    expect(engine.getCandidatesAtPoint(10, 10)).toEqual([]);
  });

  it('skips overlay elements from hit testing', () => {
    const overlay = document.createElement('div');
    overlay.setAttribute('data-overlay', 'true');

    const button = document.createElement('button');
    button.tabIndex = 0;

    document.body.append(overlay, button);

    restores.push(mockBoundingClientRect(overlay, { left: 0, top: 0, width: 200, height: 200 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));

    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [overlay, button],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(12, 12);

    // Overlay should be excluded
    expect(candidates.some((c) => c.element === overlay)).toBe(false);
    // Button should be selected
    expect(candidates.some((c) => c.element === button)).toBe(true);
  });

  it('scores interactive button element highly', () => {
    const wrapper = document.createElement('div');
    const button = document.createElement('button');
    button.tabIndex = 0;
    wrapper.append(button);
    document.body.append(wrapper);

    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 200, height: 120 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));

    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [button, wrapper],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(12, 12);

    // Button should have higher score due to interactive tag
    const buttonCandidate = getCandidate(candidates, button);
    expect(buttonCandidate).toBeDefined();
    expect(hasReason(buttonCandidate, 'button') || hasReason(buttonCandidate, 'type')).toBe(true);
  });

  it('prefers elements with visual boundaries', () => {
    const plain = document.createElement('div');
    const bordered = document.createElement('div');
    document.body.append(plain, bordered);

    restores.push(mockBoundingClientRect(plain, { left: 0, top: 0, width: 100, height: 100 }));
    restores.push(mockBoundingClientRect(bordered, { left: 0, top: 0, width: 100, height: 100 }));

    const styleByEl = new Map<Element, StyleOverrides>([
      [
        bordered,
        {
          borderTopWidth: '1px',
          borderRightWidth: '1px',
          borderBottomWidth: '1px',
          borderLeftWidth: '1px',
          borderTopStyle: 'solid',
          borderRightStyle: 'solid',
          borderBottomStyle: 'solid',
          borderLeftStyle: 'solid',
        },
      ],
    ]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [plain, bordered],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(10, 10);

    const borderedCandidate = getCandidate(candidates, bordered);
    expect(borderedCandidate).toBeDefined();
    expect(hasReason(borderedCandidate, 'border')).toBe(true);
  });

  it('penalizes tiny elements', () => {
    const tiny = document.createElement('div');
    const normal = document.createElement('div');
    document.body.append(tiny, normal);

    restores.push(mockBoundingClientRect(tiny, { left: 0, top: 0, width: 2, height: 2 }));
    restores.push(mockBoundingClientRect(normal, { left: 0, top: 0, width: 100, height: 100 }));

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [tiny, normal],
        getComputedStyle: () => ({}),
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(1, 1);

    const tinyCandidate = getCandidate(candidates, tiny);
    const normalCandidate = getCandidate(candidates, normal);

    expect(tinyCandidate).toBeDefined();
    expect(normalCandidate).toBeDefined();
    // Tiny element should have lower score
    expect((tinyCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true);
  });

  it('penalizes very large elements (viewport-sized)', () => {
    const huge = document.createElement('div');
    const normal = document.createElement('div');
    document.body.append(huge, normal);

    // Mock viewport as 800x600
    restores.push(mockViewport(800, 600));
    // Huge element takes 90% of viewport
    restores.push(mockBoundingClientRect(huge, { left: 0, top: 0, width: 720, height: 540 }));
    restores.push(mockBoundingClientRect(normal, { left: 10, top: 10, width: 100, height: 100 }));

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [normal, huge],
        getComputedStyle: () => ({}),
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(50, 50);

    const hugeCandidate = getCandidate(candidates, huge);
    const normalCandidate = getCandidate(candidates, normal);
    expect(hugeCandidate).toBeDefined();
    expect(normalCandidate).toBeDefined();
    // Large element should have lower score due to size penalty
    expect((hugeCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true);
  });

  it('excludes invisible elements', () => {
    const hidden = document.createElement('div');
    const visible = document.createElement('div');
    document.body.append(hidden, visible);

    restores.push(mockBoundingClientRect(hidden, { left: 0, top: 0, width: 100, height: 100 }));
    restores.push(mockBoundingClientRect(visible, { left: 0, top: 0, width: 100, height: 100 }));

    const styleByEl = new Map<Element, StyleOverrides>([
      [hidden, { display: 'none' }],
      [visible, { display: 'block' }],
    ]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [hidden, visible],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const candidates = engine.getCandidatesAtPoint(50, 50);

    // Hidden element should be excluded
    expect(candidates.some((c) => c.element === hidden)).toBe(false);
    expect(candidates.some((c) => c.element === visible)).toBe(true);
  });
});

// =============================================================================
// findBestTarget Tests
// =============================================================================

describe('selection-engine: findBestTarget', () => {
  it('returns null when no elements are hit', () => {
    restores.push(
      installDomMocks({
        elementsFromPoint: () => [],
        getComputedStyle: () => ({}),
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBeNull();
  });

  it('returns the best scored element', () => {
    const button = document.createElement('button');
    button.tabIndex = 0;
    document.body.append(button);

    restores.push(mockBoundingClientRect(button, { left: 0, top: 0, width: 120, height: 48 }));

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [button],
        getComputedStyle: () => ({}),
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBe(button);
  });

  it('Alt modifier drills up to parent element', () => {
    const panel = document.createElement('section');
    panel.id = 'panel';

    const wrapper = document.createElement('div');
    const button = document.createElement('button');
    button.tabIndex = 0;

    wrapper.append(button);
    panel.append(wrapper);
    document.body.append(panel);

    restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 }));
    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));

    const styleByEl = new Map<Element, StyleOverrides>([
      [panel, { paddingTop: '8px', paddingLeft: '8px' }],
      [button, { cursor: 'pointer' }],
    ]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [button, wrapper, panel],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const target = engine.findBestTarget(12, 12, { ...NO_MODIFIERS, alt: true });

    // Should drill up past wrapper to panel (which has visual boundary via padding)
    expect(target).toBe(panel);
  });
});

// =============================================================================
// findBestTargetFromEvent Tests
// =============================================================================

describe('selection-engine: findBestTargetFromEvent', () => {
  it('Ctrl/Cmd selects the innermost visible element from composedPath', () => {
    const wrapper = document.createElement('div');
    const button = document.createElement('button');
    button.tabIndex = 0;
    const inner = document.createElement('span');
    inner.textContent = 'Inner';

    button.append(inner);
    wrapper.append(button);
    document.body.append(wrapper);

    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));
    restores.push(mockBoundingClientRect(inner, { left: 14, top: 14, width: 50, height: 20 }));

    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [inner, button, wrapper],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });

    const event = createMockEvent({
      clientX: 16,
      clientY: 16,
      path: [inner, button, wrapper, document.body, document],
    });

    const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, ctrl: true });
    // Ctrl should select innermost visible element
    expect(target).toBe(inner);
  });

  it('Alt in event-based selection drills up from best target', () => {
    const panel = document.createElement('section');
    panel.id = 'panel';

    const wrapper = document.createElement('div');
    const button = document.createElement('button');
    button.tabIndex = 0;

    wrapper.append(button);
    panel.append(wrapper);
    document.body.append(panel);

    restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 }));
    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));

    const styleByEl = new Map<Element, StyleOverrides>([
      [panel, { paddingTop: '8px', paddingLeft: '8px' }],
      [button, { cursor: 'pointer' }],
    ]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [button, wrapper, panel],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });

    const event = createMockEvent({
      clientX: 12,
      clientY: 12,
      path: [button, wrapper, panel, document.body, document],
    });

    const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, alt: true });
    expect(target).toBe(panel);
  });
});

// =============================================================================
// getParentCandidate Tests
// =============================================================================

describe('selection-engine: getParentCandidate', () => {
  it('returns null for body element', () => {
    engine = createSelectionEngine({ isOverlayElement });
    expect(engine.getParentCandidate(document.body)).toBeNull();
  });

  it('returns first non-wrapper ancestor', () => {
    const section = document.createElement('section');
    section.id = 'section';
    const wrapper = document.createElement('div');
    const button = document.createElement('button');

    wrapper.append(button);
    section.append(wrapper);
    document.body.append(section);

    restores.push(mockBoundingClientRect(section, { left: 0, top: 0, width: 400, height: 300 }));
    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));
    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));

    // Section has visual boundary
    const styleByEl = new Map<Element, StyleOverrides>([
      [section, { paddingTop: '8px', paddingLeft: '8px' }],
    ]);

    restores.push(
      installDomMocks({
        elementsFromPoint: () => [],
        getComputedStyle: (el) => styleByEl.get(el) ?? {},
      }),
    );

    engine = createSelectionEngine({ isOverlayElement });
    const parent = engine.getParentCandidate(button);

    // Should skip wrapper and return section
    expect(parent).toBe(section);
  });
});

// =============================================================================
// dispose Tests
// =============================================================================

describe('selection-engine: dispose', () => {
  it('can be called multiple times safely', () => {
    engine = createSelectionEngine({ isOverlayElement });
    expect(() => {
      engine!.dispose();
      engine!.dispose();
    }).not.toThrow();
  });
});
