import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { AuditPage } from './audit';
import {
  AuditEventsService,
  type AdminAuditPage,
  type AdminAuditQuery,
  type AdminAuditStats,
  type AdminAuditStatsQuery,
} from './audit-events.service';

const STATS: AdminAuditStats = {
  dailyVolume: [
    { day: '2026-05-12', count: 5 },
    { day: '2026-05-13', count: 7 },
  ],
  outcomeBreakdown: [
    { outcome: 'success', count: 11 },
    { outcome: 'denied', count: 1 },
  ],
  eventTypeByDay: [
    { day: '2026-05-12', eventType: 'auth.sign_in', count: 5 },
    { day: '2026-05-13', eventType: 'auth.sign_in', count: 6 },
    { day: '2026-05-13', eventType: 'admin.access_denied', count: 1 },
  ],
  total: 12,
};

const PAGE: AdminAuditPage = {
  items: [
    {
      id: '11111111-1111-1111-1111-111111111111',
      createdAt: '2026-05-13T10:30:00.000Z',
      eventType: 'auth.sign_in',
      audience: 'workforce',
      actorIdHash: 'hash(jane)',
      traceId: 'trace-abc',
      subject: 'session:sid-1',
      outcome: 'success',
      payload: { amr: ['pwd', 'mfa'] },
    },
    {
      id: '22222222-2222-2222-2222-222222222222',
      createdAt: '2026-05-13T10:31:00.000Z',
      eventType: 'admin.access_denied',
      audience: 'workforce',
      actorIdHash: 'hash(mallory)',
      traceId: null,
      subject: 'GET /api/admin/me',
      outcome: 'denied',
      payload: null,
    },
  ],
  total: 247,
  limit: 50,
  offset: 0,
};

interface Fixture {
  fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
  query: ReturnType<typeof vi.fn>;
  stats: ReturnType<typeof vi.fn>;
}

function setup(opts?: {
  initial?: AdminAuditPage | 'error';
  initialStats?: AdminAuditStats | 'error';
  status?: number;
}): Fixture {
  const initial = opts?.initial ?? PAGE;
  const initialStats = opts?.initialStats ?? STATS;
  const query = vi.fn().mockImplementation(() => {
    if (initial === 'error') {
      return throwError(() => ({ status: opts?.status ?? 500 }));
    }
    return of(initial);
  });
  const stats = vi.fn().mockImplementation(() => {
    if (initialStats === 'error') {
      return throwError(() => ({ status: opts?.status ?? 500 }));
    }
    return of(initialStats);
  });
  TestBed.configureTestingModule({
    imports: [AuditPage],
    providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
  });
  const fixture = TestBed.createComponent(AuditPage);
  return { fixture, query, stats };
}

async function flush(fixture: Fixture['fixture']): Promise<void> {
  // Drain microtasks twice — the initial fetch fires from ngOnInit
  // and awaits firstValueFrom (one tick) then updates the signal
  // (another tick) before the DOM reflects the result.
  fixture.detectChanges();
  await Promise.resolve();
  await Promise.resolve();
  fixture.detectChanges();
  await fixture.whenStable();
}

describe('AuditPage', () => {
  it('runs an initial unfiltered query on init and renders the result table', async () => {
    const { fixture, query } = setup();
    await flush(fixture);
    expect(query).toHaveBeenCalledTimes(1);
    expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 }));
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    expect(rows.length).toBe(2);
  });

  it('shows the result range ("1–2 of 247") in the status bar', async () => {
    const { fixture } = setup();
    await flush(fixture);
    expect(
      (fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
    ).toContain('1–2 of 247');
  });

  it('renders the empty state when the page has no items', async () => {
    const { fixture } = setup({
      initial: { items: [], total: 0, limit: 50, offset: 0 },
    });
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    expect(root.querySelector('.empty')?.textContent).toContain('No audit events');
    expect(root.querySelector('.audit-table')).toBeNull();
  });

  it('renders an error message when the service rejects (500)', async () => {
    const { fixture } = setup({ initial: 'error', status: 500 });
    await flush(fixture);
    const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
    expect(status?.textContent).toContain('Could not load the audit log');
  });

  it('renders a permission-aware error message on 403', async () => {
    const { fixture } = setup({ initial: 'error', status: 403 });
    await flush(fixture);
    const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
    expect(status?.textContent).toContain('do not have access');
  });

  it('passes the populated filter fields to the service on Apply', async () => {
    const { fixture, query } = setup();
    await flush(fixture);
    const component = fixture.componentInstance;
    // Simulate user input by setting the signals directly.
    (component as unknown as { eventType: { set: (v: string) => void } }).eventType.set(
      'auth.sign_in',
    );
    (component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce');
    await component.search();
    await flush(fixture);
    expect(query).toHaveBeenCalledTimes(2);
    const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
    expect(lastCall.eventType).toBe('auth.sign_in');
    expect(lastCall.audience).toBe('workforce');
    expect(lastCall.offset).toBe(0);
  });

  it('resets the offset to 0 on each Apply (no stale page when filters narrow)', async () => {
    const { fixture, query } = setup();
    await flush(fixture);
    const component = fixture.componentInstance;
    // Advance to next page.
    await component.next();
    await flush(fixture);
    expect((query.mock.calls[1]?.[0] as AdminAuditQuery).offset).toBe(50);
    // Apply a new filter — offset must drop back to 0.
    await component.search();
    await flush(fixture);
    expect((query.mock.calls[2]?.[0] as AdminAuditQuery).offset).toBe(0);
  });

  it('clearFilters empties every filter signal and re-queries from offset 0', async () => {
    const { fixture, query } = setup();
    await flush(fixture);
    const component = fixture.componentInstance;
    (component as unknown as { eventType: { set: (v: string) => void } }).eventType.set('x');
    (component as unknown as { outcome: { set: (v: string) => void } }).outcome.set('denied');
    await component.clearFilters();
    await flush(fixture);
    const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
    expect(lastCall.eventType).toBeUndefined();
    expect(lastCall.outcome).toBeUndefined();
    expect(lastCall.offset).toBe(0);
  });

  it('Next is disabled when the loaded page covers the total', async () => {
    const { fixture } = setup({
      initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 },
    });
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const nextBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
      (b) => b.textContent?.trim() === 'Next',
    );
    expect(nextBtn?.disabled).toBe(true);
  });

  it('Previous is disabled at offset 0', async () => {
    const { fixture } = setup();
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const prevBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
      (b) => b.textContent?.trim() === 'Previous',
    );
    expect(prevBtn?.disabled).toBe(true);
  });

  it('renders the outcome badge variant matching the row', async () => {
    const { fixture } = setup();
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    expect(rows[0]?.querySelector('.outcome-badge--success')).not.toBeNull();
    expect(rows[1]?.querySelector('.outcome-badge--denied')).not.toBeNull();
  });

  it('shows the payload disclosure only for rows that have a payload', async () => {
    const { fixture } = setup();
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    expect(rows[0]?.querySelector('details')).not.toBeNull();
    expect(rows[1]?.querySelector('details')).toBeNull();
  });

  it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
    const { fixture } = setup();
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
    expect(link).not.toBeNull();
    expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
    expect(link?.getAttribute('target')).toBe('_blank');
    expect(link?.getAttribute('rel')).toContain('noopener');
  });

  it('renders the dash placeholder for rows without a trace_id', async () => {
    const { fixture } = setup();
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    expect(rows[1]?.querySelector('.trace-link')).toBeNull();
    expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
  });

  it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
    const { fixture, query } = setup();
    await flush(fixture);
    query.mockClear();
    const root = fixture.nativeElement as HTMLElement;
    const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
    rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
    await flush(fixture);
    expect(query).toHaveBeenCalledTimes(1);
    const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
    expect(filters.actorIdHash).toBe('hash(jane)');
    expect(filters.offset).toBe(0);
  });

  it('renders the anonymous actor as plain text (not a button)', async () => {
    const anonymousPage: AdminAuditPage = {
      ...PAGE,
      items: [{ ...PAGE.items[0]!, actorIdHash: null }],
    };
    const { fixture } = setup({ initial: anonymousPage });
    await flush(fixture);
    const root = fixture.nativeElement as HTMLElement;
    const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
    expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
    expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
  });

  describe('tabs + charts', () => {
    it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
      const { fixture, stats } = setup();
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      const activeTab = root.querySelector('.tab--active');
      expect(activeTab?.textContent?.trim()).toBe('Table');
      expect(stats).not.toHaveBeenCalled();
    });

    it('fetches stats when the user opens the Charts tab for the first time', async () => {
      const { fixture, stats } = setup();
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      const chartsTab = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab')).find(
        (b) => b.textContent?.trim() === 'Charts',
      );
      chartsTab?.click();
      await flush(fixture);
      expect(stats).toHaveBeenCalledTimes(1);
      expect(root.querySelector('lib-bar-chart')).not.toBeNull();
      expect(root.querySelector('lib-donut-chart')).not.toBeNull();
      expect(root.querySelector('lib-stacked-bar-chart')).not.toBeNull();
    });

    it('does NOT pass pagination knobs to the stats endpoint', async () => {
      const { fixture, stats } = setup();
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & {
        limit?: number;
        offset?: number;
      };
      expect(filters.limit).toBeUndefined();
      expect(filters.offset).toBeUndefined();
    });

    it('does NOT re-fetch stats on pagination (filters unchanged)', async () => {
      const { fixture, stats } = setup();
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      expect(stats).toHaveBeenCalledTimes(1);
      // Switch back to Table and paginate.
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Table')
        ?.click();
      await flush(fixture);
      // Open Charts again — cache still valid, no new stats call.
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      expect(stats).toHaveBeenCalledTimes(1);
    });

    it('re-fetches stats when filters change and the Charts tab is active', async () => {
      const { fixture, stats } = setup();
      await flush(fixture);
      // Open Charts to load the initial stats.
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      expect(stats).toHaveBeenCalledTimes(1);

      // Component-state mutation: tweak a filter and re-search.
      const cmp = fixture.componentInstance as unknown as {
        eventType: { set(v: string): void };
        search(): Promise<void>;
      };
      cmp.eventType.set('auth.sign_in');
      await cmp.search();
      await flush(fixture);

      expect(stats).toHaveBeenCalledTimes(2);
    });

    it('shows the donut centre label with the server-side total (not the per-page count)', async () => {
      const { fixture } = setup();
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
      expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total));
    });

    it('shows the empty-state message when the stats endpoint returns 0 events', async () => {
      const emptyStats: AdminAuditStats = {
        dailyVolume: [],
        outcomeBreakdown: [],
        eventTypeByDay: [],
        total: 0,
      };
      const { fixture } = setup({ initialStats: emptyStats });
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      expect(root.querySelector('lib-bar-chart')).toBeNull();
      expect(root.querySelector('#panel-charts .empty')).not.toBeNull();
    });

    it('renders a permission-aware error when the stats endpoint returns 403', async () => {
      const { fixture } = setup({ initialStats: 'error', status: 403 });
      await flush(fixture);
      const root = fixture.nativeElement as HTMLElement;
      Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
        .find((b) => b.textContent?.trim() === 'Charts')
        ?.click();
      await flush(fixture);
      const errMsg = root.querySelector('.status-line--error')?.textContent?.trim();
      expect(errMsg).toContain('do not have access');
    });
  });
});
