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 = Object.getOwnPropertyDescriptor(globalThis, 'window');
  const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
  const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
  const previousIsReactActEnvironment = Object.getOwnPropertyDescriptor(
    globalThis,
    '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,
  });
  Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
    value: true,
    configurable: true,
    writable: true,
  });

  return () => {
    const restoreProperty = (name: string, descriptor: PropertyDescriptor | undefined) => {
      if (descriptor) Object.defineProperty(globalThis, name, descriptor);
      else Reflect.deleteProperty(globalThis, name);
    };
    restoreProperty('window', previousWindow);
    restoreProperty('document', previousDocument);
    restoreProperty('HTMLElement', previousHTMLElement);
    restoreProperty('IS_REACT_ACT_ENVIRONMENT', previousIsReactActEnvironment);
  };
}

test('DOM harness restores the original global property descriptors', () => {
  const propertyNames = ['window', 'document', 'HTMLElement', 'IS_REACT_ACT_ENVIRONMENT'] as const;
  const before = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));

  const restore = installDom();
  restore();

  const after = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
  assert.deepEqual(after, before);
});

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: () => Promise<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: async () => {
      await act(async () => {
        root?.unmount();
        root = null;
      });
      clock.restore();
      // React's scheduler can still have deferred work queued; let it drain on
      // a real timer while the DOM globals it reads are still installed.
      await new Promise((resolve) => setTimeout(resolve, 0));
      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 {
    await 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 {
    await harness.teardown();
    restoreClient();
  }
});

test('chart backfill polling stops and surfaces Retry when readiness never arrives', async () => {
  let chartCalls = 0;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => summaryFixture(),
    getVocabularyCharts: async () => {
      chartCalls += 1;
      return chartsFixture({ ready: false });
    },
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    for (let poll = 0; poll < 65; poll += 1) await harness.tick(5_000);

    assert.equal(chartCalls, 60, 'a failed backfill must not poll for the lifetime of the tab');
    assert.match(harness.state().aggregatesError ?? '', /still building/i);

    await act(async () => {
      harness.state().refreshAggregates();
    });
    await harness.flush();
    assert.equal(chartCalls, 61, 'Retry starts one fresh bounded polling cycle');
    assert.equal(harness.state().aggregatesError, null);
  } finally {
    await 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 {
    await 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 {
    await harness.teardown();
    restoreClient();
    console.error = originalConsoleError;
  }
});

test('a slow response from a superseded refresh cannot replace the newest aggregates', async () => {
  let summaryCalls = 0;
  let releaseSuperseded: (() => void) | null = null;
  const restoreClient = stubVocabularyClient({
    getVocabularySummary: async () => {
      summaryCalls += 1;
      const call = summaryCalls;
      // The second call is the one that gets superseded while still in flight.
      if (call === 2) {
        await new Promise<void>((resolve) => {
          releaseSuperseded = resolve;
        });
      }
      return { ...summaryFixture(), uniqueWords: call };
    },
    getVocabularyCharts: async () => chartsFixture(),
  });
  const harness = await mountHook();

  try {
    await harness.flush();
    assert.equal(harness.state().summary?.uniqueWords, 1);

    // First refresh stalls, then a second refresh supersedes it and resolves.
    await act(async () => {
      harness.state().refreshAggregates();
    });
    await harness.flush();
    await act(async () => {
      harness.state().refreshAggregates();
    });
    await harness.flush();

    assert.equal(summaryCalls, 3);
    assert.equal(harness.state().summary?.uniqueWords, 3);

    const release = releaseSuperseded as (() => void) | null;
    assert.ok(release, 'expected the superseded request to still be in flight');
    release();
    await harness.flush();

    assert.equal(
      harness.state().summary?.uniqueWords,
      3,
      'the superseded response must not overwrite the newest totals',
    );
  } finally {
    await harness.teardown();
    restoreClient();
  }
});
