import { createHmac } from 'crypto';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';

import { LineAdapter } from './adapter.js';

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

function mockReq(body: string, secret: string): IncomingMessage {
  const signature = createHmac('sha256', secret).update(body).digest('base64');
  const req = {
    headers: { 'x-line-signature': signature },
    on(event: string, handler: (...args: unknown[]) => void) {
      if (event === 'data') {
        handler(Buffer.from(body, 'utf8'));
      }
      if (event === 'end') {
        handler();
      }
      return this;
    },
    off: () => req,
    destroy: () => undefined,
  } as unknown as IncomingMessage;
  return req;
}

function mockRes() {
  const state = {
    statusCode: 0,
    body: '',
  };
  const res = {
    writeHead: (code: number) => {
      state.statusCode = code;
    },
    end: (chunk?: string) => {
      state.body = chunk ?? '';
    },
  } as unknown as ServerResponse;
  return { res, state };
}

describe('LineAdapter', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockFetch.mockReset();
  });

  it('has name line and starts disconnected', () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
    });
    expect(adapter.name).toBe('line');
    expect(adapter.status).toBe('disconnected');
  });

  it('send posts LINE push API', async () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
    });
    await adapter.connect();
    mockFetch.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => '',
    } as Response);

    await adapter.send('U123', { text: 'hello line' });
    expect(mockFetch).toHaveBeenCalledTimes(1);
    expect(mockFetch.mock.calls[0]?.[0]).toBe('https://api.line.me/v2/bot/message/push');
  });

  it('send emits URL attachments and warns for binary attachments', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
    });
    await adapter.connect();
    mockFetch.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => '',
    } as Response);

    await adapter.send('U123', {
      text: 'hello line',
      attachments: [
        { mimeType: 'text/plain', url: 'https://example.com/file.txt', filename: 'file.txt' },
        { mimeType: 'image/png', data: 'aGVsbG8=' },
      ],
    });

    expect(mockFetch).toHaveBeenCalledTimes(3);
    const secondBody = JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body ?? '{}'));
    const thirdBody = JSON.parse(String(mockFetch.mock.calls[2]?.[1]?.body ?? '{}'));
    expect(secondBody.messages?.[0]?.text).toBe('file.txt: https://example.com/file.txt');
    expect(thirdBody.messages?.[0]?.text).toBe('[LINE] Binary attachment not uploaded yet: attachment (image/png).');
    expect(warnSpy).toHaveBeenCalledWith('LINE: skipping attachment data (image/png) — upload not implemented');
    warnSpy.mockRestore();
  });

  it('uploads binary attachments to MinIO and sends share URL when configured', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
      minio: {
        enabled: true,
        endpoint: 'localhost:9000',
        accessKey: 'minio',
        secretKey: 'secret',
        bucket: 'flynn',
        prefix: 'channels/line',
        secure: false,
      },
      minioExecRunner: vi.fn(async (_file, args) => {
        if (args[0] === 'share') {
          return { stdout: '{"share":"https://minio.local/share/file.png"}\n', stderr: '' };
        }
        return { stdout: '', stderr: '' };
      }),
    });
    await adapter.connect();
    mockFetch.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => '',
    } as Response);

    await adapter.send('U123', {
      text: '',
      attachments: [
        { mimeType: 'image/png', data: 'aGVsbG8=', filename: 'file.png' },
      ],
    });

    expect(mockFetch).toHaveBeenCalledTimes(1);
    const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}'));
    expect(body.messages?.[0]?.text).toBe('file.png: https://minio.local/share/file.png');
    expect(warnSpy).not.toHaveBeenCalled();
    warnSpy.mockRestore();
  });

  it('falls back to warning notice when MinIO upload fails', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
      minio: {
        enabled: true,
        endpoint: 'localhost:9000',
        accessKey: 'minio',
        secretKey: 'secret',
        bucket: 'flynn',
        prefix: 'channels/line',
        secure: false,
      },
      minioExecRunner: vi.fn(async () => {
        throw new Error('mc unavailable');
      }),
    });
    await adapter.connect();
    mockFetch.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => '',
    } as Response);

    await adapter.send('U123', {
      text: '',
      attachments: [{ mimeType: 'image/png', data: 'aGVsbG8=', filename: 'file.png' }],
    });

    expect(mockFetch).toHaveBeenCalledTimes(1);
    const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}'));
    expect(body.messages?.[0]?.text).toBe('[LINE] Binary attachment not uploaded yet: file.png (image/png).');
    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('LINE: MinIO upload failed'));
    expect(warnSpy).toHaveBeenCalledWith('LINE: skipping attachment data (image/png) — upload not implemented');
    warnSpy.mockRestore();
  });

  it('send delivers URL attachment even when text is empty', async () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
    });
    await adapter.connect();
    mockFetch.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => '',
    } as Response);

    await adapter.send('U123', {
      text: '   ',
      attachments: [{ mimeType: 'text/plain', url: 'https://example.com/file.txt', filename: 'file.txt' }],
    });

    expect(mockFetch).toHaveBeenCalledTimes(1);
    const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}'));
    expect(body.messages?.[0]?.text).toBe('file.txt: https://example.com/file.txt');
  });

  it('handleRequest validates signature and dispatches text event', async () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
      requireMention: false,
    });
    const inbound: Array<{ channel: string; text: string; senderId: string }> = [];
    adapter.onMessage((msg) => inbound.push({ channel: msg.channel, text: msg.text, senderId: msg.senderId }));

    const body = JSON.stringify({
      events: [{
        type: 'message',
        timestamp: 1,
        source: { type: 'user', userId: 'U123' },
        message: { id: 'm1', type: 'text', text: 'ping' },
      }],
    });
    const req = mockReq(body, 'secret');
    const { res, state } = mockRes();

    await adapter.handleRequest(req, res);
    expect(state.statusCode).toBe(200);
    expect(inbound).toEqual([{ channel: 'line', text: 'ping', senderId: 'U123' }]);
  });

  it('drops group messages without mention when require_mention=true', async () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
      requireMention: true,
      mentionName: 'flynn',
    });
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.handleEvent({
      type: 'message',
      source: { type: 'group', groupId: 'G123', userId: 'U123' },
      message: { id: 'm1', type: 'text', text: 'hello there' },
    });

    expect(handler).not.toHaveBeenCalled();
  });

  it('rejects invalid signature', async () => {
    const adapter = new LineAdapter({
      channelAccessToken: 'token',
      channelSecret: 'secret',
    });
    const body = JSON.stringify({ events: [] });
    const req = {
      headers: { 'x-line-signature': 'invalid' },
      on(event: string, handler: (...args: unknown[]) => void) {
        if (event === 'data') {
          handler(Buffer.from(body, 'utf8'));
        }
        if (event === 'end') {
          handler();
        }
        return this;
      },
      off: () => req,
      destroy: () => undefined,
    } as unknown as IncomingMessage;
    const { res, state } = mockRes();

    await adapter.handleRequest(req, res);
    expect(state.statusCode).toBe(401);
  });
});
