import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';

import { useRecordApproval } from '../src/platform/features/approvals/useRecordApproval';
import {
  OBJECT_APPROVAL_PROCESS_KEYS,
  useObjectHasApprovalProcess,
} from '../src/platform/features/approvals/useObjectHasApprovalProcess';
import { useProcessActions } from '../src/platform/features/approvals/hooks/useProcessActions';
import type { APIProcessDefinition } from '../src/platform/features/approvals/approvalTypes';

const apiMocks = vi.hoisted(() => ({
  getFullApprovalChain: vi.fn(),
  objectHasActiveApprovalProcess: vi.fn(),
  activateProcess: vi.fn(),
  deactivateProcess: vi.fn(),
  deleteApprovalProcess: vi.fn(),
}));

vi.mock('../src/platform/infrastructure/api/approvals', () => ({
  approvalsAPI: {
    getFullApprovalChain: apiMocks.getFullApprovalChain,
    objectHasActiveApprovalProcess: apiMocks.objectHasActiveApprovalProcess,
    activateProcess: apiMocks.activateProcess,
    deactivateProcess: apiMocks.deactivateProcess,
    deleteApprovalProcess: apiMocks.deleteApprovalProcess,
  },
}));

const emptyChainResult = {
  chain: [],
  processInstanceId: undefined,
  processInstanceStatus: undefined,
};

// The probe shares the app's react-query cache (that is the point: admin-side
// activate/deactivate/delete invalidate ONE key). Each test gets a fresh
// QueryClient; renderHook calls WITHIN a test share it, mirroring one SPA
// session. retryDelay(0) keeps the hook's `retry: 1` from slowing the suite.
function makeClient(): QueryClient {
  return new QueryClient({
    defaultOptions: { queries: { retryDelay: 0 } },
  });
}

function wrapperFor(client: QueryClient) {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
  };
}

describe('useRecordApproval objectHasActiveProcess (issue #341)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    apiMocks.getFullApprovalChain.mockResolvedValue(emptyChainResult);
  });

  it('is undefined while the probe is in flight, then true on a hit', async () => {
    let resolveProbe: (value: boolean) => void = () => {};
    apiMocks.objectHasActiveApprovalProcess.mockReturnValue(
      new Promise<boolean>(resolve => {
        resolveProbe = resolve;
      }),
    );

    const { result } = renderHook(() => useRecordApproval('Account', 'rec-1'), {
      wrapper: wrapperFor(makeClient()),
    });
    expect(result.current.objectHasActiveProcess).toBeUndefined();

    resolveProbe(true);
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(true));
  });

  it('is false when the object has no active process', async () => {
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValue(false);

    const { result } = renderHook(() => useRecordApproval('Contact', 'rec-1'), {
      wrapper: wrapperFor(makeClient()),
    });
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(false));
  });

  it('fails closed to false when the probe rejects, without caching the failure as data', async () => {
    const client = makeClient();
    // Initial attempt + the hook's retry:1 both reject.
    apiMocks.objectHasActiveApprovalProcess
      .mockRejectedValueOnce(new Error('boom'))
      .mockRejectedValueOnce(new Error('boom'));

    const first = renderHook(() => useRecordApproval('Account', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(first.result.current.objectHasActiveProcess).toBe(false));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(2);
    first.unmount();

    // The failure landed as a query ERROR, not cached data: a later mount
    // refetches instead of serving false for the staleTime window.
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(true);
    const second = renderHook(() => useRecordApproval('Account', 'rec-2'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(second.result.current.objectHasActiveProcess).toBe(true));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(3);
  });

  it('caches the probe per objectApiName, not per recordId', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValue(true);

    const { result, rerender } = renderHook(
      ({ recordId }) => useRecordApproval('Opportunity', recordId),
      { initialProps: { recordId: 'rec-1' }, wrapper: wrapperFor(client) },
    );
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(true));

    // Same object, different record: no new probe.
    rerender({ recordId: 'rec-2' });
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(true));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(1);

    // A second mount for the same object hits the fresh (staleTime) cache.
    const second = renderHook(() => useRecordApproval('Opportunity', 'rec-3'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(second.result.current.objectHasActiveProcess).toBe(true));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(1);

    // A different object triggers its own probe.
    const other = renderHook(() => useRecordApproval('Lead', 'rec-4'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(other.result.current.objectHasActiveProcess).toBe(true));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(2);
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenLastCalledWith('Lead');
  });

  it('re-derives after the admin invalidation key fires (activate/deactivate/delete)', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(false);

    const { result } = renderHook(() => useRecordApproval('Opportunity', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(false));

    // Admin activates a process for the object elsewhere in the app; the
    // mutation paths invalidate the root key. The mounted gate must refetch
    // and flip WITHOUT a full page reload.
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(true);
    await client.invalidateQueries({ queryKey: OBJECT_APPROVAL_PROCESS_KEYS.root });
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(true));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(2);
  });

  it('never queries for a non-API-name object and fails closed', async () => {
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValue(true);

    const { result } = renderHook(() => useRecordApproval("Bad' OR Name", 'rec-1'), {
      wrapper: wrapperFor(makeClient()),
    });
    await waitFor(() => expect(result.current.objectHasActiveProcess).toBe(false));
    expect(apiMocks.objectHasActiveApprovalProcess).not.toHaveBeenCalled();
  });

  it('a disabled consumer stays fail-closed even when the shared cache is warm with true', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValue(true);

    // An enabled consumer warms the cache for the object.
    const warm = renderHook(() => useObjectHasApprovalProcess('Quote'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(warm.result.current.hasActiveProcess).toBe(true));

    // A disabled consumer of the SAME object must not open its gate from
    // another consumer's warm cache (v5 disabled observers still see data).
    const disabled = renderHook(() => useObjectHasApprovalProcess('Quote', false), {
      wrapper: wrapperFor(client),
    });
    expect(disabled.result.current.hasActiveProcess).toBe(false);
    expect(disabled.result.current.loading).toBe(false);
  });
});

describe('admin mutations invalidate the object-gate cache (issue #341, production wiring)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    apiMocks.getFullApprovalChain.mockResolvedValue(emptyChainResult);
  });

  const activeProcess = { Id: 'pd-1', Name: 'Proc', State: 'Active', TableEnumOrId: 'Opportunity' } as unknown as APIProcessDefinition;

  it('useProcessActions.handleToggleActive re-derives a mounted gate without a reload', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(true);
    apiMocks.deactivateProcess.mockResolvedValue(undefined);

    const gate = renderHook(() => useRecordApproval('Opportunity', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(true));

    const actions = renderHook(
      () => useProcessActions('pd-1', activeProcess, async () => {}),
      { wrapper: wrapperFor(client) },
    );

    // Admin deactivates the process; the REAL production hook must
    // invalidate the root key so the mounted gate refetches and flips.
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(false);
    await act(async () => {
      await actions.result.current.handleToggleActive();
    });
    expect(apiMocks.deactivateProcess).toHaveBeenCalledWith('pd-1');
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(false));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(2);
  });

  it('useProcessActions.handleConfirmDelete re-derives a mounted gate', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(true);
    apiMocks.deactivateProcess.mockResolvedValue(undefined);
    apiMocks.deleteApprovalProcess.mockResolvedValue(undefined);

    const gate = renderHook(() => useRecordApproval('Opportunity', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(true));

    const actions = renderHook(
      () => useProcessActions('pd-1', activeProcess, async () => {}),
      { wrapper: wrapperFor(client) },
    );

    apiMocks.objectHasActiveApprovalProcess.mockResolvedValueOnce(false);
    await act(async () => {
      await actions.result.current.handleConfirmDelete();
    });
    expect(apiMocks.deleteApprovalProcess).toHaveBeenCalledWith('pd-1');
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(false));
  });

});

// Pins the test-stub's v5-fidelity behaviors that gate tests rely on
// (frontend/react-query-test-stub). These exist because the stub is shared
// infrastructure: a regression here silently un-tests every provider-mode test.
describe('react-query test-stub fidelity (shared infra)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    apiMocks.getFullApprovalChain.mockResolvedValue(emptyChainResult);
  });

  it('an invalidation arriving during an in-flight fetch still lands (refetch after settle)', async () => {
    const client = makeClient();
    let resolveFirst: (v: boolean) => void = () => {};
    apiMocks.objectHasActiveApprovalProcess
      .mockReturnValueOnce(new Promise<boolean>(resolve => { resolveFirst = resolve; }))
      .mockResolvedValueOnce(false);

    const gate = renderHook(() => useRecordApproval('Opportunity', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    expect(gate.result.current.objectHasActiveProcess).toBeUndefined();

    // Admin flips the process WHILE the first probe is still in flight.
    const invalidated = client.invalidateQueries({ queryKey: OBJECT_APPROVAL_PROCESS_KEYS.root });
    resolveFirst(true);
    await invalidated;

    // The in-flight result is stale; the stub must refetch and serve the
    // post-mutation verdict, not silently drop the invalidation.
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(false));
    expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(2);
  });

  it('a failed refetch retains last-good data, like real v5', async () => {
    const client = makeClient();
    apiMocks.objectHasActiveApprovalProcess
      .mockResolvedValueOnce(true)
      // Refetch after invalidation fails (initial attempt + retry:1).
      .mockRejectedValueOnce(new Error('boom'))
      .mockRejectedValueOnce(new Error('boom'));

    const gate = renderHook(() => useRecordApproval('Opportunity', 'rec-1'), {
      wrapper: wrapperFor(client),
    });
    await waitFor(() => expect(gate.result.current.objectHasActiveProcess).toBe(true));

    await client.invalidateQueries({ queryKey: OBJECT_APPROVAL_PROCESS_KEYS.root });

    // Real v5 keeps the last-good data when a refetch errors; the gate keeps
    // rendering from the stale verdict (server still rejects the submit).
    await waitFor(() => expect(apiMocks.objectHasActiveApprovalProcess).toHaveBeenCalledTimes(3));
    expect(gate.result.current.objectHasActiveProcess).toBe(true);
  });
});
