import { beforeEach, describe, expect, it, vi } from 'vitest';

// The browser file adapter is mocked: export drives downloadJson, import reads
// via readTextFile. The service logic (merge, notify) is what we exercise.
vi.mock('../infrastructure/file-transfer', () => ({
  downloadJson: vi.fn(),
  readTextFile: vi.fn(),
}));

// The IDB adapter is mocked so tests are deterministic and don't rely on
// happy-dom's IndexedDB. saveSnippet and deleteSnippet are replaced with
// in-memory no-ops by default; individual tests override them to simulate
// quota failures.
vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
  const original = await importOriginal<typeof import('../infrastructure/snippet-store')>();
  return {
    ...original,
    saveSnippet: vi.fn().mockResolvedValue(undefined),
    deleteSnippet: vi.fn().mockResolvedValue(undefined),
  };
});

// FontFace registration is a browser side effect; the service contract is just
// "register the imported faces once the import commits", so a spy suffices.
vi.mock('../infrastructure/font-faces', () => ({
  registerFontAssets: vi.fn(),
}));

import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createFontAsset, serializeFontAsset, type SerializedFontAsset } from '@core/font-asset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { registerFontAssets } from '../infrastructure/font-faces';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './transfer';

const mockedDownload = vi.mocked(downloadJson);
const mockedRead = vi.mocked(readTextFile);
const mockedSave = vi.mocked(saveSnippet);
const mockedDelete = vi.mocked(deleteSnippet);
const mockedRegisterFonts = vi.mocked(registerFontAssets);

/** A serialized (base64) font record, as it appears in an export envelope. */
function fontRecord(family: string): SerializedFontAsset {
  return serializeFontAsset(
    createFontAsset({
      family,
      data: new Uint8Array([1, 2, 3, 4]).buffer,
      format: 'woff2',
      fileName: `${family}.woff2`,
    }),
  );
}

/** The most recent notification raised. */
function lastNote() {
  const notes = useNotificationStore.getState().notifications;
  return notes[notes.length - 1];
}

/** Drive an import from a JSON string (readTextFile is mocked to return it). */
async function importJson(text: string) {
  mockedRead.mockResolvedValueOnce(text);
  await importWorkspace({} as File);
}

beforeEach(() => {
  vi.clearAllMocks();
  useSnippetStore.getState().reset();
  useDatasetStore.getState().reset();
  useCustomThemeStore.getState().reset();
  useFontStore.getState().reset();
  useNotificationStore.getState().clear();
});

describe('exportWorkspace', () => {
  it('reports and downloads nothing for an empty library', () => {
    exportWorkspace(new Date('2026-06-07T00:00:00.000Z'));
    expect(mockedDownload).not.toHaveBeenCalled();
    expect(lastNote()).toMatchObject({ kind: 'info' });
    expect(lastNote().message).toContain('No snippets to export');
  });

  it('downloads an envelope and reports counts', () => {
    useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
    useDatasetStore
      .getState()
      .addDatasets([
        createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
      ]);
    useCustomThemeStore.getState().addThemes([createCustomTheme({ name: 'Brand', config: {} })]);

    exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));

    expect(mockedDownload).toHaveBeenCalledTimes(1);
    const [filename, json] = mockedDownload.mock.calls[0];
    expect(filename).toBe('astrolabe-project-2026-06-07.json');
    const env = JSON.parse(json) as {
      version: string;
      snippets: unknown[];
      datasets: unknown[];
      themes: unknown[];
    };
    expect(env.version).toBe('1.0');
    expect(env.snippets).toHaveLength(1);
    expect(env.datasets).toHaveLength(1);
    expect(env.themes).toHaveLength(1);
    expect(lastNote()).toMatchObject({ kind: 'success' });
    expect(lastNote().message).toBe('Exported 1 snippet, 1 dataset and 1 theme');
  });

  it('embeds uploaded font bytes (base64) in the envelope and counts them', () => {
    useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
    useFontStore.getState().add(
      createFontAsset({
        family: 'Brand',
        data: new Uint8Array([5, 6, 7, 8]).buffer,
        format: 'woff2',
        fileName: 'brand.woff2',
      }),
    );

    exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));

    const [, json] = mockedDownload.mock.calls[0];
    const env = JSON.parse(json) as { fonts: SerializedFontAsset[] };
    expect(env.fonts).toHaveLength(1);
    expect(env.fonts[0].family).toBe('Brand');
    expect(typeof env.fonts[0].data).toBe('string'); // base64, JSON-safe
    expect(lastNote().message).toBe('Exported 1 snippet and 1 font');
  });
});

describe('importWorkspace', () => {
  it('reports an invalid JSON file and leaves the workspace untouched', async () => {
    useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
    await importJson('{ not json');
    expect(useSnippetStore.getState().snippets).toHaveLength(1);
    expect(lastNote()).toMatchObject({ kind: 'error' });
    expect(lastNote().message).toContain('valid JSON');
  });

  it('reports an empty file (no snippets found)', async () => {
    await importJson(JSON.stringify({ version: '1.0', snippets: [] }));
    expect(useSnippetStore.getState().snippets).toHaveLength(0);
    expect(lastNote()).toMatchObject({ kind: 'info' });
    expect(lastNote().message).toBe('No snippets found in file.');
  });

  it('merges an envelope: datasets and snippets are appended', async () => {
    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [
          { id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{"mark":"bar"}' },
        ],
        datasets: [{ id: 1, name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }],
      }),
    );

    expect(useSnippetStore.getState().snippets).toHaveLength(1);
    expect(useDatasetStore.getState().datasets).toHaveLength(1);
    expect(useDatasetStore.getState().datasets[0].name).toBe('Sales');
    expect(lastNote()).toMatchObject({ kind: 'success' });
    expect(lastNote().message).toBe('Imported 1 snippet and 1 dataset');
  });

  it('auto-suffixes a clashing dataset name and rewrites the importing snippet ref', async () => {
    useDatasetStore
      .getState()
      .addDatasets([
        createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
      ]);

    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [
          {
            id: 's9',
            created: '2026-01-01T00:00:00.000Z',
            name: 'Ref',
            spec: '{"data":{"name":"Sales"},"mark":"bar"}',
          },
        ],
        datasets: [{ id: 1, name: 'Sales', data: [{ b: 2 }], format: 'json', source: 'inline' }],
      }),
    );

    const names = useDatasetStore
      .getState()
      .datasets.map((d) => d.name)
      .sort();
    expect(names).toEqual(['Sales', 'Sales 2']);

    const imported = useSnippetStore.getState().snippets.find((s) => s.name === 'Ref')!;
    expect(imported.datasetRefs).toContain('Sales 2');
    expect(imported.spec).toContain('Sales 2');

    expect(lastNote()).toMatchObject({ kind: 'warning' });
    expect(lastNote().message).toContain('Sales → Sales 2');
  });

  it('merges envelope themes: ids reassigned, clashing names auto-suffixed and reported', async () => {
    const existing = useCustomThemeStore
      .getState()
      .add(createCustomTheme({ name: 'Brand', config: { background: '#000' } }));

    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
        themes: [
          { id: existing.id, name: 'Brand', config: { background: '#fff' } },
          { id: 99, name: 'Mono', config: { font: 'Courier' } },
        ],
      }),
    );

    const themes = useCustomThemeStore.getState().themes;
    expect(themes.map((t) => t.name).sort()).toEqual(['Brand', 'Brand 2', 'Mono']);
    // The existing record is untouched; incoming ids were reassigned past it.
    expect(themes.find((t) => t.id === existing.id)!.config).toEqual({ background: '#000' });
    expect(new Set(themes.map((t) => t.id)).size).toBe(3);

    expect(lastNote()).toMatchObject({ kind: 'warning' });
    expect(lastNote().message).toContain('Imported 1 snippet and 2 themes');
    expect(lastNote().message).toContain('Brand → Brand 2');
  });

  it('imports envelope fonts: added to the library and registered as live faces', async () => {
    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
        fonts: [fontRecord('Brand')],
      }),
    );

    const fonts = useFontStore.getState().fonts;
    expect(fonts.map((f) => f.family)).toEqual(['Brand']);
    expect(new Uint8Array(fonts[0].data)).toEqual(new Uint8Array([1, 2, 3, 4]));
    // The committed face is registered on document.fonts so it renders without reload.
    expect(mockedRegisterFonts).toHaveBeenCalledTimes(1);
    expect(mockedRegisterFonts.mock.calls[0][0].map((f) => f.family)).toEqual(['Brand']);
    expect(lastNote().message).toBe('Imported 1 snippet and 1 font');
  });

  it('skips an incoming font whose family already exists, keeping the local one', async () => {
    const existing = createFontAsset({
      family: 'Brand',
      data: new Uint8Array([9, 9, 9]).buffer,
      format: 'woff2',
      fileName: 'local.woff2',
    });
    useFontStore.getState().add(existing);

    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
        fonts: [fontRecord('Brand'), fontRecord('Display')],
      }),
    );

    const fonts = useFontStore.getState().fonts;
    expect(fonts.map((f) => f.family).sort()).toEqual(['Brand', 'Display']);
    // The kept "Brand" is the local one (its bytes), not the incoming face.
    expect(new Uint8Array(fonts.find((f) => f.family === 'Brand')!.data)).toEqual(
      new Uint8Array([9, 9, 9]),
    );
    expect(lastNote()).toMatchObject({ kind: 'warning' });
    expect(lastNote().message).toContain('Skipped fonts already in your library: Brand');
  });

  it('reassigns a colliding snippet id, keeping the existing one', async () => {
    useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);

    await importJson(
      JSON.stringify({
        version: '1.0',
        snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'Imported', spec: '{}' }],
      }),
    );

    const snippets = useSnippetStore.getState().snippets;
    expect(snippets).toHaveLength(2);
    const imported = snippets.find((s) => s.name === 'Imported')!;
    expect(imported.id).not.toBe('s1');
    expect(snippets.find((s) => s.name === 'Existing')!.id).toBe('s1');
  });

  describe('atomic rollback on storage-quota failure', () => {
    it('leaves the snippet store and IDB unchanged when a quota error fires on the first write', async () => {
      // Seed one pre-existing snippet.
      useSnippetStore.getState().hydrate([createSnippet({ id: 'pre1', name: 'Pre-existing' })]);
      // Every saveSnippet call throws a quota error.
      mockedSave.mockRejectedValueOnce(new StorageQuotaError());

      await importJson(
        JSON.stringify({
          version: '1.0',
          snippets: [
            {
              id: 'imp1',
              created: '2026-01-01T00:00:00.000Z',
              name: 'Incoming',
              spec: '{"mark":"bar"}',
            },
          ],
        }),
      );

      // The Zustand store must be unchanged — no partial import visible.
      const snippets = useSnippetStore.getState().snippets;
      expect(snippets).toHaveLength(1);
      expect(snippets[0].id).toBe('pre1');

      // deleteSnippet must NOT have been called (nothing was written before the
      // first save threw, so there is nothing to clean up).
      expect(mockedDelete).not.toHaveBeenCalled();
    });

    it('rolls back IDB records already written before the failing call', async () => {
      // saveSnippet succeeds for the first snippet, then throws on the second.
      mockedSave.mockResolvedValueOnce(undefined);
      mockedSave.mockRejectedValueOnce(new StorageQuotaError());

      await importJson(
        JSON.stringify({
          version: '1.0',
          snippets: [
            { id: 'a', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{"mark":"bar"}' },
            { id: 'b', created: '2026-01-01T00:00:00.000Z', name: 'B', spec: '{"mark":"point"}' },
          ],
        }),
      );

      // The store must still be empty — addSnippets was never called.
      expect(useSnippetStore.getState().snippets).toHaveLength(0);

      // deleteSnippet must be called once to clean up the record that was written
      // before the second save failed (the first snippet's id).
      expect(mockedDelete).toHaveBeenCalledTimes(1);
    });

    it('rolls back datasets, themes, and fonts added to the store before the snippet write failed', async () => {
      // The import carries a dataset, theme, font, and snippet; the snippet write fails.
      mockedSave.mockRejectedValueOnce(new StorageQuotaError());

      await importJson(
        JSON.stringify({
          version: '1.0',
          snippets: [
            { id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'S', spec: '{"mark":"bar"}' },
          ],
          datasets: [{ id: 1, name: 'DS', data: [{ x: 1 }], format: 'json', source: 'inline' }],
          themes: [{ id: 1, name: 'T', config: {} }],
          fonts: [fontRecord('Brand')],
        }),
      );

      // Snippets, datasets, themes, and fonts must all be rolled back from the store.
      expect(useSnippetStore.getState().snippets).toHaveLength(0);
      expect(useDatasetStore.getState().datasets).toHaveLength(0);
      expect(useCustomThemeStore.getState().themes).toHaveLength(0);
      expect(useFontStore.getState().fonts).toHaveLength(0);
      // A rolled-back import never registers its faces.
      expect(mockedRegisterFonts).not.toHaveBeenCalled();
    });

    it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
      mockedSave.mockRejectedValueOnce(new StorageQuotaError());

      await importJson(
        JSON.stringify({
          version: '1.0',
          snippets: [{ id: 'x', created: '2026-01-01T00:00:00.000Z', name: 'X', spec: '{}' }],
        }),
      );

      const note = lastNote();
      expect(note.kind).toBe('error');
      expect(note.title).toContain('storage full');
      // Plain-language copy: names the problem and the fix, no error codes.
      expect(note.message.toLowerCase()).toContain('delete');
      expect(note.message.toLowerCase()).toContain('try');
      // User-fixable failure: no diagnostic detail (nothing to report).
      expect(note.detail).toBeUndefined();
    });

    it('surfaces a non-quota storage error with a diagnostic detail', async () => {
      mockedSave.mockRejectedValueOnce(new DOMException('disk I/O error', 'UnknownError'));

      await importJson(
        JSON.stringify({
          version: '1.0',
          snippets: [{ id: 'x', created: '2026-01-01T00:00:00.000Z', name: 'X', spec: '{}' }],
        }),
      );

      const note = lastNote();
      expect(note.kind).toBe('error');
      expect(note.detail).toBeDefined();
      expect(note.detail).toContain('UnknownError');
    });
  });
});
