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

/* Same DOM harness + craftjs mock as NavStylePanel.test.tsx (no
   @testing-library/react in this repo). `lastProps` IS the node's prop bag: the
   mocked setProp mutates it exactly as Craft.js would, which is what lets the
   "no raw secret ever reaches a prop" assertion below be a real check on
   everything the panel writes rather than on a hand-picked key. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
  updater(lastProps);
});
let lastProps: any;

vi.mock('@craftjs/core', () => ({
  useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));

import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';

const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';

let container: HTMLDivElement;
let root: Root;

function render(ui: React.ReactElement) {
  container = document.createElement('div');
  document.body.appendChild(container);
  act(() => {
    root = createRoot(container);
    root.render(ui);
  });
}

function rerender(ui: React.ReactElement) {
  act(() => { root.render(ui); });
}

function unmount() {
  act(() => { root.unmount(); });
  container.remove();
}

function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
  const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
  const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
  act(() => {
    setter.call(el, value);
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  });
}

function click(el: Element | null) {
  if (!el) throw new Error('element not found');
  act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}

/* React 17+ implements onBlur with the native `focusout` event (which bubbles),
   not `blur`. */
async function blur(el: Element) {
  await act(async () => {
    el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
    await Promise.resolve();
    await Promise.resolve();
  });
}

const testid = (id: string) => container.querySelector(`[data-testid="${id}"]`);

function contactFormProps(over: Record<string, any> = {}) {
  return {
    fields: [],
    style: {},
    recipientEmail: '',
    thankYouUrl: '',
    destinationType: 'email',
    webhookUrl: '',
    webhookSecretId: '',
    webhookAuthMode: 'signature',
    ...over,
  };
}

beforeEach(() => {
  setPropSpy.mockClear();
  (window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 42 };
});

afterEach(() => {
  if (container) unmount();
  delete (window as any).WHP_CONFIG;
  vi.unstubAllGlobals();
});

describe('FormStylePanel destination selector', () => {
  test('choosing Webhook writes destinationType and reveals the webhook fields', () => {
    lastProps = contactFormProps();
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-url')).toBeNull();

    click(testid('destination-webhook'));
    expect(lastProps.destinationType).toBe('webhook');

    rerender(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-url')).toBeTruthy();
    expect(testid('webhook-authmode')).toBeTruthy();
    expect(testid('webhook-secret-input')).toBeTruthy();
  });

  test('the URL and auth-mode controls write their props', () => {
    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    setValue(testid('webhook-url') as HTMLInputElement, 'https://hooks.example.com/x');
    expect(lastProps.webhookUrl).toBe('https://hooks.example.com/x');
    setValue(testid('webhook-authmode') as HTMLSelectElement, 'bearer');
    expect(lastProps.webhookAuthMode).toBe('bearer');
  });

  test('the recipient field is relabelled as the fallback address for a webhook', () => {
    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
    expect(labels.some((t) => t?.includes('Fallback email'))).toBe(true);
    expect(labels.some((t) => t === 'Send submissions to (email)')).toBe(false);
  });

  test('a component without destinationType (FormContainer) shows neither the selector nor the webhook fields', () => {
    lastProps = { recipientEmail: '', thankYouUrl: '', style: {} };
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('destination-webhook')).toBeNull();
    expect(testid('webhook-url')).toBeNull();
    const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
    expect(labels).toContain('Send submissions to (email)');
  });
});

describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
  test('blurring the secret POSTs it and persists ONLY the returned id', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true, status: 200,
      json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
    });
    vi.stubGlobal('fetch', fetchMock);

    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    const input = testid('webhook-secret-input') as HTMLInputElement;
    setValue(input, RAW_SECRET);
    await blur(input);

    // The flow really ran (otherwise the assertions below would pass vacuously).
    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(JSON.parse(fetchMock.mock.calls[0][1].body).secret).toBe(RAW_SECRET);
    expect(lastProps.webhookSecretId).toBe('whs_42_deadbeefdeadbeef');

    // THE PROPERTY: nothing the panel wrote to the node's props contains the raw
    // secret, under any key -- craft props are serialised into the saved project
    // and into published output.
    expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
    for (const [, updater] of setPropSpy.mock.calls) {
      const probe: any = {};
      (updater as (p: any) => void)(probe);
      expect(JSON.stringify(probe)).not.toContain(RAW_SECRET);
    }

    // ...and the input is cleared, so it isn't sitting in the DOM either.
    expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
    expect(testid('webhook-secret-saved')).toBeTruthy();
  });

  test('typing a secret without blurring writes nothing at all', () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    setValue(testid('webhook-secret-input') as HTMLInputElement, RAW_SECRET);
    expect(fetchMock).not.toHaveBeenCalled();
    expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
  });

  test('a failed store surfaces the endpoint message and persists no id', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false, status: 429,
      json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
    }));

    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    const input = testid('webhook-secret-input') as HTMLInputElement;
    setValue(input, RAW_SECRET);
    await blur(input);

    expect(testid('webhook-secret-error')!.textContent).toContain('Too many webhook secrets stored for this site');
    expect(lastProps.webhookSecretId).toBe('');
    expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
  });

  test('the stored secret is never shown -- only its state, with a Clear action', () => {
    lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
    // The field is a password input that starts empty: there is no read route to
    // populate it from, and nothing anywhere renders the value.
    const input = testid('webhook-secret-input') as HTMLInputElement;
    expect(input.type).toBe('password');
    expect(input.value).toBe('');

    click(testid('webhook-secret-remove'));
    expect(lastProps.webhookSecretId).toBe('');
  });

  test('blurring an empty secret field posts nothing', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    await blur(testid('webhook-secret-input')!);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  test('the Clear action says what it does: the stored key stays on the server', () => {
    lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    // "Remove" would be a lie: nothing deletes the key file, and it keeps
    // counting toward the per-site cap the customer eventually 429s against.
    expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
    expect(container.textContent).toContain('the stored key stays on the server');
  });
});

describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
  /* GuidedStyles renders <FormStylePanel> with no key, so a selection change
     re-renders rather than remounts. A failed store deliberately KEEPS the
     draft, so without a remount the next form's field would open holding the
     previous form's raw secret -- and the next blur would POST it and assign
     the returned id to the wrong node. */
  test('after a failed store on node A, selecting node B shows an empty field and no status', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false, status: 429,
      json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
    }));

    const nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
    lastProps = nodeA;
    render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
    const input = testid('webhook-secret-input') as HTMLInputElement;
    setValue(input, RAW_SECRET);
    await blur(input);

    // Precondition: the draft really was retained on node A (otherwise this
    // test would pass for the wrong reason).
    expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
    expect(testid('webhook-secret-error')).toBeTruthy();

    const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
    lastProps = nodeB;
    rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);

    expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
    expect(container.textContent).not.toContain(RAW_SECRET);
    expect(testid('webhook-secret-error')).toBeNull();
  });

  test('a "Secret stored." banner does not follow the selection either', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true, status: 200,
      json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
    }));

    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
    const input = testid('webhook-secret-input') as HTMLInputElement;
    setValue(input, RAW_SECRET);
    await blur(input);
    expect(testid('webhook-secret-saved')).toBeTruthy();

    const nodeB = contactFormProps({ destinationType: 'webhook' });
    lastProps = nodeB;
    rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
    expect(testid('webhook-secret-saved')).toBeNull();
    expect(testid('webhook-secret-status')).toBeNull();
  });
});

describe('isHttpsWebhookUrl / the inline URL warning', () => {
  test.each([
    ['https://hooks.example.com/x', true],
    ['https://hooks.example.com/x?a=1&b=2', true],
    ['http://hooks.example.com/x', false],
    ['hooks.example.com/x', false],
    ['/relative/path', false],
    ['', false],
    ['   ', false],
    ['https://hooks.example.com/x\nHost: evil', false],
    ['javascript:alert(1)', false],
  ])('%s -> %s', (value, expected) => {
    expect(isHttpsWebhookUrl(value)).toBe(expected);
  });

  test('a blank URL warns that nothing will be delivered', () => {
    lastProps = contactFormProps({ destinationType: 'webhook' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
  });

  test('a non-https URL warns without altering the value', () => {
    lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
    // Warning only -- the panel must not blank or rewrite the prop, which would
    // turn the publish step's loud refusal into a silently inert form.
    expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
    expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
  });

  test('a valid https URL shows no warning', () => {
    lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
    render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
    expect(testid('webhook-url-warning')).toBeNull();
  });
});
