import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadSlackFiles, type SlackFile } from '@/channels/slack/files';

const BOT_TOKEN = 'xoxb-fake';

function makeFile(overrides: Partial<SlackFile> = {}): SlackFile {
  return {
    id: 'F001',
    name: 'screenshot.png',
    mimetype: 'image/png',
    size: 1024,
    url_private_download: 'https://files.slack.com/download/screenshot.png',
    ...overrides,
  };
}

describe('downloadSlackFiles', () => {
  let workspaceDir: string;
  const correlationId = 'corr-test-123';

  beforeEach(() => {
    workspaceDir = join(tmpdir(), `zeno-test-${randomUUID()}`);
  });

  afterEach(() => {
    vi.restoreAllMocks();
    if (existsSync(workspaceDir)) {
      rmSync(workspaceDir, { recursive: true, force: true });
    }
  });

  it('downloads files and returns attachments with correct metadata', async () => {
    const content = Buffer.from('fake-png-content');
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(content, { status: 200 }));

    const files = [makeFile()];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(1);
    expect(result[0]).toEqual({
      name: 'screenshot.png',
      mimetype: 'image/png',
      localPath: join(workspaceDir, 'uploads', correlationId, 'F001-screenshot.png'),
      sizeBytes: content.length,
    });

    const saved = readFileSync(result[0].localPath);
    expect(saved.toString()).toBe('fake-png-content');
  });

  it('downloads multiple files', async () => {
    vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
      Promise.resolve(new Response(Buffer.from('data'), { status: 200 })),
    );

    const files = [
      makeFile({ id: 'F001', name: 'a.png' }),
      makeFile({ id: 'F002', name: 'b.pdf', mimetype: 'application/pdf' }),
    ];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(2);
    expect(result[0].name).toBe('a.png');
    expect(result[1].name).toBe('b.pdf');
  });

  it('skips files with no download URL', async () => {
    const fetchSpy = vi.spyOn(globalThis, 'fetch');

    const files = [makeFile({ url_private_download: undefined, url_private: undefined })];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(0);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('falls back to url_private when url_private_download is absent', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValue(new Response(Buffer.from('ok'), { status: 200 }));

    const files = [
      makeFile({
        url_private_download: undefined,
        url_private: 'https://files.slack.com/private/file.png',
      }),
    ];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(1);
    expect(fetchSpy).toHaveBeenCalledWith(
      'https://files.slack.com/private/file.png',
      expect.objectContaining({ headers: { Authorization: `Bearer ${BOT_TOKEN}` } }),
    );
  });

  it('skips files exceeding the 50 MB limit', async () => {
    const fetchSpy = vi.spyOn(globalThis, 'fetch');

    const largeFile = makeFile({ size: 51 * 1024 * 1024 });
    const result = await downloadSlackFiles([largeFile], BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(0);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('skips files when fetch returns non-OK status', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 403 }));

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);
    expect(result).toHaveLength(0);
  });

  it('skips files when fetch throws and continues with remaining files', async () => {
    const fetchSpy = vi.spyOn(globalThis, 'fetch');
    fetchSpy.mockRejectedValueOnce(new Error('network error'));
    fetchSpy.mockResolvedValueOnce(new Response(Buffer.from('ok'), { status: 200 }));

    const files = [
      makeFile({ id: 'F001', name: 'fail.png' }),
      makeFile({ id: 'F002', name: 'ok.png' }),
    ];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(1);
    expect(result[0].name).toBe('ok.png');
  });

  it('sends the bot token as Authorization header', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValue(new Response(Buffer.from('ok'), { status: 200 }));

    await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);

    expect(fetchSpy).toHaveBeenCalledWith(expect.any(String), {
      headers: { Authorization: 'Bearer xoxb-fake' },
    });
  });

  it('skips files when Slack returns HTML for an image (login redirect / scope missing)', async () => {
    // Realistic failure: Slack returns 200 OK with the login page HTML body
    // when the bot token lacks the `files:read` scope. Without a guard we'd
    // persist the HTML as `image.png` and surface a downstream Anthropic 400.
    const htmlBody = '<!DOCTYPE html><html lang="en-US"><head></head><body></body></html>';
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(htmlBody, {
        status: 200,
        headers: { 'content-type': 'text/html; charset=utf-8' },
      }),
    );

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(0);
  });

  it('accepts content-type matching the file mimetype exactly', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
        status: 200,
        headers: { 'content-type': 'image/png' },
      }),
    );

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);
    expect(result).toHaveLength(1);
  });

  it('accepts content-type matching the file mimetype primary type (image/jpeg vs image/png)', async () => {
    // Slack occasionally serves a JPEG when the declared mimetype is PNG; we
    // care that it's some image, not the exact subtype.
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(Buffer.from([0xff, 0xd8, 0xff]), {
        status: 200,
        headers: { 'content-type': 'image/jpeg' },
      }),
    );

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);
    expect(result).toHaveLength(1);
  });

  it('accepts application/octet-stream regardless of declared mimetype', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(Buffer.from('binary'), {
        status: 200,
        headers: { 'content-type': 'application/octet-stream' },
      }),
    );

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);
    expect(result).toHaveLength(1);
  });

  it('does not collide when multiple files share the same name (Slack clipboard pastes)', async () => {
    // Slack auto-names clipboard pastes "image.png". 5 such pastes all hit
    // the same localPath under the old code; writeFile silently overwrote.
    // With the file.id prefix each gets a unique on-disk path while the
    // exposed Attachment.name stays as the human-visible original.
    const fetchSpy = vi.spyOn(globalThis, 'fetch');
    fetchSpy
      .mockResolvedValueOnce(new Response(Buffer.from('one'), { status: 200 }))
      .mockResolvedValueOnce(new Response(Buffer.from('two'), { status: 200 }))
      .mockResolvedValueOnce(new Response(Buffer.from('three'), { status: 200 }));

    const files = [
      makeFile({ id: 'F001', name: 'image.png' }),
      makeFile({ id: 'F002', name: 'image.png' }),
      makeFile({ id: 'F003', name: 'image.png' }),
    ];
    const result = await downloadSlackFiles(files, BOT_TOKEN, correlationId, workspaceDir);

    expect(result).toHaveLength(3);

    // All Attachment.name fields preserve the operator-visible name.
    expect(result.map((a) => a.name)).toEqual(['image.png', 'image.png', 'image.png']);

    // Each on-disk path is unique (salted by file.id) and the file contents
    // survive intact — no overwrite collision.
    const paths = result.map((a) => a.localPath);
    expect(new Set(paths).size).toBe(3);
    expect(readFileSync(paths[0]).toString()).toBe('one');
    expect(readFileSync(paths[1]).toString()).toBe('two');
    expect(readFileSync(paths[2]).toString()).toBe('three');
  });

  it('accepts response with no content-type header (some CDN edges omit it)', async () => {
    // Response constructed with Buffer body and no headers init → no content-type set.
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(Buffer.from('data'), { status: 200 }),
    );

    const result = await downloadSlackFiles([makeFile()], BOT_TOKEN, correlationId, workspaceDir);
    expect(result).toHaveLength(1);
  });
});
