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

import { ConnectedAppManager } from '../src/platform/pages/ConnectedAppManager';

const dataAPIMock = vi.hoisted(() => ({
  query: vi.fn(),
  createRecord: vi.fn(),
  updateRecord: vi.fn(),
  deleteRecord: vi.fn(),
}));

vi.mock('../src/platform/infrastructure/api/data', () => ({
  dataAPI: dataAPIMock,
}));

// The host-sdk test stub returns fresh notification callbacks on every render,
// which would retrigger the page's load effect forever. Pin them so the list
// settles; identity stability is what the real host provides.
const stableNotifications = vi.hoisted(() => ({
  success: () => {},
  error: () => {},
  info: () => {},
  warning: () => {},
}));

vi.mock('@nexuscrm/host-sdk', async (importOriginal) => ({
  ...(await importOriginal<Record<string, unknown>>()),
  useHostNotifications: () => stableNotifications,
}));

const CONFIDENTIAL_APP = {
  Id: 'app-1',
  Name: 'Billing Sync',
  ConsumerKey: 'ck-billing-sync',
  CallbackUrl: 'https://example.com/oauth/callback',
  RedirectUris: 'https://example.com/oauth/callback',
  Scopes: 'api',
  ClientType: 'confidential',
  RequirePkce: false,
  TokenEndpointAuthMethod: 'client_secret_post',
  AllowedGrantTypes: 'authorization_code',
  ConsentPolicy: 'prompt',
  IssueRefreshTokens: false,
  PublisherName: 'Acme',
  SupportUrl: '',
  IsActive: true,
};

const PUBLIC_APP = {
  ...CONFIDENTIAL_APP,
  Id: 'app-2',
  Name: 'Public BI',
  ClientType: 'public',
  RequirePkce: true,
  TokenEndpointAuthMethod: 'none',
  ConsentPolicy: 'trusted',
};

async function openEditForm() {
  render(<ConnectedAppManager />);
  await screen.findByTitle('Edit');
  fireEvent.click(within(screen.getByTitle('Edit')).getByRole('button'));
  await screen.findByText('Edit Connected App');
}

function secretSection(): HTMLElement {
  return screen.getByLabelText('Consumer Secret is hidden and cannot be retrieved').parentElement!;
}

describe('Connected App consumer secret is write-only in the UI', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    dataAPIMock.query.mockResolvedValue([CONFIDENTIAL_APP]);
    dataAPIMock.updateRecord.mockResolvedValue(undefined);
    dataAPIMock.createRecord.mockResolvedValue(CONFIDENTIAL_APP);
  });

  it('offers no reveal affordance in edit mode, because no reveal path exists', async () => {
    await openEditForm();

    // The stored value is a bcrypt hash; a Show toggle could only ever reveal an
    // empty field, so the control must not be rendered at all.
    expect(screen.queryByText('Show')).toBeNull();
    expect(screen.queryByText('Hide')).toBeNull();
    expect(screen.getByText(/cannot be retrieved/i)).toBeTruthy();
  });

  it('renders the secret as a non-editable placeholder rather than a text field', async () => {
    await openEditForm();

    const masked = screen.getByLabelText('Consumer Secret is hidden and cannot be retrieved');
    expect(masked.tagName).not.toBe('INPUT');
    expect(masked.textContent).toBe('•'.repeat(24));
    // Anything typed into an input here would replace the secret, not read it.
    expect(within(secretSection()).queryByRole('textbox')).toBeNull();
  });

  it('never submits ConsumerSecret when an unrelated field is edited', async () => {
    await openEditForm();

    fireEvent.change(screen.getByDisplayValue('Billing Sync'), {
      target: { value: 'Billing Sync v2' },
    });
    fireEvent.click(screen.getByText('Save Changes'));

    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));
    const [, recordId, payload] = dataAPIMock.updateRecord.mock.calls[0];
    expect(recordId).toBe('app-1');
    expect(payload.Name).toBe('Billing Sync v2');
    // ConsumerSecret is session-invalidating: a routine rename must not touch it.
    expect(Object.prototype.hasOwnProperty.call(payload, 'ConsumerSecret')).toBe(false);
  });

  it('does not rotate until the confirmation is accepted', async () => {
    await openEditForm();

    fireEvent.click(screen.getByText('Rotate secret'));

    // The confirmation is open, but nothing has been written yet.
    expect(dataAPIMock.updateRecord).not.toHaveBeenCalled();
    expect(screen.getByRole('dialog', { name: 'Rotate consumer secret?' })).toBeTruthy();
  });

  it('rotates immediately on confirmation and shows the new secret once', async () => {
    await openEditForm();

    fireEvent.click(screen.getByText('Rotate secret'));
    const dialog = await screen.findByRole('dialog', { name: 'Rotate consumer secret?' });
    fireEvent.click(within(dialog).getByRole('button', { name: 'Rotate secret' }));

    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));
    const [, recordId, payload] = dataAPIMock.updateRecord.mock.calls[0];
    expect(recordId).toBe('app-1');
    // Rotation writes the secret alone — it does not ride along with the form.
    expect(Object.keys(payload)).toEqual(['ConsumerSecret']);
    expect(typeof payload.ConsumerSecret).toBe('string');
    expect(payload.ConsumerSecret.length).toBeGreaterThan(0);

    const shown = await screen.findByText(payload.ConsumerSecret as string);
    expect(shown).toBeTruthy();
    expect(screen.getByText(/only time this secret is shown/i)).toBeTruthy();
  });

  it('does not resubmit the rotated secret when the form is saved afterwards', async () => {
    await openEditForm();

    fireEvent.click(screen.getByText('Rotate secret'));
    const dialog = await screen.findByRole('dialog', { name: 'Rotate consumer secret?' });
    fireEvent.click(within(dialog).getByRole('button', { name: 'Rotate secret' }));
    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));

    fireEvent.click(screen.getByText('Save Changes'));

    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(2));
    const [, , savePayload] = dataAPIMock.updateRecord.mock.calls[1];
    expect(Object.prototype.hasOwnProperty.call(savePayload, 'ConsumerSecret')).toBe(false);
  });

  it('never hides a secret the form is about to write', async () => {
    // Switching an edited public app to confidential mints a secret that Save
    // submits. Hiding it behind the placeholder would hand the app a credential
    // its own admin never saw and can never recover.
    dataAPIMock.query.mockResolvedValue([PUBLIC_APP]);
    await openEditForm();

    fireEvent.click(screen.getByText('confidential'));

    expect(screen.queryByLabelText('Consumer Secret is hidden and cannot be retrieved')).toBeNull();
    const field = screen.getByLabelText('Consumer Secret') as HTMLInputElement;
    expect(field.value.length).toBeGreaterThan(0);
    // Unrecoverable once saved, so it must not sit behind a password mask.
    expect(field.type).toBe('text');

    fireEvent.click(screen.getByText('Save Changes'));
    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));
    const [, , payload] = dataAPIMock.updateRecord.mock.calls[0];
    // What is written is exactly what was on screen.
    expect(payload.ConsumerSecret).toBe(field.value);
  });

  it('does not hide the secret minted by a confidential -> public -> confidential round trip', async () => {
    await openEditForm();
    expect(screen.getByLabelText('Consumer Secret is hidden and cannot be retrieved')).toBeTruthy();

    fireEvent.click(screen.getByText('public'));
    fireEvent.click(screen.getByText('confidential'));

    expect(screen.queryByLabelText('Consumer Secret is hidden and cannot be retrieved')).toBeNull();
    const field = screen.getByLabelText('Consumer Secret') as HTMLInputElement;
    expect(field.type).toBe('text');

    fireEvent.click(screen.getByText('Save Changes'));
    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));
    const [, , payload] = dataAPIMock.updateRecord.mock.calls[0];
    expect(payload.ConsumerSecret).toBe(field.value);
  });

  it('stages nothing when the already-selected client type is clicked', async () => {
    await openEditForm();

    fireEvent.click(screen.getByText('confidential'));

    // The stored secret is still the stored secret: no plaintext was minted.
    expect(screen.getByLabelText('Consumer Secret is hidden and cannot be retrieved')).toBeTruthy();
    fireEvent.click(screen.getByText('Save Changes'));
    await waitFor(() => expect(dataAPIMock.updateRecord).toHaveBeenCalledTimes(1));
    const [, , payload] = dataAPIMock.updateRecord.mock.calls[0];
    expect(Object.prototype.hasOwnProperty.call(payload, 'ConsumerSecret')).toBe(false);
  });

  it('warns that a pending edit-mode secret replaces the live one on save', async () => {
    dataAPIMock.query.mockResolvedValue([PUBLIC_APP]);
    await openEditForm();

    fireEvent.click(screen.getByText('confidential'));

    expect(screen.getByText(/applied when you save/i)).toBeTruthy();
    expect(screen.getByText(/revokes every existing token and session/i)).toBeTruthy();
  });

  it('tells the truth at creation: the secret is hashed, not masked', async () => {
    render(<ConnectedAppManager />);
    await screen.findByText('Billing Sync');
    fireEvent.click(screen.getByText('New Connected App'));
    await screen.findByText('New Connected App', { selector: 'h2' });

    // Creation is the only moment the plaintext exists, so the toggle belongs here.
    expect(screen.getByText('Hide')).toBeTruthy();
    expect(screen.getByText(/stored as a hash and cannot be retrieved later/i)).toBeTruthy();
    expect(screen.queryByText(/masked after saving/i)).toBeNull();
  });
});
