import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';

vi.mock('@craftjs/core', () => ({
  useEditor: (collect?: (state: any) => any) => {
    const state = { events: { selected: new Set<string>() }, nodes: {} };
    return {
      query: { serialize: () => '{"ROOT":{}}' },
      actions: {},
      ...(collect ? collect(state) : {}),
    };
  },
}));
vi.mock('../../state/EditorConfigContext', () => ({
  useEditorConfig: () => ({
    whpConfig: {
      apiUrl: '/panel/api/site-builder',
      csrfToken: 'tok',
      siteId: 42,
      siteDomain: 'example.com',
    },
    isWHP: true,
  }),
}));
vi.mock('../../state/PageContext', () => ({
  usePages: () => ({
    activePageId: 'home',
    pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
  }),
}));
// Mocked (rather than using the real ring buffer) only so the added
// oversized-payload test below can force an over-cap consoleErrors array --
// every other test gets a plain empty array, same as a fresh page load.
vi.mock('../../utils/console-buffer', () => ({ getRecentConsoleErrors: vi.fn() }));

import { ReportIssueModal } from './ReportIssueModal';
import { getRecentConsoleErrors } from '../../utils/console-buffer';

let container: HTMLDivElement;
let root: Root;

function render(open = true) {
  container = document.createElement('div');
  document.body.appendChild(container);
  act(() => {
    root = createRoot(container);
    root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
  });
}

// The textarea is a controlled input (`onChange`), so React's DOM value
// tracker patches its `value` setter to detect "did this really change".
// A plain `ta.value = text` assignment goes through that same patched
// setter, which updates the tracker's own record of "current value" as a
// side effect -- so by the time the dispatched 'input' event is handled,
// the tracker sees no difference and the synthetic onChange never fires.
// Bypassing the patched setter via the native prototype descriptor (same
// idiom as HeadCodeModal.test.tsx / SiteDesignPanel.reset.test.tsx /
// shared-controls.test.tsx / MediaStylePanel.*.test.tsx) sets the DOM value
// without touching the tracker, so the dispatched event is correctly seen
// as a real change.
function typeDescription(text: string) {
  const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
  act(() => {
    const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
    setter.call(ta, text);
    ta.dispatchEvent(new Event('input', { bubbles: true }));
  });
}

function submit() {
  const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
  act(() => { btn.click(); });
}

beforeEach(() => {
  vi.restoreAllMocks();
  document.body.innerHTML = '';
  vi.mocked(getRecentConsoleErrors).mockReturnValue([]);
});

describe('ReportIssueModal', () => {
  test('submit is disabled until a description is entered', () => {
    render();
    expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
    typeDescription('something is wrong');
    expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
  });

  test('posts the payload to the report_issue action with the CSRF header', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
    });
    vi.stubGlobal('fetch', fetchMock);

    render();
    typeDescription('colours do nothing');
    await act(async () => { submit(); });

    expect(fetchMock).toHaveBeenCalledTimes(1);
    const [url, init] = fetchMock.mock.calls[0];
    expect(url).toContain('action=report_issue');
    expect(init.method).toBe('POST');
    expect(init.headers['X-CSRF-Token']).toBe('tok');

    const body = JSON.parse(init.body);
    expect(body.description).toBe('colours do nothing');
    expect(body.category).toBe('bug');
    expect(body.site_id).toBe(42);
    expect(body.canvas_state).toBe('{"ROOT":{}}');
  });

  test('unchecking include-contents omits the canvas state', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
    });
    vi.stubGlobal('fetch', fetchMock);

    render();
    typeDescription('no canvas please');
    const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
    act(() => { cb.click(); });
    await act(async () => { submit(); });

    const body = JSON.parse(fetchMock.mock.calls[0][1].body);
    expect(body.canvas_state).toBeNull();
    expect(body.canvas_state_omitted).toBe('opt-out');
  });

  test('shows the returned reference on success', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
    }));
    render();
    typeDescription('x');
    await act(async () => { submit(); });
    expect(document.body.textContent).toContain('SB-1234');
  });

  test('keeps the text and shows an error when the request fails', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false,
      json: async () => ({ success: false, error: 'Rate limited' }),
    }));
    render();
    typeDescription('keep me');
    await act(async () => { submit(); });

    expect(document.body.textContent).toContain('Rate limited');
    expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
  });

  test('a payload still too large after dropping canvas_state shows an error instead of hanging, and never calls fetch', async () => {
    // buildReportPayload throws when the body is still over MAX_PAYLOAD_BYTES
    // even with canvas_state dropped -- see report-payload.ts `finalize()`.
    // A huge *description* alone can't reach that path (it's truncated to
    // MAX_DESCRIPTION_CHARS before the size check ever runs -- see
    // report-payload.test.ts's "description size bounding" suite), so this
    // forces the same defensive scenario report-payload.test.ts's "honest
    // size markers" test uses: an oversized consoleErrors array, standing in
    // for whatever future bug would let that much data through in practice.
    // The point under test here is purely the modal's reaction to the throw,
    // not how the oversized condition arises.
    vi.mocked(getRecentConsoleErrors).mockReturnValue(
      Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) })),
    );
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);

    render();
    typeDescription('this report has a huge console-error backlog attached');
    await act(async () => { submit(); });

    expect(fetchMock).not.toHaveBeenCalled();
    expect(document.body.textContent).toMatch(/too large/i);
    // The user's text must survive a rejected submission just as much as a
    // server-side failure does.
    expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe(
      'this report has a huge console-error backlog attached',
    );
    // Not stuck on "Sending..." -- the submit button is usable again.
    expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
  });
});
