import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { homedir } from 'os';
import type { GmailWatcher as GmailWatcherType } from './gmail.js';
import type { OutboundMessage } from '../channels/types.js';

// Mock googleapis module
vi.mock('googleapis', () => {
  const mockWatch = vi.fn().mockResolvedValue({
    data: { historyId: '12345', expiration: String(Date.now() + 7 * 24 * 60 * 60 * 1000) },
  });

  const mockHistoryList = vi.fn().mockResolvedValue({
    data: {
      history: [
        {
          messagesAdded: [
            { message: { id: 'msg-001' } },
            { message: { id: 'msg-002' } },
          ],
        },
      ],
      historyId: '12346',
    },
  });

  const mockMessagesGet = vi.fn().mockImplementation(({ id }: { id: string }) => {
    return Promise.resolve({
      data: {
        id,
        snippet: 'Hello, this is a test email',
        labelIds: ['INBOX', 'UNREAD'],
        payload: {
          headers: [
            { name: 'From', value: 'alice@example.com' },
            { name: 'To', value: 'bob@example.com' },
            { name: 'Subject', value: 'Test Subject' },
            { name: 'Date', value: 'Sat, 07 Feb 2026 10:00:00 +0000' },
          ],
        },
      },
    });
  });

  const mockGetProfile = vi.fn().mockResolvedValue({
    data: { emailAddress: 'bob@example.com', historyId: '12344' },
  });

  const mockOAuth2 = vi.fn().mockImplementation(() => ({
    setCredentials: vi.fn(),
    on: vi.fn(),
  }));

  return {
    google: {
      auth: {
        OAuth2: mockOAuth2,
      },
      gmail: vi.fn().mockReturnValue({
        users: {
          watch: mockWatch,
          getProfile: mockGetProfile,
          history: { list: mockHistoryList },
          messages: { get: mockMessagesGet },
        },
      }),
    },
    _mocks: {
      mockWatch,
      mockHistoryList,
      mockMessagesGet,
      mockGetProfile,
      mockOAuth2,
    },
  };
});

vi.mock('@google-cloud/pubsub', () => {
  const pull = vi.fn().mockResolvedValue([{ receivedMessages: [] }]);
  const acknowledge = vi.fn().mockResolvedValue([{}]);
  const close = vi.fn().mockResolvedValue(undefined);

  class SubscriberClient {
    pull = pull;
    acknowledge = acknowledge;
    close = close;
  }

  return {
    v1: { SubscriberClient },
    _mocks: { pull, acknowledge, close },
  };
});

// Mock fs operations
vi.mock('fs', async () => {
  const actual = await vi.importActual<typeof import('fs')>('fs');
  return {
    ...actual,
    existsSync: vi.fn().mockReturnValue(true),
    readFileSync: vi.fn().mockImplementation((path: string) => {
      if (path.includes('credentials')) {
        return JSON.stringify({
          installed: {
            client_id: 'test-client-id',
            client_secret: 'test-client-secret',
            project_id: 'test-project',
            redirect_uris: ['http://localhost'],
          },
        });
      }
      // Token file
      return JSON.stringify({
        access_token: 'test-access-token',
        refresh_token: 'test-refresh-token',
        expiry_date: Date.now() + 3600000,
      });
    }),
    writeFileSync: vi.fn(),
    mkdirSync: vi.fn(),
    chmodSync: vi.fn(),
  };
});

function createMockConfig(overrides = {}) {
  return {
    enabled: true,
    credentials_file: '~/.config/flynn/gmail-credentials.json',
    token_file: '~/.config/flynn/gmail-token.json',
    disable_push: false,
    pubsub_pull_interval: '60s',
    pubsub_max_messages: 10,
    watch_labels: ['INBOX'],
    poll_interval: '300s',
    output: {
      channel: 'telegram',
      peer: '12345',
    },
    message: 'New email from {{from}}: {{subject}}\n\n{{snippet}}',
    ...overrides,
  };
}

function createMockChannelLookup() {
  const mockSend = vi.fn().mockResolvedValue(undefined);
  return {
    get: vi.fn().mockReturnValue({ send: mockSend }),
    _mockSend: mockSend,
  };
}

describe('GmailWatcher', () => {
  let GmailWatcher: typeof GmailWatcherType;
  let watcher: GmailWatcherType;
  let channelLookup: ReturnType<typeof createMockChannelLookup>;

  beforeEach(async () => {
    vi.useFakeTimers();
    channelLookup = createMockChannelLookup();

    // Import after mocks so ESM named imports (fs/googleapis) are properly mocked.
    ({ GmailWatcher } = await import('./gmail.js'));
  });

  afterEach(async () => {
    if (watcher) {
      await watcher.disconnect();
    }
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  describe('construction', () => {
    it('creates with valid config', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      expect(watcher.name).toBe('gmail');
      expect(watcher.status).toBe('disconnected');
    });
  });

  describe('push topic resolution', () => {
    it('returns null when pubsub_topic is not set', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);
      const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
      expect(topic).toBe(null);
    });

    it('expands shorthand topic id when project_id is known', () => {
      const config = createMockConfig({ pubsub_topic: 'my-topic' });
      watcher = new GmailWatcher(config, channelLookup);
      (watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project';

      const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
      expect(topic).toBe('projects/test-project/topics/my-topic');
    });

    it('rejects invalid pubsub_topic formats', () => {
      const config = createMockConfig({ pubsub_topic: 'projects/test-project/topic/my-topic' });
      watcher = new GmailWatcher(config, channelLookup);

      expect(() => {
        (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
      }).toThrow(/Invalid pubsub_topic/);
    });
  });

  describe('pull subscription resolution', () => {
    it('returns null when pubsub_subscription_id is not set', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);
      const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
      expect(sub).toBe(null);
    });

    it('expands shorthand subscription id when project_id is known', () => {
      const config = createMockConfig({ pubsub_subscription_id: 'my-sub' });
      watcher = new GmailWatcher(config, channelLookup);
      (watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project';

      const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
      expect(sub).toBe('projects/test-project/subscriptions/my-sub');
    });

    it('rejects invalid pubsub_subscription_id formats', () => {
      const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscription/my-sub' });
      watcher = new GmailWatcher(config, channelLookup);

      expect(() => {
        (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
      }).toThrow(/Invalid pubsub_subscription_id/);
    });
  });

  describe('connect() with missing credentials', () => {
    it('logs warning and sets status to error when credentials_file is missing', async () => {
      const config = createMockConfig({ credentials_file: undefined });
      watcher = new GmailWatcher(config, channelLookup);

      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

      await watcher.connect();

      expect(watcher.status).toBe('error');
      expect(errorSpy).toHaveBeenCalledWith(
        expect.stringContaining('GmailWatcher: Authorization failed'),
      );

      // The actual error includes instructions
      const calls = errorSpy.mock.calls.flat().join(' ');
      expect(calls).toContain('flynn gmail-auth');

      errorSpy.mockRestore();
    });

    it('logs warning when token file does not exist', async () => {
      const { existsSync } = await import('fs');
      const mockExistsSync = vi.mocked(existsSync);
      // credentials file exists but token file does not
      mockExistsSync.mockImplementation((path: unknown) => {
        const p = String(path);
        if (p.includes('credentials')) {return true;}
        if (p.includes('token')) {return false;}
        return true;
      });

      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
      await watcher.connect();

      expect(watcher.status).toBe('error');
      const calls = errorSpy.mock.calls.flat().join(' ');
      expect(calls).toContain('flynn gmail-auth');

      errorSpy.mockRestore();
    });
  });

  describe('push disable flag', () => {
    it('skips watch setup when disable_push is true', async () => {
      const config = createMockConfig({ disable_push: true, pubsub_topic: 'projects/test-project/topics/gmail-push' });
      watcher = new GmailWatcher(config, channelLookup);

      const { existsSync, readFileSync } = await import('fs');
      vi.mocked(existsSync).mockReturnValue(true);
      vi.mocked(readFileSync).mockImplementation((path: unknown) => {
        const p = String(path);
        if (p.includes('credentials')) {
          return JSON.stringify({
            installed: {
              client_id: 'test-client-id',
              client_secret: 'test-client-secret',
              project_id: 'test-project',
              redirect_uris: ['http://localhost'],
            },
          });
        }
        return JSON.stringify({
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expiry_date: Date.now() + 3600000,
        });
      });

      const googleapis = await import('googleapis') as unknown as {
        _mocks: {
          mockWatch: ReturnType<typeof vi.fn>;
          mockOAuth2: ReturnType<typeof vi.fn>;
        };
      };
      googleapis._mocks.mockOAuth2.mockImplementation(() => ({
        setCredentials: vi.fn(),
        on: vi.fn(),
      }));
      const watchSpy = googleapis._mocks.mockWatch;

      await watcher.connect();

      expect(watchSpy).not.toHaveBeenCalled();
      expect(watcher.status).toBe('connected');
    });
  });

  describe('pullSubscriptionMessages', () => {
    it('pulls messages and acknowledges successfully processed ones', async () => {
      const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull' });
      watcher = new GmailWatcher(config, channelLookup);

      const { _mocks: pubsubMocks } = await import('@google-cloud/pubsub') as unknown as {
        _mocks: { pull: ReturnType<typeof vi.fn>; acknowledge: ReturnType<typeof vi.fn> };
      };
      const payload = { emailAddress: 'bob@example.com', historyId: '200' };
      pubsubMocks.pull.mockResolvedValueOnce([
        {
          receivedMessages: [
            { ackId: 'ack-1', message: { data: Buffer.from(JSON.stringify(payload)) } },
          ],
        },
      ]);

      await (watcher as unknown as { pullSubscriptionMessages: () => Promise<void> }).pullSubscriptionMessages();

      expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
      expect(pubsubMocks.acknowledge).toHaveBeenCalledWith({
        subscription: 'projects/test-project/subscriptions/gmail-pull',
        ackIds: ['ack-1'],
      });
    });
  });

  describe('renderTemplate', () => {
    it('replaces all placeholders correctly', () => {
      const config = createMockConfig({
        message: 'From: {{from}} To: {{to}} Subject: {{subject}} Snippet: {{snippet}} Date: {{date}} ID: {{id}} Labels: {{labels}}',
      });
      watcher = new GmailWatcher(config, channelLookup);

      const email = {
        id: 'msg-123',
        from: 'alice@example.com',
        to: 'bob@example.com',
        subject: 'Hello!',
        snippet: 'How are you?',
        date: '2026-02-07T10:00:00Z',
        labels: ['INBOX', 'UNREAD'],
      };

      const result = watcher.renderTemplate(email);

      expect(result).toBe(
        'From: alice@example.com To: bob@example.com Subject: Hello! Snippet: How are you? Date: 2026-02-07T10:00:00Z ID: msg-123 Labels: INBOX, UNREAD',
      );
    });

    it('handles missing fields gracefully', () => {
      const config = createMockConfig({
        message: '{{from}}: {{subject}}',
      });
      watcher = new GmailWatcher(config, channelLookup);

      const email = {
        id: '',
        from: '',
        to: '',
        subject: '',
        snippet: '',
        date: '',
        labels: [],
      };

      const result = watcher.renderTemplate(email);
      expect(result).toBe(': ');
    });

    it('replaces multiple occurrences of the same placeholder', () => {
      const config = createMockConfig({
        message: '{{from}} sent: {{subject}} (from {{from}})',
      });
      watcher = new GmailWatcher(config, channelLookup);

      const email = {
        id: 'msg-1',
        from: 'alice@example.com',
        to: 'bob@example.com',
        subject: 'Hi',
        snippet: '',
        date: '',
        labels: [],
      };

      const result = watcher.renderTemplate(email);
      expect(result).toBe('alice@example.com sent: Hi (from alice@example.com)');
    });
  });

  describe('expandPath', () => {
    it('expands ~ to homedir', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const result = watcher.expandPath('~/some/path');
      expect(result).toBe(`${homedir()}/some/path`);
    });

    it('expands bare ~', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const result = watcher.expandPath('~');
      expect(result).toBe(homedir());
    });

    it('resolves absolute paths unchanged', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const result = watcher.expandPath('/tmp/test.json');
      expect(result).toBe('/tmp/test.json');
    });
  });

  describe('handlePushNotification', () => {
    it('decodes base64 data and updates historyId', async () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      // Set initial historyId via direct access
      (watcher as unknown as { lastHistoryId: string }).lastHistoryId = '100';

      const notification = { emailAddress: 'bob@example.com', historyId: '200' };
      const encoded = Buffer.from(JSON.stringify(notification)).toString('base64');

      await watcher.handlePushNotification(encoded);

      // historyId should be updated
      expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
    });

    it('ignores stale historyId', async () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      (watcher as unknown as { lastHistoryId: string }).lastHistoryId = '200';

      const notification = { emailAddress: 'bob@example.com', historyId: '100' };
      const encoded = Buffer.from(JSON.stringify(notification)).toString('base64');

      await watcher.handlePushNotification(encoded);

      // historyId should NOT change
      expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
    });

    it('handles invalid base64 gracefully', async () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

      await watcher.handlePushNotification('not-valid-json-after-decode!!!');

      // Should not throw — just log error
      expect(errorSpy).toHaveBeenCalled();
      errorSpy.mockRestore();
    });
  });

  describe('disconnect', () => {
    it('clears timers and sets status to disconnected', async () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      // Manually set status and timers
      (watcher as unknown as { _status: string })._status = 'connected';
      (watcher as unknown as { pollTimer: ReturnType<typeof setInterval> }).pollTimer = setInterval(() => {}, 60000);
      (watcher as unknown as { watchTimer: ReturnType<typeof setInterval> }).watchTimer = setInterval(() => {}, 60000);

      await watcher.disconnect();

      expect(watcher.status).toBe('disconnected');
      expect((watcher as unknown as { pollTimer: unknown }).pollTimer).toBeUndefined();
      expect((watcher as unknown as { watchTimer: unknown }).watchTimer).toBeUndefined();
    });
  });

  describe('send', () => {
    it('routes to output channel via ChannelLookup', async () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const message: OutboundMessage = { text: 'Reply text' };

      await watcher.send('some-peer', message);

      expect(channelLookup.get).toHaveBeenCalledWith('telegram');
      expect(channelLookup._mockSend).toHaveBeenCalledWith('12345', message);
    });

    it('warns when output channel not found', async () => {
      const config = createMockConfig();
      const emptyLookup = { get: vi.fn().mockReturnValue(undefined) };
      watcher = new GmailWatcher(config, emptyLookup);

      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

      await watcher.send('some-peer', { text: 'test' });

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("Output channel 'telegram' not found"),
      );

      warnSpy.mockRestore();
    });
  });

  describe('onMessage', () => {
    it('registers message handler', () => {
      const config = createMockConfig();
      watcher = new GmailWatcher(config, channelLookup);

      const handler = vi.fn();
      watcher.onMessage(handler);

      // Verify handler is stored (indirectly, via processHistoryChanges)
      expect((watcher as unknown as { messageHandler: unknown }).messageHandler).toBe(handler);
    });
  });
});
