import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Task } from '@/lib/types';
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
import type { FreshnessFailure } from './use-fresh-collection';
import {
  describeFailure,
  useFreshCollection,
  type FreshCollection,
  type UseFreshCollectionOptions,
} from './use-fresh-collection';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';

/**
 * Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
 * auth failure, malformed response, cache corruption, stale age, schema
 * mismatch, cross-workspace, recovery, and stale-action rejection — with
 * negative controls proving no case yields current data or an enabled
 * mutation.
 */

const NOW = 1_800_000_000_000;

interface Deferred<T> {
  promise: Promise<T>;
  resolve: (value: T) => void;
  reject: (reason?: unknown) => void;
}

function createDeferred<T>(): Deferred<T> {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

let root: Root | null = null;
let container: HTMLDivElement;
let latest: FreshCollection<Task[]> | null = null;

function Probe({
  options,
}: {
  options: UseFreshCollectionOptions<Task[]>;
}): React.ReactElement | null {
  latest = useFreshCollection<Task[]>(options);
  return null;
}

beforeAll(() => {
  Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
    configurable: true,
    value: true,
  });
});

beforeEach(() => {
  sessionStorage.clear();
});

afterEach(async () => {
  await act(async () => {
    root?.unmount();
  });
  document.body.replaceChildren();
  root = null;
  latest = null;
  sessionStorage.clear();
  vi.restoreAllMocks();
});

async function renderCollection(
  options: UseFreshCollectionOptions<Task[]>,
): Promise<FreshCollection<Task[]>> {
  container = document.createElement('div');
  document.body.append(container);
  root = createRoot(container);
  await act(async () => {
    root?.render(<Probe options={options} />);
  });
  if (latest === null) throw new Error('hook did not run');
  return latest;
}

function taskOptions(
  overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
): UseFreshCollectionOptions<Task[]> {
  return {
    source: 'gateway:/api/tasks',
    fetcher: () => Promise.resolve(taskFixtures),
    validate: validateTaskCollection,
    cacheKey: 'tasks',
    clock: () => NOW,
    ...overrides,
  };
}

function authError(statusCode: number): Error & { statusCode: number } {
  return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
}

function seedCache(key: string): number {
  const result = acceptSnapshot({
    value: taskFixtures,
    validate: validateTaskCollection,
    previous: null,
    policy: DEFAULT_FRESHNESS_POLICY,
    source: 'gateway:/api/tasks',
    now: NOW,
  });
  if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
  sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
  return result.snapshot.version;
}

describe('useFreshCollection failure matrix', () => {
  it('is unknown (not empty) while the first validation is in flight', async () => {
    const deferred = createDeferred<Task[]>();
    const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));

    expect(collection.freshness).toBe('unknown');
    expect(collection.validating).toBe(true);
    expect(collection.data).toBeNull();
    expect(collection.canMutate).toBe(false);

    await act(async () => {
      deferred.resolve(taskFixtures);
      await deferred.promise;
    });
  });

  it('becomes current with provenance after a verified fetch', async () => {
    const collection = await renderCollection(taskOptions());

    expect(collection.freshness).toBe('current');
    expect(collection.data).toEqual(taskFixtures);
    expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
    expect(collection.snapshot?.version).toBe(1);
    expect(collection.failure).toBeNull();
    expect(collection.canMutate).toBe(true);
    // Verified snapshot is persisted for last-known restore.
    expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
  });

  it('treats a network failure as unavailable — never an empty healthy collection', async () => {
    const collection = await renderCollection(
      taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
    );

    expect(collection.freshness).toBe('unavailable');
    expect(collection.data).toBeNull();
    expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
    expect(collection.canMutate).toBe(false);
    expect(describeFailure(collection.failure)).toBe('network down');
  });

  it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
    let call = 0;
    const collection = await renderCollection(
      taskOptions({
        fetcher: () => {
          call += 1;
          return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
        },
      }),
    );
    expect(collection.freshness).toBe('current');

    await act(async () => {
      await collection.revalidate();
    });

    expect(latest?.freshness).toBe('unavailable');
    expect(latest?.data).toBeNull();
    expect(latest?.failure?.kind).toBe('fetch');
    // The previous user's data must not linger in the session cache.
    expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
  });

  it('invalidates a malformed response as a schema mismatch', async () => {
    const collection = await renderCollection(
      taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
    );

    expect(collection.freshness).toBe('unavailable');
    expect(collection.data).toBeNull();
    expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
    expect(collection.canMutate).toBe(false);
  });

  it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
    let call = 0;
    const collection = await renderCollection(
      taskOptions({
        fetcher: () => {
          call += 1;
          return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
        },
      }),
    );
    expect(collection.freshness).toBe('current');

    await act(async () => {
      await collection.revalidate();
    });

    expect(latest?.freshness).toBe('stale');
    expect(latest?.data).toEqual(taskFixtures);
    expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
    expect(latest?.canMutate).toBe(false);
  });

  it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
    let call = 0;
    const collection = await renderCollection(
      taskOptions({
        fetcher: () => {
          call += 1;
          return Promise.resolve(
            call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
          );
        },
        validate: validateProjectCollection as unknown as (value: unknown) => {
          data: Task[];
          workspace: string | null;
        },
        source: 'gateway:/api/projects',
      }),
    );
    expect(collection.freshness).toBe('current');

    await act(async () => {
      await collection.revalidate();
    });

    expect(latest?.freshness).toBe('unavailable');
    expect(latest?.data).toBeNull();
    expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
  });

  it('ages from current to stale and refuses mutations on stale data', async () => {
    let fakeNow = NOW;
    const collection = await renderCollection(
      taskOptions({
        clock: () => fakeNow,
        policy: { staleAfterMs: 40 },
        tickMs: 10,
      }),
    );
    expect(collection.freshness).toBe('current');

    // Age the snapshot past the policy and let the tick recompute.
    fakeNow = NOW + 60;
    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 25));
    });

    expect(latest?.freshness).toBe('stale');
    expect(latest?.data).toEqual(taskFixtures);
    expect(latest?.canMutate).toBe(false);

    const operation = vi.fn(async () => 'result');
    await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
    expect(operation).not.toHaveBeenCalled();
  });

  it('recovers to current after a successful revalidation', async () => {
    let call = 0;
    const collection = await renderCollection(
      taskOptions({
        fetcher: () => {
          call += 1;
          return call === 1
            ? Promise.reject(new Error('first attempt failed'))
            : Promise.resolve(taskFixtures);
        },
      }),
    );
    expect(collection.freshness).toBe('unavailable');

    await act(async () => {
      await collection.revalidate();
    });

    expect(latest?.freshness).toBe('current');
    expect(latest?.failure).toBeNull();

    const operation = vi.fn(async (data: Task[]) => data.length);
    await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
    expect(operation).toHaveBeenCalledOnce();
  });

  it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
    const seededVersion = seedCache('tasks');
    const deferred = createDeferred<Task[]>();
    const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));

    // Restored data is situational awareness only: labeled stale, never
    // current, and mutations are refused before verification.
    expect(collection.freshness).toBe('stale');
    expect(collection.data).toEqual(taskFixtures);
    expect(collection.canMutate).toBe(false);
    await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);

    await act(async () => {
      deferred.resolve(taskFixtures);
      await deferred.promise;
    });

    expect(latest?.freshness).toBe('current');
    expect(latest?.snapshot?.version).toBe(seededVersion + 1);
  });

  it('never promotes corrupted cache data to current (cache corruption)', async () => {
    sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
    const collection = await renderCollection(
      taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
    );

    expect(collection.freshness).toBe('unavailable');
    expect(collection.data).toBeNull();
    expect(collection.canMutate).toBe(false);
    // The corrupted entry is dropped so it cannot come back.
    expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
  });

  it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
    const deferred = createDeferred<Task[]>();
    const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
    const operation = vi.fn(async () => 'result');
    await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
    expect(operation).not.toHaveBeenCalled();
    await act(async () => {
      deferred.reject(new Error('failed'));
      await deferred.promise.catch(() => undefined);
    });

    const unavailable = latest!;
    await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
    expect(operation).not.toHaveBeenCalled();
    expect(unavailable.canMutate).toBe(false);
  });

  it('degrades to stale with last-known data when a revalidation fails after success', async () => {
    let call = 0;
    const collection = await renderCollection(
      taskOptions({
        fetcher: () => {
          call += 1;
          return call === 1
            ? Promise.resolve(taskFixtures)
            : Promise.reject(new Error('connection lost'));
        },
      }),
    );
    expect(collection.freshness).toBe('current');

    await act(async () => {
      await collection.revalidate();
    });

    expect(latest?.freshness).toBe('stale');
    expect(latest?.data).toEqual(taskFixtures);
    const failure: FreshnessFailure | null = latest?.failure ?? null;
    expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
  });
});
