import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

const { peers, resetEvents, acknowledgeResetEvent, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({
  peers: vi.fn(),
  resetEvents: vi.fn(),
  acknowledgeResetEvent: vi.fn(),
  resetPeer: vi.fn(),
  initiatePeering: vi.fn(),
  deleteUser: vi.fn(),
  addToast: vi.fn(),
}));

vi.mock('../../../api/client', async () => {
  // Re-export the REAL HttpError so the component's `err instanceof HttpError`
  // classifier and the test's constructed rejection share one class identity.
  const actual = await vi.importActual<typeof import('../../../api/client')>('../../../api/client');
  return {
    ...actual,
    api: {
      federation: {
        peers,
        approvalRequests: vi.fn().mockResolvedValue({ requests: [] }),
        resetEvents,
        acknowledgeResetEvent,
        resetPeer,
        initiatePeering,
      },
      admin: {
        deleteUser,
      },
    },
  };
});

vi.mock('../../../stores/uiStore', () => ({
  useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }),
}));

vi.mock('../../../hooks/useWebSocket', () => ({
  onFederationPeersChanged: () => () => {},
  onFederationPeerResetDetected: () => () => {},
}));

import { FederationPanel } from './FederationPanel';
import { HttpError } from '../../../api/client';

const resetPeerFixture = {
  id: 'p1',
  origin: 'https://peer.example',
  instanceName: 'Peer',
  status: 'needs_attention' as const,
  needsAttentionReason: 'peer_reset_detected' as const,
  lastSeenAt: Date.now(),
  lastFailureAt: null,
  consecutiveFailures: 0,
  consecutiveAuthFailures: 0,
  lastSyncedAt: Date.now(),
  autoRotateIntervalDays: 90,
  secretRotatedAt: null,
  rotationInProgress: false,
  createdAt: Date.now(),
};

function orphanedAccount(overrides: Record<string, unknown> = {}) {
  return {
    id: 'acc1',
    username: 'ghost',
    displayName: 'Ghost',
    avatarColor: null,
    ownedSpaces: [],
    spaceMemberCount: 2,
    messageCount: 5,
    ...overrides,
  };
}

function resetEvent(
  accounts: ReturnType<typeof orphanedAccount>[],
  overrides: Record<string, unknown> = {},
) {
  return {
    origin: 'https://peer.example',
    deadEpoch: 'epoch-old',
    newEpoch: 'epoch-new',
    detectedAt: Date.now(),
    resolvedAt: null,
    acknowledgedAt: null,
    stubCount: 3,
    orphanedAccountCount: accounts.length,
    orphanedAccounts: accounts,
    ...overrides,
  };
}

describe('FederationPanel — Reset cleanup', () => {
  beforeEach(() => {
    peers.mockReset();
    resetEvents.mockReset();
    acknowledgeResetEvent.mockReset();
    resetPeer.mockReset();
    initiatePeering.mockReset();
    deleteUser.mockReset();
    addToast.mockReset();
  });

  it('shows a Re-peer banner for a peer_reset_detected peer and re-peers on click', async () => {
    peers.mockResolvedValue({ peers: [resetPeerFixture] });
    resetEvents.mockResolvedValue({ events: [] });
    resetPeer.mockResolvedValue({ success: true });
    initiatePeering.mockResolvedValue({ peer: resetPeerFixture });

    render(<FederationPanel />);

    // The banner is a persistent surface — its "Re-peer" button is directly visible.
    const repeerBtn = await screen.findByRole('button', { name: 'Re-peer' });
    fireEvent.click(repeerBtn);

    // Confirm the warning dialog.
    const confirmBtn = await screen.findByRole('button', { name: 'Re-peer & heal' });
    fireEvent.click(confirmBtn);

    await waitFor(() => expect(resetPeer).toHaveBeenCalledWith('p1'));
    await waitFor(() =>
      expect(initiatePeering).toHaveBeenCalledWith({ remoteOrigin: 'https://peer.example' }),
    );
    // Order matters: reset BEFORE initiate.
    expect(resetPeer.mock.invocationCallOrder[0]).toBeLessThan(
      initiatePeering.mock.invocationCallOrder[0],
    );
  });

  it('warns when Re-peer completes but the handshake is unverified (verified:false)', async () => {
    peers.mockResolvedValue({ peers: [resetPeerFixture] });
    resetEvents.mockResolvedValue({ events: [] });
    resetPeer.mockResolvedValue({ success: true });
    initiatePeering.mockResolvedValue({
      peer: { ...resetPeerFixture, status: 'needs_attention' },
      verified: false,
    });

    render(<FederationPanel />);

    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' }));
    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' }));

    await waitFor(() => expect(initiatePeering).toHaveBeenCalled());
    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith(
        'Re-peer incomplete — Peer still holds stale peering for you. Its admin must reset their side, then Re-peer again.',
        'warning',
      ),
    );
    expect(addToast).not.toHaveBeenCalledWith(
      expect.stringContaining('Re-peering initiated'),
      'success',
      expect.anything(),
    );
  });

  it('warns when the remote rejects with 409 PEER_EXISTS_RESET_REQUIRED', async () => {
    peers.mockResolvedValue({ peers: [resetPeerFixture] });
    resetEvents.mockResolvedValue({ events: [] });
    resetPeer.mockResolvedValue({ success: true });
    initiatePeering.mockRejectedValue(
      new HttpError(409, 'Peer exists — reset required', {
        error: 'Peer exists — reset required',
        statusCode: 409,
        code: 'PEER_EXISTS_RESET_REQUIRED',
      }),
    );

    render(<FederationPanel />);

    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' }));
    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' }));

    await waitFor(() => expect(initiatePeering).toHaveBeenCalled());
    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith(
        'The remote instance still holds stale peering for you. Ask its admin to reset their side, then Re-peer again.',
        'warning',
      ),
    );
  });

  it('shows the success toast when the handshake is verified (verified:true)', async () => {
    peers.mockResolvedValue({ peers: [resetPeerFixture] });
    resetEvents.mockResolvedValue({ events: [] });
    resetPeer.mockResolvedValue({ success: true });
    initiatePeering.mockResolvedValue({
      peer: { ...resetPeerFixture, status: 'active' },
      verified: true,
    });

    render(<FederationPanel />);

    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' }));
    fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' }));

    await waitFor(() => expect(initiatePeering).toHaveBeenCalled());
    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith('Re-peering initiated with Peer', 'success', 3000),
    );
    expect(addToast).not.toHaveBeenCalledWith(
      expect.stringContaining('still holds stale peering'),
      'warning',
    );
  });

  it('removes an orphaned account via the existing admin delete', async () => {
    peers.mockResolvedValue({ peers: [] });
    resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
    deleteUser.mockResolvedValue({ success: true });

    render(<FederationPanel />);

    const removeBtn = await screen.findByRole('button', { name: 'Remove' });
    fireEvent.click(removeBtn);

    // The confirm dialog title uses the current "detached" vocabulary, not the
    // legacy "Orphaned Account" wording.
    expect(await screen.findByText('Remove detached account')).toBeInTheDocument();

    const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' });
    fireEvent.click(confirmBtn);

    await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1'));
  });

  it('surfaces "transfer ownership first" only when the 400 carries an ownedSpaces payload', async () => {
    peers.mockResolvedValue({ peers: [] });
    // Isolate the classifier: the fixture account does NOT own spaces locally
    // (default ownedSpaces: []), so the transfer-first message can only come
    // from the error's `ownedSpaces` payload — the real server 400 shape.
    resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
    deleteUser.mockRejectedValue(
      new HttpError(400, 'User owns spaces — transfer ownership first', {
        error: 'User owns spaces — transfer ownership first',
        statusCode: 400,
        ownedSpaces: [{ id: 's1', name: 'My Space' }],
      }),
    );

    render(<FederationPanel />);

    const removeBtn = await screen.findByRole('button', { name: 'Remove' });
    fireEvent.click(removeBtn);

    const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' });
    fireEvent.click(confirmBtn);

    await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1'));
    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith(
        expect.stringContaining('transfer ownership first'),
        'warning',
      ),
    );
  });

  it('shows the generic failure toast for a rejection WITHOUT an ownedSpaces payload', async () => {
    peers.mockResolvedValue({ peers: [] });
    resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
    deleteUser.mockRejectedValue(
      new HttpError(400, 'Internal server error', { error: 'Internal server error', statusCode: 400 }),
    );

    render(<FederationPanel />);

    const removeBtn = await screen.findByRole('button', { name: 'Remove' });
    fireEvent.click(removeBtn);

    const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' });
    fireEvent.click(confirmBtn);

    await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1'));
    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith('Internal server error', 'warning'),
    );
    // The classifier must NOT mislabel a generic 400 as an ownership problem.
    expect(addToast).not.toHaveBeenCalledWith(
      expect.stringContaining('transfer ownership first'),
      'warning',
    );
  });

  it('renders the detached-accounts card with Dismiss + Remove and informational copy, no Keep/frozen', async () => {
    peers.mockResolvedValue({ peers: [] });
    resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });

    render(<FederationPanel />);

    // Both real actions are present.
    await screen.findByRole('button', { name: /Dismiss/ });
    expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();

    // Informational detach copy — not urgent-cleanup language.
    expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0);
    expect(screen.getByText(/existing password/i)).toBeInTheDocument();
    // Re-attach is surfaced as the recovery path (spec §3.5).
    expect(screen.getByText(/re-attach a detached account to their new home identity/i)).toBeInTheDocument();

    // The fake client-only Keep/frozen affordance is fully gone.
    expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument();
    expect(screen.queryByText(/frozen/i)).not.toBeInTheDocument();
    // No "orphaned" urgency wording in the detached-accounts copy.
    expect(screen.queryByText(/with local content orphaned/i)).not.toBeInTheDocument();
  });

  it('does not render an acknowledged event and excludes it from the badge count', async () => {
    peers.mockResolvedValue({ peers: [] });
    resetEvents.mockResolvedValue({
      events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })],
    });

    render(<FederationPanel />);

    // Give effects a chance to run, then assert the whole section stays absent.
    await waitFor(() => expect(resetEvents).toHaveBeenCalled());
    await waitFor(() =>
      expect(screen.queryByText('Reset Cleanup')).not.toBeInTheDocument(),
    );
    expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument();
  });

  it('dismisses an event via the acknowledge API and re-fetches', async () => {
    peers.mockResolvedValue({ peers: [] });
    // First load: unacknowledged. After acknowledge, re-fetch returns it acknowledged.
    resetEvents
      .mockResolvedValueOnce({ events: [resetEvent([orphanedAccount()])] })
      .mockResolvedValue({ events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })] });
    acknowledgeResetEvent.mockResolvedValue({ success: true });

    render(<FederationPanel />);

    const dismissBtn = await screen.findByRole('button', { name: /Dismiss/ });
    fireEvent.click(dismissBtn);

    await waitFor(() =>
      expect(acknowledgeResetEvent).toHaveBeenCalledWith('https://peer.example'),
    );
    // fetchAll re-runs after acknowledge (peers + resetEvents both hit twice).
    await waitFor(() => expect(resetEvents).toHaveBeenCalledTimes(2));
    // The card disappears once the re-fetch marks the event acknowledged.
    await waitFor(() =>
      expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(),
    );
  });

  it('surfaces an error toast when dismiss fails', async () => {
    peers.mockResolvedValue({ peers: [] });
    resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
    acknowledgeResetEvent.mockRejectedValue(new Error('Network down'));

    render(<FederationPanel />);

    fireEvent.click(await screen.findByRole('button', { name: /Dismiss/ }));

    await waitFor(() =>
      expect(addToast).toHaveBeenCalledWith('Network down', 'warning'),
    );
  });
});
