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

import { ObjectDetail } from '../src/platform/pages/ObjectDetail';
import { StudioObjectDetails } from '../src/platform/features/studio/components/object-editor/StudioObjectDetails';
import type { SObjectDescribe } from '../src/platform/types';

/**
 * app-packs#604 — object descriptions live ONLY on the metadata (authoring)
 * API. Describe is the Salesforce parity surface and `DescribeSObjectResult`
 * carries no object description, so an admin page that sourced it from
 * getSchema() rendered "-" for every object in the org, and then wrote that
 * blank back over the stored value the moment anyone pressed Save.
 *
 * These tests pin both halves: the read comes from getInternalObjectMetadata,
 * and a description that was never read is never written.
 */

const apiMocks = vi.hoisted(() => ({
  getSchema: vi.fn(),
  getInternalObjectMetadata: vi.fn(),
  updateSchema: vi.fn(),
  deleteField: vi.fn(),
  getRecordTypes: vi.fn(),
}));

vi.mock('../src/platform/infrastructure/api/metadata', () => ({
  metadataAPI: apiMocks,
}));

vi.mock('react-router-dom', async () => {
  const actual = await vi.importActual<Record<string, unknown>>('react-router-dom');
  return { ...actual, useParams: () => ({ objectApiName: 'Account' }) };
});

// A describe payload exactly as the wire delivers it: note the total absence of
// a `description` key. Spelling one here would test a fiction.
const ACCOUNT_DESCRIBE = {
  name: 'Account',
  label: 'Account',
  labelPlural: 'Accounts',
  custom: false,
  keyPrefix: '001',
  createable: true,
  updateable: true,
  deletable: true,
  retrieveable: true,
  searchable: true,
  triggerable: true,
  undeletable: true,
  mergeable: false,
  deprecatedAndHidden: false,
  layoutable: true,
  queryable: true,
  replicateable: true,
  feedEnabled: true,
  fields: [],
} as unknown as SObjectDescribe;

const RETIREMENT_MARKER =
  'RETIRED — superseded by OrderItem (2026-07-23). Read from OrderItem instead; do not create or update here.';

const AUTHORING_KEY = ['metadata', 'object-authoring', 'Account'];

function renderObjectDetail() {
  const client = new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } });
  return { client, ...render(
    <QueryClientProvider client={client}>
      <ObjectDetail />
    </QueryClientProvider>,
  ) };
}

async function openDetailsTab(user: ReturnType<typeof userEvent.setup>) {
  await user.click(await screen.findByRole('button', { name: /Details/ }));
}

beforeEach(() => {
  vi.clearAllMocks();
  apiMocks.getSchema.mockResolvedValue({ schema: ACCOUNT_DESCRIBE });
  apiMocks.updateSchema.mockResolvedValue({ schema: ACCOUNT_DESCRIBE, message: 'ok' });
  apiMocks.getRecordTypes.mockResolvedValue([]);
});

describe('Setup object detail — Description', () => {
  it('renders the description from the authoring API, which describe never carries', async () => {
    apiMocks.getInternalObjectMetadata.mockResolvedValue({
      schema: { name: 'Account', description: RETIREMENT_MARKER },
    });
    const user = userEvent.setup();

    renderObjectDetail();
    await openDetailsTab(user);

    expect(await screen.findByText(RETIREMENT_MARKER)).toBeInTheDocument();
    // The page must actually ask the authoring API for it. Reading it off the
    // describe response is the bug, and describe has no such field to read.
    expect(apiMocks.getInternalObjectMetadata).toHaveBeenCalledWith('Account');
  });

  it('shows "-" only once the authoring API has confirmed there is no description', async () => {
    apiMocks.getInternalObjectMetadata.mockResolvedValue({ schema: { name: 'Account' } });
    const user = userEvent.setup();

    renderObjectDetail();
    await openDetailsTab(user);

    await waitFor(() => expect(screen.getByText('-')).toBeInTheDocument());
  });

  it('does not claim "no description" when the authoring read failed', async () => {
    apiMocks.getInternalObjectMetadata.mockRejectedValue(new Error('boom'));
    const user = userEvent.setup();

    renderObjectDetail();
    await openDetailsTab(user);

    expect(await screen.findByText(/Could not load the description/)).toBeInTheDocument();
    // "-" is a factual claim about the object. A failed request cannot make it.
    expect(screen.queryByText('-')).not.toBeInTheDocument();
  });

  it('round-trips the real description through Edit Object instead of blanking it', async () => {
    apiMocks.getInternalObjectMetadata.mockResolvedValue({
      schema: { name: 'Account', description: RETIREMENT_MARKER },
    });
    const user = userEvent.setup();

    const { container } = renderObjectDetail();
    await user.click(await screen.findByRole('button', { name: /Edit Object/ }));

    // Located structurally, not by its value — asserting on an element found
    // *by* the value it should have proves nothing.
    const textarea = container.querySelector('textarea');
    expect(textarea).not.toBeNull();
    expect(textarea).toHaveValue(RETIREMENT_MARKER);
    expect(textarea).not.toBeDisabled();

    await user.click(screen.getByRole('button', { name: /Save Changes/ }));

    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    expect(apiMocks.updateSchema.mock.calls[0][1]).toMatchObject({
      description: RETIREMENT_MARKER,
    });
    // The modal only closes on the far side of the post-save refreshes, so this
    // also proves the handler ran to completion rather than throwing partway.
    await waitFor(() =>
      expect(screen.queryByRole('button', { name: /Save Changes/ })).not.toBeInTheDocument(),
    );
  });

  it('fills the modal in when the authoring read lands after it was opened', async () => {
    // Describe gates the Edit Object button and resolves first, so the modal can
    // be open while the authoring read is still in flight.
    let resolveAuthoring: (value: unknown) => void = () => {};
    apiMocks.getInternalObjectMetadata.mockReturnValue(
      new Promise(resolve => {
        resolveAuthoring = resolve;
      }),
    );
    const user = userEvent.setup();

    const { container } = renderObjectDetail();
    await user.click(await screen.findByRole('button', { name: /Edit Object/ }));

    const textarea = container.querySelector('textarea');
    expect(textarea).toBeDisabled();
    expect(screen.getByText(/still loading/i)).toBeInTheDocument();

    await act(async () => {
      resolveAuthoring({ schema: { name: 'Account', description: RETIREMENT_MARKER } });
    });

    // No reopen required, and the message must stop claiming it is pending.
    await waitFor(() => expect(container.querySelector('textarea')).toHaveValue(RETIREMENT_MARKER));
    expect(container.querySelector('textarea')).not.toBeDisabled();
    expect(screen.queryByText(/still loading/i)).not.toBeInTheDocument();
    expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();

    // And the late-arriving value is what gets saved, not a blank.
    await user.click(screen.getByRole('button', { name: /Save Changes/ }));
    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    expect(apiMocks.updateSchema.mock.calls[0][1]).toMatchObject({ description: RETIREMENT_MARKER });
  });

  it('does not overwrite an edit in progress when a later read resolves', async () => {
    apiMocks.getInternalObjectMetadata.mockResolvedValue({
      schema: { name: 'Account', description: RETIREMENT_MARKER },
    });
    const user = userEvent.setup();

    const { container, client } = renderObjectDetail();
    await user.click(await screen.findByRole('button', { name: /Edit Object/ }));
    await waitFor(() => expect(container.querySelector('textarea')).toHaveValue(RETIREMENT_MARKER));

    const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
    await user.clear(textarea);
    await user.type(textarea, 'admin rewrite');

    // Force the seeding effect to actually re-run mid-edit: a refetch hands the
    // hook a fresh payload object, which is the only way `authoringMetadata`
    // changes identity. Without this the effect never fires a second time and
    // the assertion below would pass no matter what the effect does.
    apiMocks.getInternalObjectMetadata.mockResolvedValue({
      schema: { name: 'Account', description: 'server-side value that must not win' },
    });
    await act(async () => {
      await client.invalidateQueries({ queryKey: AUTHORING_KEY });
    });

    expect(container.querySelector('textarea')).toHaveValue('admin rewrite');

    await user.click(screen.getByRole('button', { name: /Save Changes/ }));
    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    expect(apiMocks.updateSchema.mock.calls[0][1]).toMatchObject({ description: 'admin rewrite' });
  });

  it('omits description entirely when it was never read, so Save cannot erase it', async () => {
    apiMocks.getInternalObjectMetadata.mockRejectedValue(new Error('boom'));
    const user = userEvent.setup();

    renderObjectDetail();
    await user.click(await screen.findByRole('button', { name: /Edit Object/ }));
    await user.click(screen.getByRole('button', { name: /Save Changes/ }));

    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    const payload = apiMocks.updateSchema.mock.calls[0][1] as Record<string, unknown>;
    // Absent, not empty: the endpoint clears the stored description on an empty
    // string and leaves it alone on a missing key.
    expect('description' in payload).toBe(false);
  });
});

describe('Studio object editor — Description', () => {
  const baseProps = {
    objectApiName: 'Account',
    object: ACCOUNT_DESCRIBE,
    onUpdate: () => {},
  };

  it('renders the authoring description rather than a blank box', () => {
    render(
      <StudioObjectDetails
        {...baseProps}
        description={RETIREMENT_MARKER}
        descriptionStatus="loaded"
      />,
    );

    expect(screen.getByDisplayValue(RETIREMENT_MARKER)).toBeInTheDocument();
  });

  it('sends the edited description when it was read', async () => {
    const user = userEvent.setup();
    render(
      <StudioObjectDetails
        {...baseProps}
        description={RETIREMENT_MARKER}
        descriptionStatus="loaded"
      />,
    );

    await user.click(screen.getByRole('button', { name: /Edit Details/ }));
    await user.click(screen.getByRole('button', { name: /Save Changes/ }));

    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    expect(apiMocks.updateSchema.mock.calls[0][1]).toMatchObject({
      description: RETIREMENT_MARKER,
    });
  });

  it('omits description entirely when it was never read', async () => {
    const user = userEvent.setup();
    render(
      <StudioObjectDetails
        {...baseProps}
        description={undefined}
        descriptionStatus="unavailable"
      />,
    );

    await user.click(screen.getByRole('button', { name: /Edit Details/ }));
    await user.click(screen.getByRole('button', { name: /Save Changes/ }));

    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    const payload = apiMocks.updateSchema.mock.calls[0][1] as Record<string, unknown>;
    expect('description' in payload).toBe(false);
  });

  it('never forwards the describe payload as an update body', async () => {
    const user = userEvent.setup();
    render(
      <StudioObjectDetails
        {...baseProps}
        description={RETIREMENT_MARKER}
        descriptionStatus="loaded"
      />,
    );

    await user.click(screen.getByRole('button', { name: /Edit Details/ }));
    await user.click(screen.getByRole('button', { name: /Save Changes/ }));

    await waitFor(() => expect(apiMocks.updateSchema).toHaveBeenCalled());
    // It used to spread the whole SObjectDescribe into the PATCH body.
    expect(Object.keys(apiMocks.updateSchema.mock.calls[0][1] as object).sort()).toEqual([
      'description',
      'label',
      'labelPlural',
    ]);
  });
});
