import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

import { OneNoteApplication } from './App.js';
import type {
  OneNotePackageDto,
  OneNoteSectionDto,
} from './onenote/model/dto.js';
import {
  MAX_SOURCE_FILE_BYTES,
  sourceKindFromFileName,
} from './source-file.js';
import {
  OneNoteWorkerClient,
  type WorkerPort,
} from './worker/onenote.client.js';
import type {
  WorkerRequest,
  WorkerResponse,
} from './worker/worker-protocol.js';

const section: OneNoteSectionDto = {
  format: 'one',
  sourceName: 'Section.one',
  sectionName: 'Project notes',
  pages: [
    {
      id: 'page-1',
      title: 'First meeting',
      text: 'This is the complete extracted page text.',
      textPreview: 'This is the complete extracted page text.',
      createdAt: '2024-01-02T10:00:00.000Z',
    },
  ],
  diagnostics: [
    {
      severity: 'warning',
      code: 'UNSUPPORTED_IMAGE',
      message: 'An image was not rendered.',
      recoverable: true,
    },
  ],
  parserInfo: {
    implementation: 'typescript',
    supportedVariant: 'desktop-revision-store',
    referenceRevision: 'test',
  },
};

class SuccessfulParserWorker implements WorkerPort {
  onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
  onerror: ((event: ErrorEvent) => void) | null = null;
  terminated = false;
  lastMessage: WorkerRequest | undefined;
  readonly messages: WorkerRequest[] = [];
  lastTransfer: Transferable[] = [];

  constructor(
    private readonly packageResult?: OneNotePackageDto,
    private readonly resourcePage = false
  ) {}

  postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
    this.lastMessage = message;
    this.messages.push(message);
    this.lastTransfer = transfer;
    queueMicrotask(() => {
      if (message.type === 'parse-one') {
        this.respond({
          type: 'section',
          requestId: message.requestId,
          sessionId: 'session-one',
          section,
        });
      } else if (message.type === 'parse-onepkg' && this.packageResult) {
        this.respond({
          type: 'package',
          requestId: message.requestId,
          sessionId: 'session-package',
          notebook: this.packageResult,
        });
      } else if (message.type === 'get-page') {
        const page = section.pages[0]!;
        this.respond({
          type: 'page',
          requestId: message.requestId,
          sessionId: message.sessionId,
          page: {
            ...page,
            id: message.pageId,
            blocks: this.resourcePage
              ? [
                  {
                    kind: 'image',
                    id: 'image-1',
                    resourceId: 'resource-image',
                    filename: 'large.png',
                    isBackground: false,
                    layout: {},
                  },
                ]
              : [
                  {
                    kind: 'text',
                    id: 'text-1',
                    sourceText: page.text,
                    text: page.text,
                    runs: [
                      {
                        start: 0,
                        end: page.text.length,
                        text: page.text,
                        style: {},
                      },
                    ],
                    paragraphStyle: {},
                    layout: {},
                  },
                ],
            diagnostics: [],
          },
        });
      } else if (message.type === 'get-resource') {
        if (message.intent === 'preview') {
          this.respond({
            type: 'failure',
            requestId: message.requestId,
            error: {
              code: 'resource-not-available',
              message:
                'This image can be downloaded but is not approved for automatic preview.',
              recoverable: true,
            },
          });
        } else {
          this.respond({
            type: 'resource',
            requestId: message.requestId,
            sessionId: message.sessionId,
            resource: {
              id: message.resourceId,
              kind: 'image',
              filename: 'large.png',
              extension: 'png',
              mediaType: 'image/png',
              browserRenderable: false,
              size: 4,
              bytes: Uint8Array.of(1, 2, 3, 4).buffer,
            },
          });
        }
      } else if (message.type === 'export-session') {
        this.respond({
          type: 'export-artifact',
          requestId: message.requestId,
          sessionId: message.sessionId,
          artifact: {
            filename: 'Project notes.zip',
            mediaType: 'application/zip',
            bytes: Uint8Array.of(0x50, 0x4b, 3, 4).buffer,
          },
        });
      }
    });
  }

  terminate(): void {
    this.terminated = true;
  }

  private respond(response: WorkerResponse): void {
    this.onmessage?.(new MessageEvent('message', { data: response }));
  }
}

class FailingParserWorker implements WorkerPort {
  onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
  onerror: ((event: ErrorEvent) => void) | null = null;

  postMessage(message: WorkerRequest): void {
    if (message.type !== 'parse-one' && message.type !== 'parse-onepkg') return;
    queueMicrotask(() => {
      this.onmessage?.(
        new MessageEvent('message', {
          data: {
            type: 'failure',
            requestId: message.requestId,
            error: {
              code: 'invalid-format',
              message: 'The source header is not a recognized OneNote file.',
              recoverable: false,
              structure: 'OneStore header',
            },
          } satisfies WorkerResponse,
        })
      );
    });
  }

  terminate(): void {}
}

describe('OneNoteApplication', () => {
  it('recognizes only supported local filename extensions', () => {
    expect(sourceKindFromFileName('Section.ONE')).toBe('one');
    expect(sourceKindFromFileName('Notebook.OnePkg')).toBe('onepkg');
    expect(sourceKindFromFileName('notes.zip')).toBeNull();
  });

  it('opens a section, navigates its page, shows diagnostics, and clears', async () => {
    const user = userEvent.setup();
    const worker = new SuccessfulParserWorker();
    render(
      <OneNoteApplication
        createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
      />
    );

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File([new Uint8Array([1, 2, 3])], 'Section.one')
    );

    expect(
      await screen.findByRole('heading', { name: 'First meeting' })
    ).toBeInTheDocument();
    expect(
      within(screen.getByRole('article', { name: 'First meeting' })).getByText(
        'This is the complete extracted page text.'
      )
    ).toBeInTheDocument();
    expect(screen.getByText('UNSUPPORTED_IMAGE')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: 'Clear file' }));
    expect(worker.terminated).toBe(true);
    expect(
      screen.getByRole('heading', { name: 'Open a OneNote file' })
    ).toBeInTheDocument();
  });

  it('downloads a worker-built export after explicit user action', async () => {
    const user = userEvent.setup();
    const worker = new SuccessfulParserWorker();
    const createObjectUrl = vi
      .spyOn(URL, 'createObjectURL')
      .mockReturnValue('blob:local-export');
    const click = vi
      .spyOn(HTMLAnchorElement.prototype, 'click')
      .mockImplementation(() => undefined);
    render(
      <OneNoteApplication
        createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
      />
    );

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File([new Uint8Array([1, 2, 3])], 'Section.one')
    );
    await screen.findByRole('heading', { name: 'First meeting' });
    await user.click(screen.getByRole('button', { name: 'Create export' }));

    expect(
      await screen.findByText('The export download is ready.')
    ).toBeVisible();
    expect(worker.lastMessage).toEqual({
      type: 'export-session',
      requestId: 'export-3',
      sessionId: 'session-one',
      format: 'static-zip',
    });
    expect(createObjectUrl).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'application/zip' })
    );
    expect(click).toHaveBeenCalledOnce();
  });

  it('requests a rejected preview separately from an explicit image download', async () => {
    const user = userEvent.setup();
    const worker = new SuccessfulParserWorker(undefined, true);
    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image');
    const click = vi
      .spyOn(HTMLAnchorElement.prototype, 'click')
      .mockImplementation(() => undefined);
    render(
      <OneNoteApplication
        createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
      />
    );

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File([new Uint8Array([1, 2, 3])], 'Section.one')
    );

    expect(
      await screen.findByText(/not approved for automatic preview/i)
    ).toBeVisible();
    expect(worker.messages).toContainEqual(
      expect.objectContaining({
        type: 'get-resource',
        resourceId: 'resource-image',
        intent: 'preview',
      })
    );
    expect(
      worker.messages.some(
        (message) =>
          message.type === 'get-resource' && message.intent === 'download'
      )
    ).toBe(false);

    await user.click(
      screen.getByRole('button', { name: 'Download original image' })
    );
    expect(worker.messages).toContainEqual(
      expect.objectContaining({
        type: 'get-resource',
        resourceId: 'resource-image',
        intent: 'download',
      })
    );
    expect(click).toHaveBeenCalledOnce();
  });

  it('opens a dropped OneNote package through the local worker', async () => {
    const notebook: OneNotePackageDto = {
      format: 'onepkg',
      sourceName: 'Notebook.onepkg',
      sourceSize: 1,
      entries: [],
      sections: [],
      tree: { interpreted: false, nodes: [] },
      diagnostics: [],
    };
    const worker = new SuccessfulParserWorker(notebook);
    render(
      <OneNoteApplication
        createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
      />
    );

    const part3 = new File([new Uint8Array([3])], 'Notebook-3.cab');
    const primary = new File([new Uint8Array([1])], 'Notebook.onepkg');
    const part2 = new File([new Uint8Array([2])], 'Notebook-2.cab');
    fireEvent.drop(screen.getByTestId('file-drop-zone'), {
      dataTransfer: {
        files: [part3, primary, part2],
      },
    });

    expect(
      await screen.findByRole('heading', { name: 'Package summary' })
    ).toBeInTheDocument();
    expect(worker.lastMessage).toEqual(
      expect.objectContaining({
        type: 'parse-onepkg',
        fileName: 'Notebook.onepkg',
        cabinetParts: [
          expect.objectContaining({ fileName: 'Notebook-3.cab' }),
          expect.objectContaining({ fileName: 'Notebook-2.cab' }),
        ],
      })
    );
    expect(worker.lastTransfer).toHaveLength(3);
  });

  it('hands a multi-file picker selection to the package worker', async () => {
    const notebook: OneNotePackageDto = {
      format: 'onepkg',
      sourceName: 'Notebook.onepkg',
      sourceSize: 2,
      entries: [],
      sections: [],
      tree: { interpreted: false, nodes: [] },
      diagnostics: [],
    };
    const worker = new SuccessfulParserWorker(notebook);
    const user = userEvent.setup();
    render(
      <OneNoteApplication
        createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
      />
    );

    const input = screen.getByLabelText('Choose OneNote files');
    expect(input).toHaveAttribute('multiple');
    expect(input).toHaveAttribute('accept', '.one,.onepkg,.cab');
    await user.upload(input, [
      new File([new Uint8Array([2])], 'Notebook-2.cab'),
      new File([new Uint8Array([1])], 'Notebook.onepkg'),
    ]);

    expect(
      await screen.findByRole('heading', { name: 'Package summary' })
    ).toBeInTheDocument();
    expect(worker.lastMessage).toEqual(
      expect.objectContaining({
        type: 'parse-onepkg',
        fileName: 'Notebook.onepkg',
        cabinetParts: [expect.objectContaining({ fileName: 'Notebook-2.cab' })],
      })
    );
  });

  it('keeps a failed packaged section visible beside a readable one', async () => {
    const notebook: OneNotePackageDto = {
      format: 'onepkg',
      sourceName: 'Notebook.onepkg',
      sourceSize: 4096,
      entries: [
        {
          path: 'Notes/Readable.one',
          normalizedPath: 'Notes/Readable.one',
          kind: 'section',
          uncompressedSize: 2048,
          compressionMethod: 'LZX',
          parseStatus: 'parsed',
        },
        {
          path: 'Notes/Broken.one',
          normalizedPath: 'Notes/Broken.one',
          kind: 'section',
          uncompressedSize: 1024,
          compressionMethod: 'LZX',
          parseStatus: 'failed',
        },
      ],
      sections: [
        {
          id: 'readable',
          path: 'Notes/Readable.one',
          displayName: 'Readable',
          parseStatus: 'parsed',
          section,
          diagnostics: [],
        },
        {
          id: 'broken',
          path: 'Notes/Broken.one',
          displayName: 'Broken',
          parseStatus: 'failed',
          diagnostics: [
            {
              severity: 'error',
              code: 'SECTION_PARSE_FAILED',
              message: 'This section is malformed.',
              recoverable: true,
            },
          ],
        },
      ],
      tree: {
        interpreted: true,
        tocPath: 'Open Notebook.onetoc2',
        nodes: [
          {
            kind: 'section-group',
            id: 'Notes',
            displayName: 'Notes',
            children: [
              {
                kind: 'section',
                id: 'readable',
                sectionId: 'readable',
                displayName: 'Readable',
              },
              {
                kind: 'section',
                id: 'broken',
                sectionId: 'broken',
                displayName: 'Broken',
              },
            ],
          },
        ],
      },
      diagnostics: [
        {
          severity: 'warning',
          code: 'TEST_PACKAGE_WARNING',
          message: 'Example recoverable package warning.',
          recoverable: true,
        },
      ],
    };
    const user = userEvent.setup();
    render(
      <OneNoteApplication
        createWorkerClient={() =>
          new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
        }
      />
    );

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File([new Uint8Array([1])], 'Notebook.onepkg')
    );

    expect(
      await screen.findByRole('heading', { name: 'Package summary' })
    ).toBeInTheDocument();
    expect(screen.getByText('Notes')).toBeVisible();
    expect(screen.getByText('OneNote notebook order')).toBeVisible();
    expect(
      screen.getByRole('button', { name: /Broken, failed/i })
    ).toBeVisible();
    expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
    expect(
      await within(
        screen.getByRole('article', { name: 'First meeting' })
      ).findByText('This is the complete extracted page text.')
    ).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: /Broken, failed/i }));
    expect(screen.getByText('SECTION_PARSE_FAILED')).toBeInTheDocument();
    expect(
      screen.getByText('This section did not produce a readable page list.')
    ).toBeInTheDocument();
  });

  it('shows a controlled parser failure', async () => {
    const user = userEvent.setup();
    render(
      <OneNoteApplication
        createWorkerClient={() =>
          new OneNoteWorkerClient(() => new FailingParserWorker())
        }
      />
    );

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File(['not OneNote'], 'Broken.one')
    );

    expect(await screen.findByRole('alert')).toHaveTextContent(
      'The source header is not a recognized OneNote file.'
    );
    expect(screen.getByRole('alert')).toHaveTextContent(
      'invalid-format · OneStore header'
    );
  });

  it('rejects unrelated extensions without starting a worker', async () => {
    const user = userEvent.setup({ applyAccept: false });
    render(<OneNoteApplication />);

    await user.upload(
      screen.getByLabelText('Choose OneNote files'),
      new File(['not a notebook'], 'notes.zip')
    );

    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Choose one .one file, or one .onepkg file together with its .cab parts.'
    );
  });

  it('enforces the source-size safety limit before reading', async () => {
    const user = userEvent.setup();
    render(<OneNoteApplication />);
    const file = new File(['tiny'], 'large.one');
    Object.defineProperty(file, 'size', {
      configurable: true,
      value: MAX_SOURCE_FILE_BYTES + 1,
    });

    await user.upload(screen.getByLabelText('Choose OneNote files'), file);

    expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
  });
});
