/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
import {describe, it} from 'node:test';

import {
  click,
  hover,
  fill,
  drag,
  fillForm,
  uploadFile,
  pressKey,
} from '../../src/tools/input.js';
import {parseKey} from '../../src/utils/keyboard.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';

describe('input', () => {
  const server = serverHooks();

  describe('click', () => {
    it('clicks', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<button onclick="this.innerText = 'clicked';">test</button>`,
        );
        await context.createTextSnapshot();
        await click.handler(
          {
            params: {
              uid: '1_1',
            },
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/clicked'));
      });
    });
    it('double clicks', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<button ondblclick="this.innerText = 'dblclicked';"
            >test</button
          >`,
        );
        await context.createTextSnapshot();
        await click.handler(
          {
            params: {
              uid: '1_1',
              dblClick: true,
            },
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully double clicked on the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/dblclicked'));
      });
    });
    it('waits for navigation', async () => {
      const resolveNavigation = Promise.withResolvers<void>();
      server.addHtmlRoute(
        '/link',
        html`<a href="/navigated">Navigate page</a>`,
      );
      server.addRoute('/navigated', async (_req, res) => {
        await resolveNavigation.promise;
        res.write(html`<main>I was navigated</main>`);
        res.end();
      });

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.goto(server.getRoute('/link'));
        await context.createTextSnapshot();
        const clickPromise = click.handler(
          {
            params: {
              uid: '1_1',
            },
          },
          response,
          context,
        );
        const [t1, t2] = await Promise.all([
          clickPromise.then(() => Date.now()),
          new Promise<number>(res => {
            setTimeout(() => {
              resolveNavigation.resolve();
              res(Date.now());
            }, 300);
          }),
        ]);

        assert(t1 > t2, 'Waited for navigation');
      });
    });

    it('waits for stable DOM', async () => {
      server.addHtmlRoute(
        '/unstable',
        html`
          <button>Click to change to see time</button>
          <script>
            const button = document.querySelector('button');
            button.addEventListener('click', () => {
              setTimeout(() => {
                button.textContent = Date.now();
              }, 50);
            });
          </script>
        `,
      );
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.goto(server.getRoute('/unstable'));
        await context.createTextSnapshot();
        const handlerResolveTime = await click
          .handler(
            {
              params: {
                uid: '1_1',
              },
            },
            response,
            context,
          )
          .then(() => Date.now());
        const buttonChangeTime = await page.evaluate(() => {
          const button = document.querySelector('button');
          return Number(button?.textContent);
        });

        assert(handlerResolveTime > buttonChangeTime, 'Waited for navigation');
      });
    });
  });

  describe('hover', () => {
    it('hovers', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<button onmouseover="this.innerText = 'hovered';">test</button>`,
        );
        await context.createTextSnapshot();
        await hover.handler(
          {
            params: {
              uid: '1_1',
            },
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully hovered over the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/hovered'));
      });
    });
  });

  describe('fill', () => {
    it('fills out an input', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(html`<input />`);
        await context.createTextSnapshot();
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'test',
            },
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/test'));
      });
    });

    it('fills out a select by text', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<select
            ><option value="v1">one</option
            ><option value="v2">two</option></select
          >`,
        );
        await context.createTextSnapshot();
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'two',
            },
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        const selectedValue = await page.evaluate(
          () => document.querySelector('select')!.value,
        );
        assert.strictEqual(selectedValue, 'v2');
      });
    });
  });

  describe('drags', () => {
    it('drags one element onto another', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<div
              role="button"
              id="drag"
              draggable="true"
              >drag me</div
            >
            <div
              id="drop"
              aria-label="drop"
              style="width: 100px; height: 100px; border: 1px solid black;"
              ondrop="this.innerText = 'dropped';"
            >
            </div>
            <script>
              drag.addEventListener('dragstart', event => {
                event.dataTransfer.setData('text/plain', event.target.id);
              });
              drop.addEventListener('dragover', event => {
                event.preventDefault();
                event.dataTransfer.dropEffect = 'move';
              });
              drop.addEventListener('drop', event => {
                event.preventDefault();
                const data = event.dataTransfer.getData('text/plain');
                event.target.appendChild(document.getElementById(data));
              });
            </script>`,
        );
        await context.createTextSnapshot();
        await drag.handler(
          {
            params: {
              from_uid: '1_1',
              to_uid: '1_2',
            },
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          'Successfully dragged an element',
        );
        assert.ok(await page.$('text/dropped'));
      });
    });
  });

  describe('fill form', () => {
    it('successfully fills out the form', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<form>
            <label
              >username<input
                name="username"
                type="text"
            /></label>
            <label
              >email<input
                name="email"
                type="text"
            /></label>
            <input
              type="submit"
              value="Submit"
            />
          </form>`,
        );
        await context.createTextSnapshot();
        await fillForm.handler(
          {
            params: {
              elements: [
                {
                  uid: '1_2',
                  value: 'test',
                },
                {
                  uid: '1_4',
                  value: 'test2',
                },
              ],
            },
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the form',
        );
        assert.deepStrictEqual(
          await page.evaluate(() => {
            return [
              // @ts-expect-error missing types
              document.querySelector('input[name=username]').value,
              // @ts-expect-error missing types
              document.querySelector('input[name=email]').value,
            ];
          }),
          ['test', 'test2'],
        );
      });
    });
  });

  describe('uploadFile', () => {
    it('uploads a file to a file input', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<form>
            <input
              type="file"
              id="file-input"
            />
          </form>`,
        );
        await context.createTextSnapshot();
        await uploadFile.handler(
          {
            params: {
              uid: '1_1',
              filePath: testFilePath,
            },
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          `File uploaded from ${testFilePath}.`,
        );
      });

      await fs.unlink(testFilePath);
    });

    it('uploads a file when clicking an element opens a file uploader', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<button id="file-chooser-button">Upload file</button>
            <input
              type="file"
              id="file-input"
              style="display: none;"
            />
            <script>
              document
                .getElementById('file-chooser-button')
                .addEventListener('click', () => {
                  document.getElementById('file-input').click();
                });
            </script>`,
        );
        await context.createTextSnapshot();
        await uploadFile.handler(
          {
            params: {
              uid: '1_1',
              filePath: testFilePath,
            },
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          `File uploaded from ${testFilePath}.`,
        );
        const uploadedFileName = await page.$eval('#file-input', el => {
          const input = el as HTMLInputElement;
          return input.files?.[0]?.name;
        });
        assert.strictEqual(uploadedFileName, 'test.txt');

        await fs.unlink(testFilePath);
      });
    });

    it('throws an error if the element is not a file input and does not open a file chooser', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(html`<div>Not a file input</div>`);
        await context.createTextSnapshot();

        await assert.rejects(
          uploadFile.handler(
            {
              params: {
                uid: '1_1',
                filePath: testFilePath,
              },
            },
            response,
            context,
          ),
          {
            message:
              'Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.',
          },
        );

        assert.strictEqual(response.responseLines.length, 0);
        assert.strictEqual(response.snapshotParams, undefined);

        await fs.unlink(testFilePath);
      });
    });
  });

  describe('press_key', () => {
    it('parses keys', () => {
      assert.deepStrictEqual(parseKey('Shift+A'), ['A', 'Shift']);
      assert.deepStrictEqual(parseKey('Shift++'), ['+', 'Shift']);
      assert.deepStrictEqual(parseKey('Control+Shift++'), [
        '+',
        'Control',
        'Shift',
      ]);
      assert.deepStrictEqual(parseKey('Shift'), ['Shift']);
      assert.deepStrictEqual(parseKey('KeyA'), ['KeyA']);
    });
    it('throws on empty key', () => {
      assert.throws(() => {
        parseKey('');
      });
    });
    it('throws on invalid key', () => {
      assert.throws(() => {
        parseKey('aaaaa');
      });
    });
    it('throws on multiple keys', () => {
      assert.throws(() => {
        parseKey('Shift+Shift');
      });
    });

    it('processes press_key', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedPage();
        await page.setContent(
          html`<script>
            logs = [];
            document.addEventListener('keydown', e => logs.push('d' + e.key));
            document.addEventListener('keyup', e => logs.push('u' + e.key));
          </script>`,
        );
        await context.createTextSnapshot();

        await pressKey.handler(
          {
            params: {
              key: 'Control+Shift+C',
            },
          },
          response,
          context,
        );

        assert.deepStrictEqual(await page.evaluate('logs'), [
          'dControl',
          'dShift',
          'dC',
          'uC',
          'uShift',
          'uControl',
        ]);
      });
    });
  });
});
