import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../lib/api-client';
import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords';
import { useVocabulary } from './useVocabulary';
import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats';

type VocabularyState = ReturnType<typeof useVocabulary>;

function installDom(): () => void {
  const previousWindow = globalThis.window;
  const previousDocument = globalThis.document;
  const previousHTMLElement = globalThis.HTMLElement;
  const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
  const previousIsReactActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
  const window = new Window();

  Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
  Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
  Object.defineProperty(globalThis, 'HTMLElement', {
    value: window.HTMLElement,
    configurable: true,
  });
  globals.IS_REACT_ACT_ENVIRONMENT = true;

  return () => {
    Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
    Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
    Object.defineProperty(globalThis, 'HTMLElement', {
      value: previousHTMLElement,
      configurable: true,
    });
    globals.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
  };
}

function installLocalStorage(): () => void {
  const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
  const values = new Map<string, string>();
  Object.defineProperty(globalThis, 'localStorage', {
    configurable: true,
    value: {
      getItem: (key: string) => values.get(key) ?? null,
      setItem: (key: string, value: string) => values.set(key, value),
      removeItem: (key: string) => values.delete(key),
    },
  });
  return () => {
    if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
    else delete (globalThis as { localStorage?: unknown }).localStorage;
  };
}

interface FakeClock {
  tick: (ms: number) => void;
  restore: () => void;
}

/** Bun's `node:test` shim has no `mock.timers`, so the retry clock is faked here. */
function installFakeTimers(): FakeClock {
  const originalSetTimeout = globalThis.setTimeout;
  const originalClearTimeout = globalThis.clearTimeout;
  const timers = new Map<number, { at: number; fn: () => void }>();
  let now = 0;
  let nextId = 1;

  globalThis.setTimeout = ((fn: () => void, delay = 0) => {
    const id = nextId;
    nextId += 1;
    timers.set(id, { at: now + delay, fn });
    return id;
  }) as unknown as typeof globalThis.setTimeout;
  globalThis.clearTimeout = ((id: number) => {
    timers.delete(id);
  }) as unknown as typeof globalThis.clearTimeout;

  return {
    tick: (ms: number) => {
      now += ms;
      const due = [...timers.entries()]
        .filter(([, timer]) => timer.at <= now)
        .sort(([, a], [, b]) => a.at - b.at);
      for (const [id, timer] of due) {
        timers.delete(id);
        timer.fn();
      }
    },
    restore: () => {
      globalThis.setTimeout = originalSetTimeout;
      globalThis.clearTimeout = originalClearTimeout;
    },
  };
}

function summaryFixture(): StatsVocabularySummary {
  return {
    uniqueWords: 42,
    uniqueWordsWithoutNames: 40,
    uniqueKanji: 7,
    newThisWeek: 3,
    newThisWeekWithoutNames: 2,
    knownWordCount: 10,
    knownWordCountWithoutNames: 9,
  };
}

function chartsFixture(overrides: Partial<StatsVocabularyCharts> = {}): StatsVocabularyCharts {
  return {
    ready: true,
    topWords: [{ wordId: 1, headword: '猫', frequency: 5 }],
    topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }],
    newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }],
    newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }],
    ...overrides,
  };
}

interface Harness {
  state: () => VocabularyState;
  flush: () => Promise<void>;
  tick: (ms: number) => Promise<void>;
  unmount: () => Promise<void>;
  teardown: () => void;
}

async function mountHook(): Promise<Harness> {
  const uninstallDom = installDom();
  const uninstallLocalStorage = installLocalStorage();
  const clock = installFakeTimers();

  let latest: VocabularyState | null = null;
  function Probe() {
    latest = useVocabulary();
    return null;
  }

  const container = document.createElement('div');
  document.body.append(container);
  let root: Root | null = createRoot(container);
  await act(async () => {
    root!.render(<Probe />);
  });

  const flush = async (): Promise<void> => {
    // Drain promise callbacks without advancing the mocked clock.
    await act(async () => {
      await Promise.resolve();
      await Promise.resolve();
    });
  };

  return {
    state: () => {
      assert.ok(latest, 'expected the hook to have rendered');
      return latest;
    },
    flush,
    tick: async (ms: number) => {
      await act(async () => {
        clock.tick(ms);
      });
      await flush();
    },
    unmount: async () => {
      await act(async () => {
        root?.unmount();
        root = null;
      });
    },
    teardown: () => {
      if (root) root.unmount();
      clock.restore();
      uninstallLocalStorage();
      uninstallDom();
      resetExcludedWordsStoreForTests();
    },
  };
}

function stubVocabularyClient(overrides: {
  getVocabularySummary: () => Promise<StatsVocabularySummary>;
  getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
}): () => void {
  const original = {
    getVocabulary: apiClient.getVocabulary,
    getKanji: apiClient.getKanji,
    getKnownWords: apiClient.getKnownWords,
    getVocabularySummary: apiClient.getVocabularySummary,
    getVocabularyCharts: apiClient.getVocabularyCharts,
    setExcludedWords: apiClient.setExcludedWords,
  };
  apiClient.getVocabulary = async () => [];
  apiClient.getKanji = async () => [];
  apiClient.getKnownWords = async () => [];
  apiClient.setExcludedWords = async () => {};
  apiClient.getVocabularySummary = overrides.getVocabularySummary;
  apiClient.getVocabularyCharts = overrides.getVocabularyCharts;
  return () => Object.assign(apiClient, original);
}

test('aggregate failures retry with backoff, then surface an error that Retry clears', async () => {
  const originalConsoleError = console.error;
  console.error = () => {};
  let summaryCalls = 0;
  let failSummary = true;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => {
      summaryCalls += 1;
      if (failSummary) throw new Error('summary unavailable');
      return summaryFixture();
    },
    getVocabularyCharts: async () => chartsFixture(),
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    assert.equal(summaryCalls, 1);
    assert.equal(harness.state().aggregatesError, null, 'no error until retries are exhausted');

    // Backoff is 1s, 2s, 4s, 8s across the remaining four attempts.
    for (const delayMs of [1_000, 2_000, 4_000, 8_000]) {
      await harness.tick(delayMs);
    }
    assert.equal(summaryCalls, 5, 'retries are bounded at the attempt limit');
    assert.match(harness.state().aggregatesError ?? '', /totals failed to load/i);

    // Nothing further is scheduled once the limit is reached.
    await harness.tick(60_000);
    assert.equal(summaryCalls, 5);

    failSummary = false;
    await act(async () => {
      harness.state().refreshAggregates();
    });
    await harness.flush();

    assert.equal(summaryCalls, 6);
    assert.equal(harness.state().aggregatesError, null);
    assert.deepEqual(harness.state().summary, summaryFixture());
  } finally {
    harness.teardown();
    restoreClient();
    console.error = originalConsoleError;
  }
});

test('charts poll while the backfill is pending and stop once it is ready', async () => {
  let chartCalls = 0;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => summaryFixture(),
    getVocabularyCharts: async () => {
      chartCalls += 1;
      return chartsFixture({ ready: chartCalls >= 3 });
    },
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    assert.equal(chartCalls, 1);
    assert.equal(harness.state().charts?.ready, false);

    await harness.tick(1_000);
    assert.equal(chartCalls, 2);
    await harness.tick(1_000);
    assert.equal(chartCalls, 3);
    assert.equal(harness.state().charts?.ready, true);

    // A ready result ends the poll.
    await harness.tick(60_000);
    assert.equal(chartCalls, 3);
  } finally {
    harness.teardown();
    restoreClient();
  }
});

test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => {
  let summaryCalls = 0;
  let chartCalls = 0;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => {
      summaryCalls += 1;
      return summaryFixture();
    },
    getVocabularyCharts: async () => {
      chartCalls += 1;
      return chartsFixture();
    },
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    assert.equal(summaryCalls, 1);
    assert.equal(chartCalls, 1);

    await act(async () => {
      await setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]);
    });
    await harness.flush();

    assert.equal(summaryCalls, 2, 'totals must not keep counting the excluded word');
    assert.equal(chartCalls, 2);
  } finally {
    harness.teardown();
    restoreClient();
  }
});

test('pending retries are cancelled when the tab unmounts', async () => {
  const originalConsoleError = console.error;
  console.error = () => {};
  let summaryCalls = 0;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => {
      summaryCalls += 1;
      throw new Error('summary unavailable');
    },
    getVocabularyCharts: async () => chartsFixture(),
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    assert.equal(summaryCalls, 1);

    await harness.unmount();
    await harness.tick(60_000);

    assert.equal(summaryCalls, 1, 'no retry may run after unmount');
  } finally {
    harness.teardown();
    restoreClient();
    console.error = originalConsoleError;
  }
});
