import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js';
import { PUBLIC_ORIGIN_ENV, createWriteOriginGuard } from '../../src/server/middleware.js';

// The guard admits GITNEXUS_PUBLIC_ORIGIN as well as loopback, and the
// rejection cases below assume none is configured. Clear the developer's
// ambient value for the file rather than inheriting it.
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
beforeAll(() => {
  delete process.env[PUBLIC_ORIGIN_ENV];
});
afterAll(() => {
  if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
});

const BOUNDARY = '----gitnexusuploadtest';

function multipart(
  parts: Array<{ name: string; value?: string; filename?: string; data?: Buffer }>,
): { body: Buffer; headers: Record<string, string> } {
  const chunks: Buffer[] = [];
  for (const p of parts) {
    chunks.push(Buffer.from(`--${BOUNDARY}\r\n`));
    if (p.filename !== undefined) {
      chunks.push(
        Buffer.from(
          `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n` +
            `Content-Type: application/octet-stream\r\n\r\n`,
        ),
      );
      chunks.push(p.data ?? Buffer.alloc(0));
      chunks.push(Buffer.from('\r\n'));
    } else {
      chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`));
      chunks.push(Buffer.from(p.value ?? ''));
      chunks.push(Buffer.from('\r\n'));
    }
  }
  chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));
  return {
    body: Buffer.concat(chunks),
    headers: { 'content-type': `multipart/form-data; boundary=${BOUNDARY}` },
  };
}

function mockReq(parts: Parameters<typeof multipart>[0]): IncomingMessage {
  const { body, headers } = multipart(parts);
  const r = Readable.from([body]) as unknown as IncomingMessage & { headers: typeof headers };
  r.headers = headers;
  return r;
}

interface MockRes {
  statusCode: number;
  body: unknown;
  status: (c: number) => MockRes;
  json: (b: unknown) => MockRes;
}
function mockRes(): MockRes {
  const res = { statusCode: 0, body: undefined as unknown } as MockRes;
  res.status = (c: number) => {
    res.statusCode = c;
    return res;
  };
  res.json = (b: unknown) => {
    res.body = b;
    return res;
  };
  return res;
}

// Track promoted upload dirs created by the real ingest+promote so we clean up.
const promoted: string[] = [];
afterEach(async () => {
  while (promoted.length) {
    await fs.rm(promoted.pop()!, { recursive: true, force: true }).catch(() => {});
  }
});

function uniqueTop(): string {
  return `uptest_${Math.floor(Math.random() * 1e9).toString(36)}`;
}

describe('createAnalyzeUploadHandler', () => {
  it('ingests, promotes the inner folder, and launches analysis (202)', async () => {
    const top = uniqueTop();
    const createJob = vi.fn(() => ({ id: 'job-1', status: 'queued' }));
    const launch = vi.fn((_j, dir: string) => promoted.push(dir));
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify([`${top}/a.js`, `${top}/sub/b.js`]) },
        { name: 'files', filename: 'blob', data: Buffer.from('alpha') },
        { name: 'files', filename: 'blob', data: Buffer.from('beta') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(202);
    expect((res.body as { jobId: string }).jobId).toBe('job-1');
    expect(createJob).toHaveBeenCalledOnce();
    expect(launch).toHaveBeenCalledOnce();
    const dir = launch.mock.calls[0][1] as string;
    const opts = launch.mock.calls[0][2] as { registryName: string };
    // Inner folder promoted: contents live directly under the upload dir.
    expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('alpha');
    expect(await fs.readFile(path.join(dir, 'sub', 'b.js'), 'utf8')).toBe('beta');
    expect(opts.registryName).toBe(top);
    expect(createJob.mock.calls[0][0].repoPath).toBe(dir);
  });

  it('maps a busy job (createJob throws "already in progress") to 409 and promotes nothing', async () => {
    const top = uniqueTop();
    const createJob = vi.fn(() => {
      throw new Error('Analysis already in progress for another repository');
    });
    const launch = vi.fn((_j, dir: string) => promoted.push(dir));
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify([`${top}/a.js`]) },
        { name: 'files', filename: 'blob', data: Buffer.from('x') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(409);
    expect(launch).not.toHaveBeenCalled();
    // Nothing promoted onto disk.
    const { UPLOAD_ROOT } = await import('../../src/server/upload-paths.js');
    await expect(fs.access(path.join(UPLOAD_ROOT, top))).rejects.toBeTruthy();
  });

  it('rejects a traversal path in the manifest (400) without launching', async () => {
    const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
    const launch = vi.fn();
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify(['../escape.js']) },
        { name: 'files', filename: 'blob', data: Buffer.from('x') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(400);
    expect(createJob).not.toHaveBeenCalled();
    expect(launch).not.toHaveBeenCalled();
  });

  it('rejects an un-nameable top folder (Windows-reserved → 400)', async () => {
    const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
    const launch = vi.fn();
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify(['CON/a.js']) },
        { name: 'files', filename: 'blob', data: Buffer.from('x') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(400);
    expect(launch).not.toHaveBeenCalled();
  });

  it('strips a crafted .gitnexus index from the promoted upload', async () => {
    const top = uniqueTop();
    const createJob = vi.fn(() => ({ id: 'job-x', status: 'queued' }));
    const launch = vi.fn((_j, dir: string) => promoted.push(dir));
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify([`${top}/.gitnexus/meta.json`, `${top}/a.js`]) },
        { name: 'files', filename: 'blob', data: Buffer.from('{"evil":true}') },
        { name: 'files', filename: 'blob', data: Buffer.from('real') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(202);
    const dir = launch.mock.calls[0][1] as string;
    await expect(fs.access(path.join(dir, '.gitnexus'))).rejects.toBeTruthy();
    expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('real');
  });

  it('rejects a single-segment manifest before creating a job (no slot taken)', async () => {
    const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
    const launch = vi.fn();
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify(['loosefile.js']) },
        { name: 'files', filename: 'blob', data: Buffer.from('x') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(400);
    expect(createJob).not.toHaveBeenCalled(); // slot never taken → no wedge
    expect(launch).not.toHaveBeenCalled();
  });

  it('rejects a multi-top-folder manifest (would silently drop folders)', async () => {
    const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
    const launch = vi.fn();
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify(['aaa/x.js', 'bbb/y.js']) },
        { name: 'files', filename: 'blob', data: Buffer.from('1') },
        { name: 'files', filename: 'blob', data: Buffer.from('2') },
      ]) as never,
      res as never,
    );

    expect(res.statusCode).toBe(400);
    expect(createJob).not.toHaveBeenCalled();
  });

  it('releases the single slot (failJob) when a step fails after createJob', async () => {
    const top = uniqueTop();
    const createJob = vi.fn(() => ({ id: 'job-fail', status: 'queued' }));
    // launch throws AFTER createJob + promote — the slot must be released.
    const launch = vi.fn((_j, dir: string) => {
      promoted.push(dir);
      throw new Error('worker fork blew up');
    });
    const failJob = vi.fn();
    const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });

    const res = mockRes();
    await handler(
      mockReq([
        { name: 'manifest', value: JSON.stringify([`${top}/a.js`]) },
        { name: 'files', filename: 'blob', data: Buffer.from('x') },
      ]) as never,
      res as never,
    );

    expect(createJob).toHaveBeenCalledOnce();
    expect(failJob).toHaveBeenCalledWith('job-fail', expect.any(String));
    expect(res.statusCode).toBe(500);
  });
});

describe('createWriteOriginGuard (no bound host)', () => {
  function call(origin: string | undefined): { passed: boolean; status: number } {
    let passed = false;
    let status = 0;
    const req = { headers: origin === undefined ? {} : { origin } } as never;
    const res = {
      status: (c: number) => {
        status = c;
        return { json: () => {} };
      },
    } as never;
    createWriteOriginGuard()(req, res, () => {
      passed = true;
    });
    return { passed, status };
  }

  it('passes localhost / 127.0.0.1 / [::1] / no-origin', () => {
    expect(call('http://localhost:5173').passed).toBe(true);
    expect(call('http://127.0.0.1:4747').passed).toBe(true);
    expect(call('http://[::1]:4747').passed).toBe(true);
    expect(call(undefined).passed).toBe(true);
  });

  it('rejects a public/cross origin with 403', () => {
    const r = call('https://gitnexus.vercel.app');
    expect(r.passed).toBe(false);
    expect(r.status).toBe(403);
  });

  it('rejects RFC1918 origins when no boundHost is set', () => {
    expect(call('http://10.0.0.1:4173').passed).toBe(false);
    expect(call('http://172.16.1.21:4173').passed).toBe(false);
    expect(call('http://192.168.1.100:4173').passed).toBe(false);
  });

  it('rejects malformed and non-private hostnames with 403', () => {
    expect(call('http://my-local-server.local:4173').passed).toBe(false);
    expect(call('ftp://localhost:4173').passed).toBe(false);
    expect(call('null').passed).toBe(false);
  });
});

describe('createWriteOriginGuard (bound host)', () => {
  function callWith(
    boundHost: string,
    origin: string | undefined,
  ): { passed: boolean; status: number; body?: { error?: string; code?: string } } {
    const guard = createWriteOriginGuard(boundHost);
    let passed = false;
    let status = 0;
    let body: { error?: string; code?: string } | undefined;
    const req = { headers: origin === undefined ? {} : { origin } } as never;
    const res = {
      status: (c: number) => {
        status = c;
        return {
          json: (b: { error?: string; code?: string }) => {
            body = b;
          },
        };
      },
    } as never;
    guard(req, res, () => {
      passed = true;
    });
    return { passed, status, body };
  }

  it('allows origin matching the bound host', () => {
    expect(callWith('192.168.1.100', 'http://192.168.1.100:4747').passed).toBe(true);
    expect(callWith('10.0.0.5', 'http://10.0.0.5:4173').passed).toBe(true);
    expect(callWith('172.16.1.21', 'http://172.16.1.21:4173').passed).toBe(true);
  });

  it('still allows loopback regardless of bound host', () => {
    expect(callWith('192.168.1.100', 'http://localhost:5173').passed).toBe(true);
    expect(callWith('192.168.1.100', 'http://127.0.0.1:4747').passed).toBe(true);
    expect(callWith('192.168.1.100', 'http://[::1]:4747').passed).toBe(true);
  });

  it('normalizes mixed-case host binds to match the WHATWG origin hostname', () => {
    // WHATWG lowercases the Origin hostname; boundHost must canonicalize the same way.
    expect(callWith('MyHost.local', 'http://myhost.local:4747').passed).toBe(true);
  });

  it('normalizes IPv6 host binds (compressed + non-canonical) to match the origin', () => {
    expect(callWith('fe80::1', 'http://[fe80::1]:4747').passed).toBe(true);
    // Expanded form must compress to the same WHATWG hostname as the origin.
    expect(callWith('fe80:0:0:0:0:0:0:1', 'http://[fe80::1]:4747').passed).toBe(true);
    // Already-bracketed input is idempotent.
    expect(callWith('[fe80::1]', 'http://[fe80::1]:4747').passed).toBe(true);
  });

  it('keeps wildcard binds (0.0.0.0 / :: / expanded) loopback-only', () => {
    // No browser Origin equals a wildcard, so non-loopback writes are rejected...
    expect(callWith('0.0.0.0', 'http://192.168.1.5:4747').passed).toBe(false);
    expect(callWith('::', 'http://[fe80::1]:4747').passed).toBe(false);
    expect(callWith('0:0:0:0:0:0:0:0', 'http://[fe80::1]:4747').passed).toBe(false);
    // ...while loopback still passes under a wildcard bind.
    expect(callWith('0.0.0.0', 'http://localhost:5173').passed).toBe(true);
    expect(callWith('::', 'http://127.0.0.1:4747').passed).toBe(true);
  });

  it('rejects other RFC1918 origins that do not match bound host', () => {
    expect(callWith('192.168.1.100', 'http://192.168.1.101:4747').passed).toBe(false);
    expect(callWith('192.168.1.100', 'http://10.0.0.1:4747').passed).toBe(false);
    expect(callWith('10.0.0.5', 'http://172.16.1.21:4747').passed).toBe(false);
  });

  it('rejects public origins even when bound to LAN', () => {
    const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app');
    expect(r.passed).toBe(false);
    expect(r.status).toBe(403);
  });

  it('tags the rejection 403 with a machine-readable code', () => {
    const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app');
    expect(r.status).toBe(403);
    expect(r.body?.code).toBe('origin_not_allowed');
  });

  it('passes no-origin (non-browser) requests', () => {
    expect(callWith('192.168.1.100', undefined).passed).toBe(true);
  });
});
