import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { lstat, mkdir, readFile, rm, symlink } from 'node:fs/promises';
import path from 'node:path';
import { after, before, describe, test } from 'node:test';

import { Hono } from 'hono';
import type { Pool } from 'pg';

import type { LocalStorageService } from './service.js';

process.env.NODE_ENV = 'test';

const testId = randomUUID();
const publicBucket = `__storage_test_public_${testId}`;
const privateBucket = `__storage_test_private_${testId}`;
const bucketIds = [publicBucket, privateBucket];
const objectPath = `nested/${testId} report.txt`;
const privateObjectPath = `documents/${testId}.txt`;
const firstContents = 'primeira-versao';
const currentContents = 'segunda-versao-local';
const signingSecret = `storage-test-${randomUUID()}-${randomUUID()}`;

let pool: Pool;
let service: LocalStorageService;
let app: Hono;
let storageRoot: string;
let nonTestObjectCountBefore = 0;
let mirrorChecksumBefore: Buffer | null = null;

async function cleanup(): Promise<void> {
  if (pool) {
    await pool.query(`DELETE FROM local_storage.buckets WHERE id = ANY($1::text[])`, [bucketIds]);
  }
  if (storageRoot) {
    for (const bucketId of bucketIds) {
      await rm(path.join(storageRoot, bucketId), { recursive: true, force: true });
    }
  }
}

async function objectCountOutsideTestBuckets(): Promise<number> {
  const response = await pool.query<{ count: string }>(
    `SELECT count(*)::text AS count
     FROM local_storage.objects
     WHERE NOT (bucket_id = ANY($1::text[]))`,
    [bucketIds],
  );
  return Number(response.rows[0]?.count ?? 0);
}

describe('local PostgreSQL storage', { concurrency: false }, () => {
  before(async () => {
    const [{ db }, serviceModule, routesModule] = await Promise.all([
      import('../db.js'),
      import('./service.js'),
      import('./routes.js'),
    ]);
    pool = db;

    const schema = await pool.query<{ objects_table: string | null }>(
      `SELECT to_regclass('local_storage.objects')::text AS objects_table`,
    );
    assert.equal(
      schema.rows[0]?.objects_table,
      'local_storage.objects',
      'Run the local database migrations before the storage tests.',
    );

    service = new serviceModule.LocalStorageService(pool, {
      publicBaseUrl: 'http://storage.test',
      routePrefix: '/storage/v1',
      signingSecret,
    });
    storageRoot = service.storageRoot;
    await cleanup();
    nonTestObjectCountBefore = await objectCountOutsideTestBuckets();
    try {
      mirrorChecksumBefore = await readFile(path.join(storageRoot, 'manifest.sha256'));
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        throw error;
      }
    }

    app = new Hono();
    app.route(
      '/storage/v1',
      routesModule.createStorageRoutes(service, {
        authorize: (context) => context.req.header('X-Test-Storage-Auth') === testId,
      }),
    );
  });

  after(async () => {
    if (!pool) {
      return;
    }
    try {
      await cleanup();
      assert.equal(await objectCountOutsideTestBuckets(), nonTestObjectCountBefore);
      if (mirrorChecksumBefore) {
        assert.deepEqual(
          await readFile(path.join(storageRoot, 'manifest.sha256')),
          mirrorChecksumBefore,
          'The imported storage manifest must remain untouched.',
        );
      }
    } finally {
      await pool.end();
    }
  });

  test('creates, lists and updates isolated buckets', async () => {
    const created = await service.createBucket(publicBucket, {
      public: false,
      fileSizeLimit: 64,
      allowedMimeTypes: ['text/plain'],
    });
    assert.equal(created.error, null);
    assert.deepEqual(created.data, { name: publicBucket });

    const duplicate = await service.createBucket(publicBucket);
    assert.equal(duplicate.data, null);
    assert.equal(duplicate.error?.code, 'bucket_exists');

    const updated = await service.updateBucket(publicBucket, { public: true });
    assert.equal(updated.error, null);
    assert.equal(await service.bucketIsPublic(publicBucket), true);

    const privateCreated = await service.createBucket(privateBucket, {
      public: false,
      fileSizeLimit: 128,
      allowedMimeTypes: ['text/*'],
    });
    assert.equal(privateCreated.error, null);

    const listed = await service.listBuckets();
    assert.equal(listed.error, null);
    assert.equal(listed.data?.some((bucket) => bucket.id === publicBucket), true);
    assert.equal(listed.data?.some((bucket) => bucket.id === privateBucket), true);
  });

  test('uploads atomically and records size, checksum and local path', async () => {
    const uploaded = await service.from(publicBucket).upload(objectPath, firstContents, {
      contentType: 'text/plain',
      cacheControl: '60',
      upsert: false,
    });
    assert.equal(uploaded.error, null);
    assert.deepEqual(uploaded.data, {
      path: objectPath,
      fullPath: `${publicBucket}/${objectPath}`,
    });

    const row = await pool.query<{
      local_path: string;
      size_bytes: string;
      content_checksum: string;
    }>(
      `SELECT local_path, size_bytes::text, content_checksum
       FROM local_storage.objects
       WHERE bucket_id = $1 AND name = $2`,
      [publicBucket, objectPath],
    );
    assert.equal(row.rows[0]?.local_path, `${publicBucket}/${objectPath}`);
    assert.equal(Number(row.rows[0]?.size_bytes), Buffer.byteLength(firstContents));
    assert.equal(
      row.rows[0]?.content_checksum,
      createHash('sha256').update(firstContents).digest('hex'),
    );

    const duplicate = await service.from(publicBucket).upload(objectPath, 'nao-gravar', {
      contentType: 'text/plain',
    });
    assert.equal(duplicate.error?.code, 'object_exists');

    const replacement = await service.from(publicBucket).upload(objectPath, currentContents, {
      contentType: 'text/plain',
      cacheControl: '120',
      upsert: true,
    });
    assert.equal(replacement.error, null);
    const downloaded = await service.from(publicBucket).download(objectPath);
    assert.equal(downloaded.error, null);
    assert.equal(await downloaded.data?.text(), currentContents);
  });

  test('rejects traversal, disallowed MIME types, oversized data and symlinks', async (context) => {
    const traversal = await service.from(publicBucket).upload('../escape.txt', 'unsafe', {
      contentType: 'text/plain',
    });
    assert.equal(traversal.error?.code, 'invalid_path');

    const wrongMime = await service.from(publicBucket).upload(`nested/${testId}.json`, '{}', {
      contentType: 'application/json',
    });
    assert.equal(wrongMime.error?.code, 'mime_type_not_allowed');

    const tooLarge = await service.from(publicBucket).upload(
      `nested/${testId}.large.txt`,
      'x'.repeat(65),
      { contentType: 'text/plain' },
    );
    assert.equal(tooLarge.error?.code, 'file_too_large');

    const bucketRoot = path.join(storageRoot, publicBucket);
    const target = path.join(bucketRoot, 'symlink-target');
    const link = path.join(bucketRoot, 'unsafe-link');
    await mkdir(target, { recursive: true });
    try {
      await symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir');
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === 'EPERM') {
        context.skip('Creating a symlink is not permitted on this host.');
        return;
      }
      throw error;
    }

    const throughLink = await service.from(publicBucket).upload('unsafe-link/file.txt', 'unsafe', {
      contentType: 'text/plain',
    });
    assert.equal(throughLink.error?.code, 'unsafe_storage_path');
    assert.equal((await lstat(link)).isSymbolicLink(), true);
  });

  test('serves public, authenticated and signed URLs with Range and content headers', async () => {
    const publicUrl = service.from(publicBucket).getPublicUrl(objectPath).data.publicUrl;
    const fullResponse = await app.request(publicUrl);
    assert.equal(fullResponse.status, 200);
    assert.equal(fullResponse.headers.get('content-type'), 'text/plain');
    assert.equal(await fullResponse.text(), currentContents);

    const rangeResponse = await app.request(publicUrl, { headers: { Range: 'bytes=0-6' } });
    assert.equal(rangeResponse.status, 206);
    assert.equal(rangeResponse.headers.get('content-range'), `bytes 0-6/${currentContents.length}`);
    assert.equal(await rangeResponse.text(), currentContents.slice(0, 7));

    const unsatisfiable = await app.request(publicUrl, { headers: { Range: 'bytes=999-' } });
    assert.equal(unsatisfiable.status, 416);
    assert.equal(unsatisfiable.headers.get('content-range'), `bytes */${currentContents.length}`);

    const privateUpload = await service.from(privateBucket).upload(privateObjectPath, 'privado', {
      contentType: 'text/plain',
    });
    assert.equal(privateUpload.error, null);
    const encodedPrivatePath = privateObjectPath.split('/').map(encodeURIComponent).join('/');
    const privateUrl =
      `http://storage.test/storage/v1/object/authenticated/${privateBucket}/${encodedPrivatePath}`;
    assert.equal((await app.request(privateUrl)).status, 403);
    const authorized = await app.request(privateUrl, {
      headers: { 'X-Test-Storage-Auth': testId },
    });
    assert.equal(authorized.status, 200);
    assert.equal(await authorized.text(), 'privado');

    const signed = await service.from(privateBucket).createSignedUrl(privateObjectPath, 60, {
      download: 'comprovativo.txt',
    });
    assert.equal(signed.error, null);
    assert.ok(signed.data?.signedUrl);
    const signedResponse = await app.request(signed.data.signedUrl);
    assert.equal(signedResponse.status, 200);
    assert.match(signedResponse.headers.get('content-disposition') ?? '', /comprovativo\.txt/);
    assert.equal(await signedResponse.text(), 'privado');

    const tampered = new URL(signed.data.signedUrl);
    tampered.searchParams.set('token', `${tampered.searchParams.get('token')}x`);
    assert.equal((await app.request(tampered.toString())).status, 403);
  });

  test('removes only requested object rows and files', async () => {
    const publicRemoved = await service.from(publicBucket).remove([objectPath, 'missing.txt']);
    assert.equal(publicRemoved.error, null);
    assert.deepEqual(publicRemoved.data?.map((item) => item.name), [objectPath]);
    assert.equal((await service.from(publicBucket).download(objectPath)).error?.code, 'object_not_found');

    const privateRemoved = await service.from(privateBucket).remove([privateObjectPath]);
    assert.equal(privateRemoved.error, null);
    assert.equal(privateRemoved.data?.[0]?.name, privateObjectPath);
  });
});

