import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';

import { type InterpolateFunction, toDataFrame } from '@grafana/data';
import config from 'app/core/config';

import { CodeLanguage, RenderMode, TextMode } from '../../panelcfg.gen';

import { PREVIEW_TEST_ID, TextNGEditor, type TextNGEditorChange } from './TextNGEditor';
import { FOOTER_TEST_ID } from './TextNGEditorFooter';
import { FORMAT_TOOLBAR_TEST_ID } from './TextNGFormatToolbar';

// The real CodeMirrorEditor pulls in a heavy, lazily-loaded CodeMirror bundle;
// stub it with a plain textarea so these tests stay fast and deterministic.
jest.mock('@grafana/ui/unstable', () => ({
  __esModule: true,
  // The stubbed editor never runs completions; the source itself is covered by
  // variableCompletion.test.ts.
  createVariableCompletionSource: () => () => null,
  CodeMirrorEditor: ({
    value,
    onChange,
    basicSetup,
    'aria-label': ariaLabel,
  }: {
    value: string;
    onChange: (value: string) => void;
    basicSetup?: { lineNumbers?: boolean };
    'aria-label'?: string;
  }) => (
    <textarea
      aria-label={ariaLabel}
      value={value}
      data-line-numbers={String(Boolean(basicSetup?.lineNumbers))}
      onChange={(e) => onChange(e.target.value)}
    />
  ),
}));

function ControlledEditor({
  initialValue,
  mode: initialMode,
  showLineNumbers: initialShowLineNumbers = false,
  codeLanguage: initialLanguage,
  replaceVariables = (value: string) => value,
  onChange,
}: {
  initialValue: string;
  mode: TextMode;
  showLineNumbers?: boolean;
  codeLanguage?: CodeLanguage;
  replaceVariables?: (value: string) => string;
  onChange: (change: TextNGEditorChange) => void;
}) {
  const [value, setValue] = useState(initialValue);
  const [mode, setMode] = useState(initialMode);
  const [codeLanguage, setCodeLanguage] = useState(initialLanguage);
  const [showLineNumbers, setShowLineNumbers] = useState(initialShowLineNumbers);
  return (
    <TextNGEditor
      content={value}
      mode={mode}
      showLineNumbers={showLineNumbers}
      codeLanguage={codeLanguage}
      replaceVariables={replaceVariables}
      onChange={(change) => {
        setValue(change.content);
        if (change.mode !== undefined) {
          setMode(change.mode);
        }
        if (change.codeLanguage !== undefined) {
          setCodeLanguage(change.codeLanguage);
        }
        if (change.showLineNumbers !== undefined) {
          setShowLineNumbers(change.showLineNumbers);
        }
        onChange(change);
      }}
    />
  );
}

const setup = (
  value: string,
  mode: TextMode,
  onChange = jest.fn(),
  showLineNumbers = false,
  codeLanguage?: CodeLanguage,
  replaceVariables: (value: string) => string = (v) => v
) => {
  render(
    <ControlledEditor
      initialValue={value}
      mode={mode}
      showLineNumbers={showLineNumbers}
      codeLanguage={codeLanguage}
      replaceVariables={replaceVariables}
      onChange={onChange}
    />
  );
  return { onChange };
};

const enterWriteMode = () => userEvent.click(screen.getByRole('radio', { name: 'Write' }));

const openModeMenu = () => userEvent.click(screen.getByRole('button', { name: /^Text mode/ }));

const selectMode = async (name: string) => {
  await openModeMenu();
  await userEvent.click(await screen.findByRole('menuitemradio', { name }));
};

const selectLanguage = async (name: string) => {
  await openModeMenu();
  await userEvent.hover(screen.getByRole('menuitem', { name: 'Code' }));
  await userEvent.click(await screen.findByRole('menuitemradio', { name }));
};

describe('TextNGEditor', () => {
  describe('default (view-first) state', () => {
    it('lands on the rendered preview, not the editor', () => {
      setup('# Hello', TextMode.Markdown);

      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
      expect(screen.getByRole('radio', { name: 'Preview' })).toBeChecked();
      expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
    });

    it('opens straight into the editor when content is empty', () => {
      setup('', TextMode.Markdown);

      expect(screen.getByRole('textbox')).toBeInTheDocument();
      expect(screen.getByRole('radio', { name: 'Write' })).toBeChecked();
    });

    it('reveals the editor after selecting Write', async () => {
      setup('# Hello', TextMode.Markdown);

      await enterWriteMode();
      expect(screen.getByRole('textbox')).toHaveValue('# Hello');
      expect(screen.queryByTestId(PREVIEW_TEST_ID)).not.toBeInTheDocument();
    });
  });

  describe('views', () => {
    it('shows only the rendered preview in Preview view', () => {
      setup('# Hello', TextMode.Markdown);

      expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
    });

    it('shows editor and preview side by side in Split view', async () => {
      setup('# Hello', TextMode.Markdown);

      await userEvent.click(screen.getByRole('radio', { name: 'Split' }));

      expect(screen.getByRole('textbox')).toBeInTheDocument();
      expect(screen.getByTestId(PREVIEW_TEST_ID)).toBeInTheDocument();
    });

    it('sanitizes script tags in the HTML mode preview', () => {
      setup('<script>alert(1)</script><p>safe</p>', TextMode.HTML);

      const preview = screen.getByTestId(PREVIEW_TEST_ID);
      expect(preview.innerHTML).not.toContain('<script>');
      expect(preview.innerHTML).toContain('safe');
    });

    it('skips sanitization in the preview when disableSanitizeHtml is set, matching the panel', () => {
      const original = config.disableSanitizeHtml;
      config.disableSanitizeHtml = true;
      try {
        setup('<form><p>kept</p></form>', TextMode.HTML);

        const preview = screen.getByTestId(PREVIEW_TEST_ID);
        expect(preview.innerHTML).toContain('<form>');
      } finally {
        config.disableSanitizeHtml = original;
      }
    });

    it('renders code mode preview as a raw, unrendered code view', () => {
      setup('# Not a heading in code mode', TextMode.Code);

      const preview = screen.getByTestId(PREVIEW_TEST_ID);
      // The preview reuses the read-only code view, so the content stays raw.
      expect(within(preview).getByRole('textbox')).toHaveValue('# Not a heading in code mode');
      expect(preview.innerHTML).not.toContain('<h1');
    });

    it('passes language and line numbers to the code mode preview', () => {
      setup('{\n  "a": 1\n}', TextMode.Code, jest.fn(), true, CodeLanguage.Json);

      const preview = screen.getByTestId(PREVIEW_TEST_ID);
      expect(within(preview).getByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
    });

    it('interpolates variables in the preview but keeps the raw template in the editor', async () => {
      const replaceVariables = (value: string) => value.replace('$datacenter', 'A, B, C');
      setup('# Data center = $datacenter', TextMode.Markdown, jest.fn(), false, undefined, replaceVariables);

      expect(screen.getByTestId(PREVIEW_TEST_ID)).toHaveTextContent('Data center = A, B, C');

      await userEvent.click(screen.getByRole('radio', { name: 'Write' }));
      expect(screen.getByRole('textbox')).toHaveValue('# Data center = $datacenter');
    });

    it('forwards editor changes via a debounced onChange', async () => {
      const { onChange } = setup('initial', TextMode.Markdown);
      await enterWriteMode();

      const editor = screen.getByRole('textbox');
      await userEvent.clear(editor);
      await userEvent.type(editor, 'updated');

      // The commit is debounced so the dashboard is not re-rendered per keystroke.
      await waitFor(() => expect(onChange).toHaveBeenLastCalledWith({ content: 'updated' }));
    });

    it('commits a pending draft when the editor loses focus', async () => {
      const { onChange } = setup('initial', TextMode.Markdown);
      await enterWriteMode();

      const editor = screen.getByRole('textbox');
      await userEvent.clear(editor);
      await userEvent.type(editor, 'updated');

      // Moving focus out (e.g. clicking Save/Apply) must persist the draft
      // synchronously instead of waiting out the commit debounce.
      await userEvent.tab();

      expect(onChange).toHaveBeenLastCalledWith({ content: 'updated' });
    });

    it('commits the latest draft when the last change and the blur happen in the same event turn', async () => {
      const { onChange } = setup('initial', TextMode.Markdown);
      await enterWriteMode();

      const editor = screen.getByRole('textbox');
      // One batch: the blur handler runs before React re-renders with the new draft.
      act(() => {
        fireEvent.change(editor, { target: { value: 'updated' } });
        fireEvent.blur(editor);
      });

      expect(onChange).toHaveBeenCalledWith({ content: 'updated' });
    });

    it('does not commit a pending draft on unmount, so Discard is not overwritten', async () => {
      const onChange = jest.fn();
      const { unmount } = render(
        <ControlledEditor initialValue="initial" mode={TextMode.Markdown} onChange={onChange} />
      );
      await enterWriteMode();

      const editor = screen.getByRole('textbox');
      await userEvent.clear(editor);
      await userEvent.type(editor, 'updated');

      unmount();

      // A flush here could overwrite a Discard.
      expect(onChange).not.toHaveBeenCalledWith({ content: 'updated' });
    });

    it('interpolates with the json format when code language is json, regardless of mode', () => {
      const replaceVariables = jest.fn((value: string) => value);
      setup('# Hello', TextMode.Markdown, jest.fn(), false, CodeLanguage.Json, replaceVariables);

      // Matches the panel render path, which keys the interpolation format off
      // code.language alone.
      expect(replaceVariables).toHaveBeenCalledWith('# Hello', {}, 'json');
    });
  });

  describe('content that renders to nothing', () => {
    // Deleting everything and pressing enter in Split view: markdown renders a
    // lone newline to '', which the space fallback keeps DangerouslySetHtmlContent
    // from throwing on.
    it.each(['\n', '\n\n', '   \n  ', '<!-- just a comment -->'])(
      'renders the empty space fallback in the preview for %j',
      async (content) => {
        setup('# Hello', TextMode.Markdown);
        await userEvent.click(screen.getByRole('radio', { name: 'Split' }));

        // fireEvent, because userEvent.type() does not reproduce a value that is
        // only whitespace. A throw here fails the test.
        fireEvent.change(screen.getByRole('textbox'), { target: { value: content } });

        // Debounced preview settles from the '# Hello' <h1> to the space fallback.
        await waitFor(() => expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML.trim()).toBe(''));
      }
    );
  });

  describe('preview updates', () => {
    it('re-renders the preview once typing settles', async () => {
      setup('# Hello', TextMode.Markdown);
      await userEvent.click(screen.getByRole('radio', { name: 'Split' }));

      fireEvent.change(screen.getByRole('textbox'), { target: { value: '## Updated' } });

      // Still the old preview: the update is debounced.
      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
      await waitFor(() => expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2'));
    });

    it('shows the current draft immediately when switching view, without waiting for the debounce', async () => {
      setup('# Hello', TextMode.Markdown);
      await enterWriteMode();

      fireEvent.change(screen.getByRole('textbox'), { target: { value: '## Updated' } });
      await userEvent.click(screen.getByRole('radio', { name: 'Preview' }));

      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2');
    });

    it('shows externally replaced content immediately, e.g. after a discard', async () => {
      const { rerender } = render(
        <TextNGEditor
          content="# Hello"
          mode={TextMode.Markdown}
          showLineNumbers={false}
          replaceVariables={(value: string) => value}
          onChange={jest.fn()}
        />
      );

      rerender(
        <TextNGEditor
          content="## Reverted"
          mode={TextMode.Markdown}
          showLineNumbers={false}
          replaceVariables={(value: string) => value}
          onChange={jest.fn()}
        />
      );

      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2');
    });
  });

  describe('formatting toolbar', () => {
    it('renders alongside the editor in Write view', async () => {
      setup('hello', TextMode.Markdown);
      await enterWriteMode();

      expect(screen.getByTestId(FORMAT_TOOLBAR_TEST_ID)).toBeInTheDocument();
    });

    it('renders in Split view', async () => {
      setup('hello', TextMode.Markdown);

      await userEvent.click(screen.getByRole('radio', { name: 'Split' }));

      expect(screen.getByTestId(FORMAT_TOOLBAR_TEST_ID)).toBeInTheDocument();
    });

    it('is hidden in Preview view, where there is nothing to format', () => {
      setup('hello', TextMode.Markdown);

      expect(screen.queryByTestId(FORMAT_TOOLBAR_TEST_ID)).not.toBeInTheDocument();
    });

    it('is not rendered in Code mode', async () => {
      setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Typescript);
      await enterWriteMode();

      expect(screen.queryByTestId(FORMAT_TOOLBAR_TEST_ID)).not.toBeInTheDocument();
    });
  });

  describe('mode picker', () => {
    it('shows the current mode in the toolbar, in every view', async () => {
      setup('<p>Hello</p>', TextMode.HTML);

      expect(screen.getByRole('button', { name: 'Text mode: HTML' })).toBeInTheDocument();

      await userEvent.click(screen.getByRole('radio', { name: 'Split' }));
      expect(screen.getByRole('button', { name: 'Text mode: HTML' })).toBeInTheDocument();

      await enterWriteMode();
      expect(screen.getByRole('button', { name: 'Text mode: HTML' })).toBeInTheDocument();
    });

    it('shows the language alongside the mode in Code mode', () => {
      setup('{}', TextMode.Code, jest.fn(), false, CodeLanguage.Json);

      expect(screen.getByRole('button', { name: 'Text mode: Code · JSON' })).toBeInTheDocument();
    });

    it('changes the mode and re-renders the preview with it', async () => {
      const { onChange } = setup('# Hello', TextMode.Markdown);

      await selectMode('HTML');

      expect(onChange).toHaveBeenCalledWith({ mode: TextMode.HTML, content: '# Hello' });
      // HTML mode does not turn '#' into a heading, it renders the text as-is.
      expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML.trim()).toBe('# Hello');
    });

    it('carries a pending draft with the mode change, so typing is not lost', async () => {
      const { onChange } = setup('initial', TextMode.Markdown);
      await enterWriteMode();

      fireEvent.change(screen.getByRole('textbox'), { target: { value: 'draft' } });
      await selectMode('HTML');

      expect(onChange).toHaveBeenLastCalledWith({ mode: TextMode.HTML, content: 'draft' });
      expect(screen.getByRole('textbox')).toHaveValue('draft');
    });
  });

  describe('language submenu', () => {
    it('only hangs off the Code option', async () => {
      setup('# Hello', TextMode.Markdown);

      await openModeMenu();

      expect(screen.getByRole('menuitem', { name: 'Code' })).toHaveAttribute('aria-haspopup', 'menu');
      expect(screen.getByRole('menuitemradio', { name: 'Markdown' })).not.toHaveAttribute('aria-haspopup');
    });

    it('falls back to the default language when none is set', () => {
      setup('hello', TextMode.Code);

      expect(screen.getByRole('button', { name: 'Text mode: Code · Plain text' })).toBeInTheDocument();
    });

    it('switches to Code mode and the picked language in one change', async () => {
      const { onChange } = setup('# Hello', TextMode.Markdown);

      await selectLanguage('SQL');

      // A second, mode-only change from the parent item would drop the language.
      expect(onChange).toHaveBeenCalledTimes(1);
      expect(onChange).toHaveBeenCalledWith({
        mode: TextMode.Code,
        codeLanguage: CodeLanguage.Sql,
        content: '# Hello',
      });
      expect(screen.getByRole('button', { name: 'Text mode: Code · SQL' })).toBeInTheDocument();
    });

    it('changes the language while already in Code mode', async () => {
      const { onChange } = setup('{}', TextMode.Code, jest.fn(), false, CodeLanguage.Plaintext);

      await selectLanguage('JSON');

      expect(onChange).toHaveBeenCalledWith({ mode: TextMode.Code, codeLanguage: CodeLanguage.Json, content: '{}' });
      expect(screen.getByRole('button', { name: 'Text mode: Code · JSON' })).toBeInTheDocument();
    });
  });

  describe('line numbers', () => {
    it('never shows line numbers in Markdown mode', async () => {
      setup('# Hello', TextMode.Markdown, jest.fn(), true);
      await enterWriteMode();

      expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
    });

    it('never shows line numbers in HTML mode', async () => {
      setup('<p>Hello</p>', TextMode.HTML, jest.fn(), true);
      await enterWriteMode();

      expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
    });

    it('shows line numbers in Code mode when showLineNumbers is enabled', async () => {
      setup('const a = 1;', TextMode.Code, jest.fn(), true, CodeLanguage.Json);
      await enterWriteMode();

      expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
    });

    it('hides line numbers in Code mode when showLineNumbers is disabled', async () => {
      setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Json);
      await enterWriteMode();

      expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'false');
    });
  });

  describe('footer', () => {
    const lineNumbersToggle = () => screen.getByRole('switch', { name: 'Line numbers' });

    it('only shows the footer in Code mode', async () => {
      setup('# Hello', TextMode.Markdown);

      expect(screen.queryByTestId(FOOTER_TEST_ID)).not.toBeInTheDocument();

      await selectLanguage('JSON');
      expect(screen.getByTestId(FOOTER_TEST_ID)).toBeInTheDocument();
    });

    it('keeps the footer in Preview view, where the editor is hidden', () => {
      setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Typescript);

      expect(screen.getByRole('radio', { name: 'Preview' })).toBeChecked();
      expect(screen.getByTestId(FOOTER_TEST_ID)).toBeInTheDocument();
    });

    it('turns line numbers on and applies them to the editor', async () => {
      const { onChange } = setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Typescript);
      await enterWriteMode();

      await userEvent.click(lineNumbersToggle());

      expect(onChange).toHaveBeenLastCalledWith({ showLineNumbers: true, content: 'const a = 1;' });
      expect(screen.getByRole('textbox')).toHaveAttribute('data-line-numbers', 'true');
    });

    it('sends the pending draft with the line numbers change, so typing is not lost', async () => {
      const { onChange } = setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Typescript);
      await enterWriteMode();

      fireEvent.change(screen.getByRole('textbox'), { target: { value: 'const b = 2;' } });
      await userEvent.click(lineNumbersToggle());

      expect(onChange).toHaveBeenLastCalledWith({ showLineNumbers: true, content: 'const b = 2;' });
      expect(screen.getByRole('textbox')).toHaveValue('const b = 2;');
    });
  });
});

describe('TextNGEditor render mode preview', () => {
  const series = [
    toDataFrame({
      fields: [{ name: 'host', values: ['web-1', 'web-2'] }],
    }),
  ];

  // Reports the row context it was handed, so these assert the preview wiring
  // rather than re-testing macro resolution (covered in renderContent.test.ts).
  const reportRowContext: InterpolateFunction = (target, scopedVars) => {
    const context = scopedVars?.__dataContext?.value;
    return context ? `row-${context.rowIndex}` : target;
  };

  const previewFor = (renderMode?: RenderMode) => (
    <TextNGEditor
      content="no row context"
      mode={TextMode.Markdown}
      showLineNumbers={false}
      renderMode={renderMode}
      series={series}
      replaceVariables={reportRowContext}
      onChange={jest.fn()}
    />
  );

  const previewHtml = () => screen.getByTestId(PREVIEW_TEST_ID).innerHTML;

  it('previews one block per row in every row mode, matching the panel', () => {
    render(previewFor(RenderMode.PerRow));

    expect(previewHtml()).toContain('row-0');
    expect(previewHtml()).toContain('row-1');
  });

  it.each([
    ['once mode', RenderMode.Once],
    ['an unset render mode', undefined],
  ])('previews a single render with no row context in %s', (_name, renderMode) => {
    render(previewFor(renderMode));

    expect(previewHtml()).toContain('no row context');
  });

  it('updates the preview when the pane changes the render mode', () => {
    const { rerender } = render(previewFor(RenderMode.Once));
    expect(previewHtml()).toContain('no row context');

    rerender(previewFor(RenderMode.PerRow));

    expect(previewHtml()).toContain('row-0');
    expect(previewHtml()).toContain('row-1');
  });
});
