import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { S3StorageProvider } from '../../src/providers/storage/s3.provider.ts';
import { CopyObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import crypto from 'crypto';

vi.mock('@/infra/config/app.config.js', () => {
  const c = {
    cloud: {
      get cloudFrontUrl() {
        return process.env.AWS_CLOUDFRONT_URL;
      },
      get cloudFrontKeyPairId() {
        return process.env.AWS_CLOUDFRONT_KEY_PAIR_ID;
      },
      get cloudFrontPrivateKey() {
        return process.env.AWS_CLOUDFRONT_PRIVATE_KEY;
      },
    },
    storage: {
      get s3EndpointUrl() {
        return process.env.S3_ENDPOINT_URL;
      },
    },
    server: {
      logsDir: 'logs',
    },
    app: {
      logLevel: 'info',
    },
  };
  return { config: c, appConfig: c };
});

function asyncIterableFromString(s: string): AsyncIterable<Uint8Array> {
  return {
    [Symbol.asyncIterator]: async function* () {
      yield Buffer.from(s);
    },
  };
}

function notFoundError(name = 'NoSuchKey') {
  return Object.assign(new Error(name), { name, $metadata: { httpStatusCode: 404 } });
}

describe('S3StorageProvider — branch fallback', () => {
  let sendMock: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    sendMock = vi.fn();
    vi.spyOn(S3Client.prototype, 'send').mockImplementation(
      sendMock as unknown as typeof S3Client.prototype.send
    );
  });

  function makeProvider(parentAppKey?: string): S3StorageProvider {
    const p = new S3StorageProvider('bucket', 'branchkey', 'us-east-2', parentAppKey);
    // Inject a real client without going through env-driven initialize().
    // Dummy credentials so getSignedUrl can sign locally without hitting the
    // SDK credential provider chain (which fails on CI runners with no creds).
    (p as unknown as { s3Client: S3Client }).s3Client = new S3Client({
      region: 'us-east-2',
      credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
    });
    return p;
  }

  describe('getObject', () => {
    it('returns branch object on first hit (no parent call)', async () => {
      sendMock.mockResolvedValueOnce({ Body: asyncIterableFromString('hello') });
      const p = makeProvider('parentkey');
      const out = await p.getObject('photos', 'a.txt');
      expect(out?.toString()).toBe('hello');
      expect(sendMock).toHaveBeenCalledTimes(1);
      const cmd = sendMock.mock.calls[0][0] as GetObjectCommand;
      expect(cmd.input.Key).toBe('branchkey/photos/a.txt');
    });

    it('falls back to parent on NoSuchKey', async () => {
      sendMock
        .mockRejectedValueOnce(notFoundError('NoSuchKey'))
        .mockResolvedValueOnce({ Body: asyncIterableFromString('parent-data') });
      const p = makeProvider('parentkey');
      const out = await p.getObject('photos', 'a.txt');
      expect(out?.toString()).toBe('parent-data');
      const k1 = (sendMock.mock.calls[0][0] as GetObjectCommand).input.Key;
      const k2 = (sendMock.mock.calls[1][0] as GetObjectCommand).input.Key;
      expect(k1).toBe('branchkey/photos/a.txt');
      expect(k2).toBe('parentkey/photos/a.txt');
    });

    it('returns null when both branch and parent miss', async () => {
      sendMock.mockRejectedValueOnce(notFoundError()).mockRejectedValueOnce(notFoundError());
      const p = makeProvider('parentkey');
      expect(await p.getObject('photos', 'a.txt')).toBeNull();
    });

    it('does NOT fall back when no parent configured', async () => {
      sendMock.mockRejectedValueOnce(notFoundError());
      const p = makeProvider(); // no parentAppKey
      expect(await p.getObject('photos', 'a.txt')).toBeNull();
      expect(sendMock).toHaveBeenCalledTimes(1);
    });

    it('returns null on non-404 error WITHOUT calling parent', async () => {
      // Transient/IAM/etc errors must not silently masquerade as parent reads.
      sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' }));
      const p = makeProvider('parentkey');
      expect(await p.getObject('photos', 'a.txt')).toBeNull();
      expect(sendMock).toHaveBeenCalledTimes(1);
    });
  });

  describe('headObject', () => {
    it('returns branch metadata on first hit', async () => {
      sendMock.mockResolvedValueOnce({
        ContentLength: 42,
        ETag: '"abc"',
        ContentType: 'text/plain',
        LastModified: new Date('2026-04-29'),
      });
      const p = makeProvider('parentkey');
      const meta = await p.headObject('photos', 'a.txt');
      expect(meta?.size).toBe(42);
      expect(sendMock).toHaveBeenCalledTimes(1);
    });

    it('falls back to parent on NotFound', async () => {
      sendMock
        .mockRejectedValueOnce(notFoundError('NotFound'))
        .mockResolvedValueOnce({ ContentLength: 7, ETag: '"p"', LastModified: new Date() });
      const p = makeProvider('parentkey');
      const meta = await p.headObject('photos', 'a.txt');
      expect(meta?.size).toBe(7);
      expect(sendMock).toHaveBeenCalledTimes(2);
    });

    it('rethrows non-404 errors instead of falling back', async () => {
      sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' }));
      const p = makeProvider('parentkey');
      await expect(p.headObject('photos', 'a.txt')).rejects.toThrow();
      expect(sendMock).toHaveBeenCalledTimes(1);
    });
  });

  describe('getObjectStream', () => {
    function fakeStreamResponse(body: string) {
      return {
        Body: Readable.from([Buffer.from(body)]),
        ContentLength: body.length,
        ETag: '"x"',
        ContentType: 'text/plain',
        LastModified: new Date(),
      };
    }

    it('streams from parent when branch is 404', async () => {
      sendMock
        .mockRejectedValueOnce(notFoundError())
        .mockResolvedValueOnce(fakeStreamResponse('parent-stream'));
      const p = makeProvider('parentkey');
      const out = await p.getObjectStream('photos', 'a.txt');
      const chunks: Buffer[] = [];
      for await (const c of out.body) chunks.push(c as Buffer);
      expect(Buffer.concat(chunks).toString()).toBe('parent-stream');
    });

    it('throws when both branch and parent miss', async () => {
      sendMock.mockRejectedValueOnce(notFoundError()).mockRejectedValueOnce(notFoundError());
      const p = makeProvider('parentkey');
      await expect(p.getObjectStream('photos', 'a.txt')).rejects.toThrow();
    });
  });

  describe('getDownloadStrategy presigned URL', () => {
    beforeEach(() => {
      delete process.env.AWS_CLOUDFRONT_URL;
    });

    it('signs branch key when branch HEAD succeeds', async () => {
      sendMock.mockResolvedValueOnce({ ContentLength: 5, LastModified: new Date(), ETag: '"x"' });
      const p = makeProvider('parentkey');
      const strategy = await p.getDownloadStrategy('photos', 'a.txt');
      expect(strategy.method).toBe('presigned');
      expect(strategy.url).toContain('branchkey/photos/a.txt');
    });

    it('signs parent key when branch HEAD returns 404', async () => {
      // First call = branch HEAD → 404.
      sendMock.mockRejectedValueOnce(notFoundError('NotFound'));
      const p = makeProvider('parentkey');
      const strategy = await p.getDownloadStrategy('photos', 'a.txt');
      expect(strategy.url).toContain('parentkey/photos/a.txt');
    });

    it('non-branch project: no HEAD round-trip, signs branch key directly', async () => {
      const p = makeProvider(); // no parentAppKey
      const strategy = await p.getDownloadStrategy('photos', 'a.txt');
      expect(strategy.url).toContain('branchkey/photos/a.txt');
      // No HEAD call should have happened.
      expect(sendMock).not.toHaveBeenCalled();
    });

    it('defaults to branch key (no throw) when HEAD fails with non-404', async () => {
      // HEAD failures (network/IAM/throttling) shouldn't break URL gen.
      sendMock.mockRejectedValueOnce(Object.assign(new Error('throttled'), { name: 'SlowDown' }));
      const p = makeProvider('parentkey');
      const strategy = await p.getDownloadStrategy('photos', 'a.txt');
      expect(strategy.url).toContain('branchkey/photos/a.txt');
    });
  });

  describe('getDownloadStrategy — CloudFront signed URL with cache-bust version', () => {
    // CloudFront canned-policy verification reconstructs `Resource` from the
    // request URL minus the three CF params (Expires/Signature/Key-Pair-Id).
    // Any *other* query — like our `?v=<etag>` — must be in the URL BEFORE
    // signing or CloudFront returns 403 SignatureDoesNotMatch on download.
    let testPrivateKey: string;
    const savedEnv: Record<string, string | undefined> = {};

    beforeAll(() => {
      const { privateKey } = crypto.generateKeyPairSync('rsa', {
        modulusLength: 2048,
        privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
        publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
      });
      testPrivateKey = privateKey;
    });

    beforeEach(() => {
      for (const k of [
        'AWS_CLOUDFRONT_URL',
        'AWS_CLOUDFRONT_KEY_PAIR_ID',
        'AWS_CLOUDFRONT_PRIVATE_KEY',
        'S3_ENDPOINT_URL',
      ]) {
        savedEnv[k] = process.env[k];
      }
      process.env.AWS_CLOUDFRONT_URL = 'https://cdn.example.test';
      process.env.AWS_CLOUDFRONT_KEY_PAIR_ID = 'K123TEST';
      process.env.AWS_CLOUDFRONT_PRIVATE_KEY = testPrivateKey;
      delete process.env.S3_ENDPOINT_URL;
    });

    afterEach(() => {
      for (const [k, v] of Object.entries(savedEnv)) {
        if (v === undefined) delete process.env[k];
        else process.env[k] = v;
      }
    });

    it('places v= before the CF signing params (i.e. v is part of the signed Resource)', async () => {
      const p = makeProvider(); // no parent — skip the HEAD round-trip
      const strategy = await p.getDownloadStrategy('photos', 'a.txt', 3600, false, 'etag-abc');

      expect(strategy.method).toBe('presigned');
      const url = new URL(strategy.url);
      expect(url.searchParams.get('v')).toBe('etag-abc');

      // Order matters: getCloudFrontSignedUrl appends Expires/Signature/Key-Pair-Id
      // *after* the URL it signs. So v= sitting before Expires= proves v was
      // present at signing time and is therefore covered by the signature.
      const params = url.search.replace(/^\?/, '').split('&');
      const vIdx = params.findIndex((p) => p.startsWith('v='));
      const expiresIdx = params.findIndex((p) => p.startsWith('Expires='));
      expect(vIdx).toBeGreaterThanOrEqual(0);
      expect(expiresIdx).toBeGreaterThanOrEqual(0);
      expect(vIdx).toBeLessThan(expiresIdx);
    });

    it('omits v= when no version is supplied', async () => {
      const p = makeProvider();
      const strategy = await p.getDownloadStrategy('photos', 'a.txt');

      const url = new URL(strategy.url);
      expect(url.searchParams.get('v')).toBeNull();
      expect(url.searchParams.get('Signature')).not.toBeNull();
    });

    it('URL-encodes version values with reserved characters', async () => {
      const p = makeProvider();
      const strategy = await p.getDownloadStrategy('photos', 'a.txt', 3600, false, 'a&b c');

      const url = new URL(strategy.url);
      expect(url.searchParams.get('v')).toBe('a&b c');
      // Raw-form check: the literal `&` from the version must be percent-encoded
      // so it doesn't break the query into a separate param.
      expect(url.search).toContain('v=a%26b%20c');
    });
  });

  describe('copyObject', () => {
    function copyOk(etag = 'cp') {
      return {
        CopyObjectResult: { ETag: `"${etag}"`, LastModified: new Date('2026-04-29') },
      };
    }

    it('copies from branch source on first hit', async () => {
      sendMock.mockResolvedValueOnce(copyOk());
      const p = makeProvider('parentkey');
      const out = await p.copyObject('photos', 'a.txt', 'photos', 'b.txt');
      expect(out.etag).toBe('cp');
      expect(sendMock).toHaveBeenCalledTimes(1);
      const cmd = sendMock.mock.calls[0][0] as CopyObjectCommand;
      expect(cmd.input.Key).toBe('branchkey/photos/b.txt');
      expect(cmd.input.CopySource).toBe('bucket/branchkey/photos/a.txt');
    });

    it('falls back to parent source when branch returns NoSuchKey', async () => {
      sendMock.mockRejectedValueOnce(notFoundError('NoSuchKey')).mockResolvedValueOnce(copyOk());
      const p = makeProvider('parentkey');
      const out = await p.copyObject('photos', 'a.txt', 'photos', 'b.txt');
      expect(out.etag).toBe('cp');
      expect(sendMock).toHaveBeenCalledTimes(2);
      const branchCmd = sendMock.mock.calls[0][0] as CopyObjectCommand;
      const parentCmd = sendMock.mock.calls[1][0] as CopyObjectCommand;
      expect(branchCmd.input.CopySource).toBe('bucket/branchkey/photos/a.txt');
      expect(parentCmd.input.CopySource).toBe('bucket/parentkey/photos/a.txt');
      // Destination always stays on branch.
      expect(parentCmd.input.Key).toBe('branchkey/photos/b.txt');
    });

    it('rethrows non-404 errors instead of falling back', async () => {
      sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' }));
      const p = makeProvider('parentkey');
      await expect(p.copyObject('photos', 'a.txt', 'photos', 'b.txt')).rejects.toThrow();
      expect(sendMock).toHaveBeenCalledTimes(1);
    });

    it('does NOT fall back when no parent configured', async () => {
      sendMock.mockRejectedValueOnce(notFoundError('NoSuchKey'));
      const p = makeProvider();
      await expect(p.copyObject('photos', 'a.txt', 'photos', 'b.txt')).rejects.toThrow();
      expect(sendMock).toHaveBeenCalledTimes(1);
    });
  });

  describe('getDownloadStrategy expiry', () => {
    // Uses the real presigner (signs locally with the dummy creds), so the
    // returned URL actually carries `X-Amz-Expires` — proving the caller's
    // expiresIn reaches the signature, not just the function arguments.
    it('private bucket: caller expiresIn flows into the S3 presigned signature', async () => {
      const p = makeProvider(); // no parent -> no HEAD round-trip, pure S3 presign
      const strat = await p.getDownloadStrategy('photos', 'a.txt', 120, false);
      expect(strat.method).toBe('presigned');
      expect(new URL(strat.url).searchParams.get('X-Amz-Expires')).toBe('120');
    });

    it('public bucket: expiry is overridden to 7 days regardless of caller value', async () => {
      const p = makeProvider();
      const strat = await p.getDownloadStrategy('photos', 'a.txt', 120, true);
      expect(strat.method).toBe('presigned');
      expect(new URL(strat.url).searchParams.get('X-Amz-Expires')).toBe('604800');
    });
  });
});
