import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GameAssetBrowser } from '@/pages/Chat/GameAssetBrowser';
import { AppError } from '@/lib/error-model';

const hostApiFetchMock = vi.fn();
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args) }));

const assets = [
  { id: 'hero', name: '主角', category: 'visual', status: 'candidate', purpose: '玩家角色', source: 'Kenney', license: 'CC0', mediaKind: 'image', previewDataUrl: 'data:image/png;base64,AA==', manifest: [] },
  { id: 'jump', name: '跳跃音效', category: 'audio', status: 'candidate', purpose: '跳跃反馈', source: 'Kenney', license: 'CC0', mediaKind: 'audio', audioDataUrl: 'data:audio/wav;base64,AA==', manifest: [] },
];

function review(overrides: Record<string, unknown> = {}) {
  return {
    invocationId: 'review-1',
    candidateIds: ['hero', 'jump'],
    decisions: {},
    status: 'pending',
    pendingAssetIds: ['hero', 'jump'],
    approvedAssetIds: [],
    discardedAssetIds: [],
    ...overrides,
  };
}

describe('desktop game asset review tool', () => {
  beforeEach(() => {
    hostApiFetchMock.mockReset();
    hostApiFetchMock.mockImplementation((path: string, init?: RequestInit) => {
      if (path.startsWith('/api/files/game-asset-review?')) return Promise.resolve({ review: review(), assets });
      if (path === '/api/files/game-asset-review' && init?.method === 'POST') {
        const body = JSON.parse(String(init.body)) as { decisions: Array<{ assetId: string; action: string }> };
        const decisions = Object.fromEntries(body.decisions.map(({ assetId, action }) => [
          assetId,
          action === 'approve' ? 'approved' : action === 'replace' ? 'replace-requested' : 'discarded',
        ]));
        const approvedAssetIds = body.decisions.filter(({ action }) => action === 'approve').map(({ assetId }) => assetId);
        const discardedAssetIds = body.decisions.filter(({ action }) => action !== 'approve').map(({ assetId }) => assetId);
        return Promise.resolve({
          success: true,
          review: review({
            decisions,
            status: 'resolved',
            pendingAssetIds: [],
            approvedAssetIds,
            discardedAssetIds,
          }),
        });
      }
      throw new Error(`Unexpected Host API call: ${path}`);
    });
  });

  it('keeps card choices local and sends one aggregated decision after final confirmation', async () => {
    const onSubmit = vi.fn();
    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={onSubmit} />);
    expect(await screen.findByText('主角')).toBeInTheDocument();
    expect(screen.getByText('跳跃音效')).toBeInTheDocument();
    expect(document.querySelector('audio')).toHaveAttribute('src', 'data:audio/wav;base64,AA==');

    fireEvent.click(screen.getByRole('button', { name: '纳入开发 主角' }));
    expect(onSubmit).not.toHaveBeenCalled();
    expect(hostApiFetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'POST')).toBe(false);
    expect(screen.getByText('主角')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: '舍弃 跳跃音效' }));
    expect(screen.getByText('已选择 2 / 2 个，可以提交')).toBeInTheDocument();
    const submitButton = screen.getByRole('button', { name: '查看并提交本轮结果' });
    expect(submitButton).toBeEnabled();
    fireEvent.click(submitButton);
    expect(await screen.findByRole('dialog', { name: '确认提交本轮素材审核结果' })).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();

    fireEvent.click(screen.getByRole('button', { name: '确认并一次性提交' }));
    await waitFor(() => expect(hostApiFetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'POST')).toBe(true));
    const postCall = hostApiFetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'POST');
    expect(postCall).toBeDefined();
    expect(JSON.parse(String((postCall?.[1] as RequestInit).body))).toMatchObject({
      invocationId: 'review-1',
      candidateIds: ['hero', 'jump'],
      decisions: [
        { assetId: 'hero', action: 'approve' },
        { assetId: 'jump', action: 'discard' },
      ],
    });
    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
    expect(onSubmit).toHaveBeenCalledWith(assets, { hero: 'approve', jump: 'discard' });
    expect(await screen.findByTestId('game-asset-review-summary')).toHaveTextContent('纳入开发 1 个，舍弃 1 个');
  });

  it('hides technical details when review submission fails', async () => {
    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
    await screen.findByText('主角');
    hostApiFetchMock.mockRejectedValueOnce(new Error('HTTP 500: internal socket path /tmp/review.sock'));

    fireEvent.click(screen.getByRole('button', { name: '纳入开发 主角' }));
    fireEvent.click(screen.getByRole('button', { name: '舍弃 跳跃音效' }));
    fireEvent.click(screen.getByRole('button', { name: '查看并提交本轮结果' }));
    fireEvent.click(await screen.findByRole('button', { name: '确认并一次性提交' }));

    expect(await screen.findByText('素材审核结果暂时无法保存，请稍后重试')).toBeInTheDocument();
    expect(screen.queryByText(/internal socket path/)).not.toBeInTheDocument();
    expect(screen.queryByText(/HTTP 500/)).not.toBeInTheDocument();
  });

  it('renders a persisted resolved review after the app is reopened', async () => {
    hostApiFetchMock.mockResolvedValueOnce({
      review: review({ decisions: { hero: 'approved', jump: 'discarded' }, status: 'resolved', pendingAssetIds: [], approvedAssetIds: ['hero'], discardedAssetIds: ['jump'] }),
      assets: [],
    });
    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
    expect(await screen.findByTestId('game-asset-review-summary')).toHaveTextContent('本轮审核已完成');
    expect(hostApiFetchMock).toHaveBeenCalledWith(expect.stringContaining('invocationId=review-1'));
  });

  it('does not send an empty candidate list to the host route', async () => {
    render(<GameAssetBrowser invocationId="review-1" candidateIds={[]} onSubmit={vi.fn()} />);
    await screen.findByText('主角');
    expect(hostApiFetchMock).toHaveBeenCalledWith('/api/files/game-asset-review?invocationId=review-1');
  });

  it('shows a local error instead of crashing when a host response omits assets', async () => {
    hostApiFetchMock.mockResolvedValueOnce({ review: review() });

    render(<GameAssetBrowser invocationId="legacy-review" candidateIds={['hero']} onSubmit={vi.fn()} />);

    expect(await screen.findByText('旧素材卡片已失效，已自动跳过，不影响继续对话')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: '重新加载' })).not.toBeInTheDocument();
    expect(screen.getByTestId('game-asset-browser-stale')).toBeInTheDocument();
    expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
  });

  it('skips a stale historical card without retrying or exposing the server error', async () => {
    const error = new AppError('PERMISSION', 'Game asset review is unavailable for an uninitialized project', undefined, { status: 403 });
    hostApiFetchMock.mockRejectedValueOnce(error);

    render(<GameAssetBrowser invocationId="legacy-review" candidateIds={['hero']} onSubmit={vi.fn()} />);

    expect(await screen.findByText('旧素材卡片已失效，已自动跳过，不影响继续对话')).toBeInTheDocument();
    expect(screen.queryByText(error.message)).not.toBeInTheDocument();
    expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
  });

  it('automatically retries one transient failure and recovers silently', async () => {
    hostApiFetchMock
      .mockRejectedValueOnce(new AppError('NETWORK', 'fetch failed'))
      .mockResolvedValueOnce({ review: review(), assets });

    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);

    expect(await screen.findByText('主角')).toBeInTheDocument();
    expect(hostApiFetchMock).toHaveBeenCalledTimes(2);
    expect(screen.queryByText('素材卡片暂时无法加载，不影响继续对话')).not.toBeInTheDocument();
  });

  it('offers manual reload after two transient failures without showing technical details', async () => {
    hostApiFetchMock
      .mockRejectedValueOnce(new AppError('UNKNOWN', 'HTTP 503', undefined, { status: 503 }))
      .mockRejectedValueOnce(new Error('socket closed'));

    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero']} onSubmit={vi.fn()} />);

    expect(await screen.findByText('素材卡片暂时无法加载，不影响继续对话')).toBeInTheDocument();
    expect(screen.queryByText('HTTP 503')).not.toBeInTheDocument();
    expect(screen.queryByText('socket closed')).not.toBeInTheDocument();
    expect(hostApiFetchMock).toHaveBeenCalledTimes(2);
    expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument();
  });

  it('starts one new bounded retry round when the user reloads', async () => {
    hostApiFetchMock
      .mockRejectedValueOnce(new AppError('NETWORK', 'first round one'))
      .mockRejectedValueOnce(new AppError('NETWORK', 'first round two'))
      .mockRejectedValueOnce(new AppError('NETWORK', 'second round one'))
      .mockResolvedValueOnce({ review: review(), assets });

    render(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
    fireEvent.click(await screen.findByRole('button', { name: '重新加载' }));

    expect(await screen.findByText('主角')).toBeInTheDocument();
    expect(hostApiFetchMock).toHaveBeenCalledTimes(4);
  });
});
