import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
  useRecordActivityFeed,
  type CrmActivityQueryApi,
} from '../src/record-activity-feed-data';
import { crmActivityTimelineSurface } from '../src/activity-timeline-surface';

// The feed reads three streams through the injected activityQueryApi: field
// changes (raw-SOQL /queryAll over <Object>History), Task/Event activities
// (structured /query per surface object), and posts (_System_Comment /query).
const queryMock = vi.fn<CrmActivityQueryApi['query']>();
const queryAllMock = vi.fn<NonNullable<CrmActivityQueryApi['queryAll']>>();
const api: CrmActivityQueryApi = { query: queryMock, queryAll: queryAllMock };

const OPP_ID = '006000000000001';

describe('useRecordActivityFeed', () => {
  beforeEach(() => {
    queryMock.mockReset();
    queryAllMock.mockReset();
    queryAllMock.mockResolvedValue([]);
    queryMock.mockResolvedValue([]);
  });

  it('falls back to CreatedDate so a logged Task with no due date still appears', async () => {
    queryMock.mockImplementation(async ({ objectApiName }) => {
      if (objectApiName === 'Task') {
        // A logged call with no ActivityDate — must not drop out of the feed.
        return [{
          Id: '00T000000000001',
          Subject: 'Logged call (no due date)',
          Status: 'Completed',
          IsClosed: '1',
          CreatedDate: '2026-07-10T12:00:00Z',
        }];
      }
      return [];
    });

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api));
    await waitFor(() => expect(result.current.loading).toBe(false));

    const entry = result.current.entries.find(e => e.title === 'Logged call (no due date)');
    expect(entry).toBeDefined();
    expect(entry?.timestampMs).toBe(new Date('2026-07-10T12:00:00Z').getTime());
  });

  it('merges history, activity, and post streams newest-first', async () => {
    queryAllMock.mockResolvedValue([{
      Id: 'h1',
      Field: 'StageName',
      OldValue: 'Prospecting',
      NewValue: 'Qualification',
      CreatedDate: '2026-07-05T00:00:00Z',
      CreatedBy: { Name: 'Ada Lovelace' },
    }]);
    queryMock.mockImplementation(async ({ objectApiName }) => {
      if (objectApiName === 'Task') {
        return [{
          Id: '00T000000000009',
          Subject: 'Recent task',
          ActivityDate: '2026-07-08',
          Status: 'Completed',
          IsClosed: '1',
          CreatedDate: '2026-07-08T00:00:00Z',
        }];
      }
      if (objectApiName === '_System_Comment') {
        return [{
          Id: 'c1',
          Body: 'left a note',
          CreatedById: '005000000000001',
          CreatedDate: '2026-07-01T00:00:00Z',
        }];
      }
      return [];
    });

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api));
    await waitFor(() => expect(result.current.loading).toBe(false));

    // 07-08 activity → 07-05 field change → 07-01 post.
    expect(result.current.entries.map(e => e.kind)).toEqual(['activity', 'field-change', 'post']);
    const change = result.current.entries.find(e => e.kind === 'field-change');
    expect(change?.actorName).toBe('Ada Lovelace');
    expect(change?.field).toBe('StageName');
  });

  it('shows every tracked field when no core list is configured', async () => {
    // Backward compatibility: packs and objects with no authored config must be
    // unaffected by the contract existing.
    queryAllMock.mockResolvedValue([
      { Id: 'h1', Field: 'Probability', OldValue: '20', NewValue: '50',
        CreatedDate: '2026-08-05T00:00:02Z', CreatedBy: { Name: 'Kenric Tran' } },
      { Id: 'h2', Field: 'StageName', OldValue: 'Discovery & Qualification',
        NewValue: 'Business Validation',
        CreatedDate: '2026-08-05T00:00:00Z', CreatedBy: { Name: 'Kenric Tran' } },
    ]);

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries.map(e => e.field)).toEqual(['Probability', 'StageName']);
  });

  it('keeps only the configured core fields when a core list is given', async () => {
    queryAllMock.mockResolvedValue([
      { Id: 'h-prob', Field: 'Probability', OldValue: '20', NewValue: '50',
        CreatedDate: '2026-08-05T00:00:03Z', CreatedBy: { Name: 'Kenric Tran' } },
      { Id: 'h-fc', Field: 'ForecastCategory', OldValue: 'Pipeline', NewValue: 'BestCase',
        CreatedDate: '2026-08-05T00:00:02Z', CreatedBy: { Name: 'Kenric Tran' } },
      { Id: 'h-stage', Field: 'StageName', OldValue: 'Discovery & Qualification',
        NewValue: 'Business Validation',
        CreatedDate: '2026-08-05T00:00:01Z', CreatedBy: { Name: 'Kenric Tran' } },
      { Id: 'h-launch', Field: 'Launch_Date__c', OldValue: '2026-09-30', NewValue: '2026-10-29',
        CreatedDate: '2026-08-05T00:00:00Z', CreatedBy: { Name: 'Kenric Tran' } },
    ]);

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api, {
      coreHistoryFields: ['StageName', 'Launch_Date__c'],
    }));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries.map(e => e.field)).toEqual(['StageName', 'Launch_Date__c']);
  });

  it('keeps lifecycle sentinels even when they are not in the core list', async () => {
    // __created__ is a record-lifecycle event, not a field a tenant opts into.
    queryAllMock.mockResolvedValue([
      { Id: 'h-stage', Field: 'StageName', OldValue: 'Prospecting', NewValue: 'Negotiation',
        CreatedDate: '2026-08-05T00:00:01Z', CreatedBy: { Name: 'Kenric Tran' } },
      { Id: 'h-created', Field: '__created__', OldValue: null, NewValue: null,
        CreatedDate: '2026-08-01T00:00:00Z', CreatedBy: { Name: 'Kenric Tran' } },
    ]);

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api, {
      coreHistoryFields: ['StageName'],
    }));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries.map(e => e.field)).toEqual(['StageName', '__created__']);
  });

  it('fetches history wider than the page so non-core rows cannot starve core ones', async () => {
    // 20 stage advances, each with two flow-derived companions: 60 rows, of which
    // only 20 are core. A fetch bounded at FEED_PAGE_SIZE would see 10 advances.
    const rows = [];
    for (let i = 0; i < 20; i += 1) {
      const at = `2026-08-${String(20 - i).padStart(2, '0')}T00:00:00Z`;
      rows.push({ Id: `stage-${i}`, Field: 'StageName', OldValue: 'a', NewValue: 'b',
        CreatedDate: at, CreatedBy: { Name: 'Kenric Tran' } });
      rows.push({ Id: `prob-${i}`, Field: 'Probability', OldValue: '10', NewValue: '20',
        CreatedDate: at, CreatedBy: { Name: 'Kenric Tran' } });
      rows.push({ Id: `fc-${i}`, Field: 'ForecastCategory', OldValue: 'Pipeline', NewValue: 'BestCase',
        CreatedDate: at, CreatedBy: { Name: 'Kenric Tran' } });
    }
    // Honour the LIMIT the way the server does, so a too-narrow fetch really
    // truncates instead of the mock handing back everything regardless.
    queryAllMock.mockImplementation(async (soql: string) => {
      const limit = Number(/LIMIT (\d+)/.exec(soql)?.[1] ?? rows.length);
      return rows.slice(0, limit);
    });

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api, {
      coreHistoryFields: ['StageName'],
    }));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries).toHaveLength(20);
    expect(result.current.entries.every(e => e.field === 'StageName')).toBe(true);
  });

  it('skips the _System_Comment posts query for external-identity sessions', async () => {
    const { result } = renderHook(
      () => useRecordActivityFeed(OPP_ID, 'Opportunity', api, { postsEnabled: false }),
    );
    await waitFor(() => expect(result.current.loading).toBe(false));

    // Task and Event are still queried; _System_Comment must not be.
    const queriedObjects = queryMock.mock.calls.map(([request]) => request.objectApiName);
    expect(queriedObjects).toContain(crmActivityTimelineSurface.activityObjects[0].objectApiName);
    expect(queriedObjects).not.toContain('_System_Comment');
  });

  it('drops a stale in-flight response so a newer record never shows the old rows', async () => {
    let releaseA: () => void = () => {};
    const aPending = new Promise<void>(resolve => { releaseA = resolve; });
    queryMock.mockImplementation(async ({ objectApiName, filterExpr }) => {
      if (objectApiName === 'Task') {
        if (filterExpr.includes("'A'")) {
          // Record A's Task read is held open until we release it below.
          await aPending;
          return [{
            Id: 'ta', Subject: 'A task', ActivityDate: '2026-07-01', CreatedDate: '2026-07-01T00:00:00Z',
          }];
        }
        return [{
          Id: 'tb', Subject: 'B task', ActivityDate: '2026-07-02', CreatedDate: '2026-07-02T00:00:00Z',
        }];
      }
      return [];
    });

    const { result, rerender } = renderHook(
      ({ id }) => useRecordActivityFeed(id, 'Opportunity', api),
      { initialProps: { id: 'A' } },
    );

    // Navigate A → B while A's fetch is still pending, then B resolves first.
    rerender({ id: 'B' });
    await waitFor(() => expect(result.current.entries.some(e => e.title === 'B task')).toBe(true));

    // Now let A's stale response land — the generation guard must drop it.
    releaseA();
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(result.current.entries.some(e => e.title === 'A task')).toBe(false);
    expect(result.current.entries.some(e => e.title === 'B task')).toBe(true);
  });

  it('excludes future-dated activities so the feed stays most-recent-first', async () => {
    // A scheduled meeting a month out must not pin above genuinely recent rows.
    const futureIso = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
    queryMock.mockImplementation(async ({ objectApiName }) => {
      if (objectApiName === 'Event') {
        return [{
          Id: 'ev-future',
          Subject: 'Scheduled QBR',
          StartDateTime: futureIso,
          CreatedDate: '2026-07-15T00:00:00Z',
        }];
      }
      if (objectApiName === 'Task') {
        return [{
          Id: 't-past',
          Subject: 'Past call',
          ActivityDate: '2026-07-01',
          Status: 'Completed',
          IsClosed: '1',
          CreatedDate: '2026-07-01T00:00:00Z',
        }];
      }
      return [];
    });

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries.some(e => e.title === 'Scheduled QBR')).toBe(false);
    expect(result.current.entries.some(e => e.title === 'Past call')).toBe(true);
  });

  it('keeps a completed activity even when its due date is in the future', async () => {
    // A closed Task has occurred; it must not be dropped just because its due
    // date is still ahead (mirrors the timeline treating closed Tasks as past).
    const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
    queryMock.mockImplementation(async ({ objectApiName }) => {
      if (objectApiName === 'Task') {
        return [{
          Id: 't-done-future',
          Subject: 'Completed early',
          ActivityDate: futureDate,
          Status: 'Completed',
          IsClosed: '1',
          CreatedDate: '2026-07-01T00:00:00Z',
        }];
      }
      return [];
    });

    const { result } = renderHook(() => useRecordActivityFeed(OPP_ID, 'Opportunity', api));
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.entries.some(e => e.title === 'Completed early')).toBe(true);
  });

  it('clears the previous record rows on navigation before the new fetch resolves', async () => {
    let releaseB: () => void = () => {};
    const bPending = new Promise<void>(resolve => { releaseB = resolve; });
    queryMock.mockImplementation(async ({ objectApiName, filterExpr }) => {
      if (objectApiName === 'Task') {
        if (filterExpr.includes("'B'")) {
          // Hold B so we can observe the state between clearing A and B loading.
          await bPending;
          return [{
            Id: 'tb', Subject: 'B task', ActivityDate: '2026-07-02', CreatedDate: '2026-07-02T00:00:00Z',
          }];
        }
        return [{
          Id: 'ta', Subject: 'A task', ActivityDate: '2026-07-01', CreatedDate: '2026-07-01T00:00:00Z',
        }];
      }
      return [];
    });

    const { result, rerender } = renderHook(
      ({ id }) => useRecordActivityFeed(id, 'Opportunity', api),
      { initialProps: { id: 'A' } },
    );
    await waitFor(() => expect(result.current.entries.some(e => e.title === 'A task')).toBe(true));

    // Navigate A → B while B's fetch is still pending: A's rows must be gone
    // synchronously (the previous record must never paint under the new one),
    // not linger until B resolves.
    rerender({ id: 'B' });
    expect(result.current.entries.some(e => e.title === 'A task')).toBe(false);
    expect(result.current.entries).toHaveLength(0);

    releaseB();
    await waitFor(() => expect(result.current.entries.some(e => e.title === 'B task')).toBe(true));
  });

  it('drops a stale response landing while the new record is still loading', async () => {
    let releaseA: () => void = () => {};
    let releaseB: () => void = () => {};
    const aPending = new Promise<void>(resolve => { releaseA = resolve; });
    const bPending = new Promise<void>(resolve => { releaseB = resolve; });
    queryMock.mockImplementation(async ({ objectApiName, filterExpr }) => {
      if (objectApiName === 'Task') {
        if (filterExpr.includes("'A'")) {
          await aPending;
          return [{
            Id: 'ta', Subject: 'A task', ActivityDate: '2026-07-01', CreatedDate: '2026-07-01T00:00:00Z',
          }];
        }
        await bPending;
        return [{
          Id: 'tb', Subject: 'B task', ActivityDate: '2026-07-02', CreatedDate: '2026-07-02T00:00:00Z',
        }];
      }
      return [];
    });

    const { result, rerender } = renderHook(
      ({ id }) => useRecordActivityFeed(id, 'Opportunity', api),
      { initialProps: { id: 'A' } },
    );

    // Navigate A → B while BOTH fetches are pending. The generation is bumped
    // synchronously on the key change, so releasing A's stale response before B
    // resolves must not restore A's rows under B (it lands invalidated).
    rerender({ id: 'B' });
    releaseA();
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(result.current.entries.some(e => e.title === 'A task')).toBe(false);
    expect(result.current.entries).toHaveLength(0);

    releaseB();
    await waitFor(() => expect(result.current.entries.some(e => e.title === 'B task')).toBe(true));
  });
});
