import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import fetch from 'node-fetch';
import { createClient, RedisClientType } from 'redis';
import { join } from 'path';
import { CacheEntry } from '../../src/RedisStringsHandler';
import { revalidate as next1503_revalidatedFetch_route } from './next-app-15-0-3/src/app/api/revalidated-fetch/route';

// Select which Next.js test app to use. Can be overridden via NEXT_TEST_APP env var
// Examples: next-app-15-0-3, next-app-15-3-2, next-app-15-4-7
const NEXT_TEST_APP = process.env.NEXT_TEST_APP || 'next-app-15-4-7';
const NEXT_APP_DIR = join(__dirname, NEXT_TEST_APP);
console.log('NEXT_APP_DIR', NEXT_APP_DIR);
const NEXT_START_PORT = 3055;
const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;

const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments

let nextProcess: ChildProcessWithoutNullStreams;
let redisClient: RedisClientType;

async function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function runCommand(cmd: string, args: string[], cwd: string) {
  return new Promise((resolve, reject) => {
    let stderr = '';
    let stdout = '';
    const proc = spawn(cmd, args, { cwd, stdio: 'pipe' });

    proc.stdout.on('data', (data) => {
      if (process.env.DEBUG_INTEGRATION) {
        console.log(data.toString());
      }
      stdout += data.toString();
    });

    proc.stderr.on('data', (data) => {
      if (process.env.DEBUG_INTEGRATION) {
        console.error(data.toString());
      }
      stderr += data.toString();
    });

    proc.on('exit', (code) => {
      if (code === 0) resolve(undefined);
      else {
        reject(
          new Error(
            `${cmd} ${args.join(' ')} failed with code ${code}\n` +
              `stdout: ${stdout}\n` +
              `stderr: ${stderr}`,
          ),
        );
      }
    });
  });
}

async function waitForServer(url, timeout = 20000) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    try {
      const res = await fetch(url + '/api/cached-static-fetch');
      if (res.ok) return;
    } catch {}
    await new Promise((r) => setTimeout(r, 300));
  }
  throw new Error('Next.js server did not start in time');
}

describe('Next.js Turbo Redis Cache Integration', () => {
  beforeAll(async () => {
    // If there was detected to run a server before (any old server which was not stopped correctly), kill it
    try {
      const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
      if (res.ok) {
        await runCommand('pkill', ['next'], NEXT_APP_DIR);
      }
    } catch {}

    // Set up environment variables
    process.env.VERCEL_ENV = 'production';
    process.env.VERCEL_URL =
      'integration-test-' + Math.random().toString(36).substring(2, 15);
    console.log('redis key prefix is:', process.env.VERCEL_URL);

    // Only override if redis env vars if not set. This can be set in the CI env.
    process.env.REDISHOST = process.env.REDISHOST || 'localhost';
    process.env.REDISPORT = process.env.REDISPORT || '6379';
    process.env.NEXT_START_PORT = String(NEXT_START_PORT);

    if (process.env.SKIP_BUILD === 'true') {
      console.log('skipping build');
    } else {
      // Build Next.js app first
      await runCommand('pnpm', ['i'], NEXT_APP_DIR);
      console.log('pnpm i done');
      await runCommand('pnpm', ['build'], NEXT_APP_DIR);
      console.log('pnpm build done');
    }

    // Start Next.js app
    nextProcess = spawn(
      'npx',
      ['next', 'start', '-p', String(NEXT_START_PORT)],
      {
        cwd: NEXT_APP_DIR,
        env: {
          ...process.env,
        },
        stdio: 'pipe',
      },
    );
    if (process.env.DEBUG_INTEGRATION) {
      nextProcess.stdout.on('data', (data) => {
        console.log(`stdout: ${data}`);
      });
    }

    nextProcess.stderr.on('data', (data) => {
      console.error(`stderr: ${data}`);
    });
    await waitForServer(NEXT_START_URL);
    console.log('next start successful');

    // Connect to Redis
    redisClient = createClient({
      url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
    });
    await redisClient.connect();

    console.log('redis key prefix is:', process.env.VERCEL_URL);
  }, 60_000);

  afterAll(async () => {
    if (process.env.KEEP_SERVER_RUNNING === 'true') {
      console.log('keeping server running');
    } else {
      if (nextProcess) nextProcess.kill();
    }
    if (redisClient) await redisClient.quit();
  });

  describe('should have the correct caching behavior for API routes', () => {
    describe('should cache static API routes in Redis', () => {
      let counter1: number;

      it('First request (should increment counter)', async () => {
        const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
        const data: any = await res.json();
        expect(data.counter).toBe(1);
        counter1 = data.counter;
      });

      it('Second request (should hit cache, counter should not increment if cache works)', async () => {
        const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
        const data: any = await res.json();
        // If cache is working, counter should stay 1; if not, it will increment
        expect(data.counter).toBe(counter1);
      });

      it('The data in the redis key should match the expected format', async () => {
        await delay(REDIS_BACKGROUND_SYNC_DELAY);
        const keys = await redisClient.keys(process.env.VERCEL_URL + '*');
        expect(keys.length).toBeGreaterThan(0);

        // check the content of redis key
        const value = (await redisClient.get(
          process.env.VERCEL_URL + '/api/cached-static-fetch',
        )) as string;
        expect(value).toBeDefined();
        const cacheEntry: CacheEntry = JSON.parse(value);

        // The format should be as expected
        expect(cacheEntry).toEqual({
          value: {
            kind: 'APP_ROUTE',
            status: 200,
            body: { $binary: 'eyJjb3VudGVyIjoxfQ==' },
            headers: {
              'cache-control': 'public, max-age=1',
              'content-type': 'application/json',
              'x-next-cache-tags':
                '_N_T_/layout,_N_T_/api/layout,_N_T_/api/cached-static-fetch/layout,_N_T_/api/cached-static-fetch/route,_N_T_/api/cached-static-fetch',
            },
          },
          lastModified: expect.any(Number),
          tags: [
            '_N_T_/layout',
            '_N_T_/api/layout',
            '_N_T_/api/cached-static-fetch/layout',
            '_N_T_/api/cached-static-fetch/route',
            '_N_T_/api/cached-static-fetch',
          ],
        });

        expect((cacheEntry.value as any).kind).toBe('APP_ROUTE');
        const bodyBuffer = Buffer.from(
          (cacheEntry.value as any)?.body?.$binary,
          'base64',
        );
        const bodyJson = JSON.parse(bodyBuffer.toString('utf-8'));
        expect(bodyJson.counter).toBe(counter1);
      });

      it('A request to revalidatePath API should remove the route from redis (string and hashmap)', async () => {
        const revalidateRes = await fetch(
          NEXT_START_URL + '/api/revalidatePath?path=/api/cached-static-fetch',
        );
        const revalidateResJson: any = await revalidateRes.json();
        expect(revalidateResJson.success).toBe(true);
        await delay(REDIS_BACKGROUND_SYNC_DELAY);

        // check Redis keys
        const keys = await redisClient.keys(
          process.env.VERCEL_URL + '/api/cached-static-fetch',
        );
        expect(keys.length).toBe(0);

        const hashmap = await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/api/cached-static-fetch',
        );
        expect(hashmap).toBeNull();
      });

      it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
        const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
        const data: any = await res.json();
        expect(data.counter).toBe(counter1 + 1);
      });

      it('After the new request was made the redis key and hashmap should be set again', async () => {
        await delay(REDIS_BACKGROUND_SYNC_DELAY);
        // check Redis keys
        const keys = await redisClient.keys(
          process.env.VERCEL_URL + '/api/cached-static-fetch',
        );
        expect(keys.length).toBe(1);

        const hashmap = (await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/api/cached-static-fetch',
        )) as string;
        expect(JSON.parse(hashmap)).toEqual([
          '_N_T_/layout',
          '_N_T_/api/layout',
          '_N_T_/api/cached-static-fetch/layout',
          '_N_T_/api/cached-static-fetch/route',
          '_N_T_/api/cached-static-fetch',
        ]);
      });
    });

    describe('should cache revalidation API routes in Redis', () => {
      let counter1: number;

      it('First request (should increment counter)', async () => {
        const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
        const data: any = await res.json();
        expect(data.counter).toBe(1);
        counter1 = data.counter;
      });

      it('Second request which is send in revalidation time should hit cache (counter should not increment)', async () => {
        const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
        const data: any = await res.json();
        expect(data.counter).toBe(counter1);
      });

      if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
        const FIRST_DELAY = 6000;
        const SECOND_DELAY = 1000;
        it('Third request which is send directly after revalidation time will still serve cache but trigger re-evaluation (stale-while-revalidate)', async () => {
          await delay(FIRST_DELAY);
          const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
          const data: any = await res.json();
          expect(data.counter).toBe(counter1);
        }, 10_000);

        it('Third request which is send directly after revalidation time will serve re-evaluated data (stale-while-revalidate)', async () => {
          await delay(SECOND_DELAY);
          const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
          const data: any = await res.json();
          expect(data.counter).toBe(counter1 + 1);
        });

        it('After expiration, the redis key should be removed from redis and the hashmap', async () => {
          const ttl = await redisClient.ttl(
            process.env.VERCEL_URL + '/api/revalidated-fetch',
          );
          expect(ttl).toBeLessThan(2 * next1503_revalidatedFetch_route);
          expect(ttl).toBeGreaterThan(
            2 * next1503_revalidatedFetch_route -
              FIRST_DELAY -
              SECOND_DELAY -
              REDIS_BACKGROUND_SYNC_DELAY,
          );

          await delay(ttl * 1000 + 500);

          // check Redis keys
          const keys = await redisClient.keys(
            process.env.VERCEL_URL + '/api/revalidated-fetch',
          );
          expect(keys.length).toBe(0);

          await delay(1000);

          // The key should also be removed from the hashmap
          const hashmap = await redisClient.hGet(
            process.env.VERCEL_URL + '__sharedTags__',
            '/api/revalidated-fetch',
          );
          expect(hashmap).toBeNull();
        }, 15_000);
      }
    });

    // Next 16-only caching API tests. These routes exist only in the Next 16 test app
    // and exercise the new revalidateTag profiles and updateTag semantics.
    if (NEXT_TEST_APP.includes('16.')) {
      describe('Next 16 caching APIs', () => {
        const cachedStaticPath = '/api/cached-static-fetch';

        async function assertCachedStaticFetchCleared() {
          await delay(REDIS_BACKGROUND_SYNC_DELAY);
          const keys = await redisClient.keys(
            process.env.VERCEL_URL + cachedStaticPath,
          );
          expect(keys.length).toBe(0);

          const hashmap = await redisClient.hGet(
            process.env.VERCEL_URL + '__sharedTags__',
            cachedStaticPath,
          );
          expect(hashmap).toBeNull();
        }

        it('revalidateTag(tag, "max") should invalidate cached-static-fetch by tag', async () => {
          // Warm up cache and sharedTagsMap for cached-static-fetch
          await fetch(NEXT_START_URL + cachedStaticPath);
          await delay(REDIS_BACKGROUND_SYNC_DELAY);

          // Use explicit tag for this route as set by Next.js
          const tag = '_N_T_/api/cached-static-fetch';
          const res = await fetch(
            `${NEXT_START_URL}/api/revalidateTag?tag=${encodeURIComponent(
              tag,
            )}&profile=max`,
          );
          const json: any = await res.json();
          expect(json.success).toBe(true);

          await assertCachedStaticFetchCleared();
        });

        it('revalidateTag(tag, { expire: 60 }) should also invalidate cached-static-fetch', async () => {
          // Warm up cache again
          await fetch(NEXT_START_URL + cachedStaticPath);
          await delay(REDIS_BACKGROUND_SYNC_DELAY);

          const tag = '_N_T_/api/cached-static-fetch';
          const res = await fetch(
            `${NEXT_START_URL}/api/revalidateTag?tag=${encodeURIComponent(
              tag,
            )}&profile=expire`,
          );
          const json: any = await res.json();
          expect(json.success).toBe(true);

          await assertCachedStaticFetchCleared();
        });
      });
    }

    describe('should not cache uncached API routes in Redis', () => {
      let counter1: number;

      it('First request should increment counter', async () => {
        const res1 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
        const data1: any = await res1.json();
        expect(data1.counter).toBe(1);
        counter1 = data1.counter;
      });

      it('Second request should hit cache (counter should not increment if cache works)', async () => {
        const res2 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
        const data2: any = await res2.json();

        // If not caching it is working request 2 should be higher as request one
        expect(data2.counter).toBe(counter1 + 1);
      });

      it('The redis key should not be set', async () => {
        // check the content of redis key
        const value = await redisClient.get(
          process.env.VERCEL_URL + '/api/uncached-fetch',
        );
        expect(value).toBeNull();
      });
    });

    describe('should cache a nested fetch request inside a uncached API route', () => {
      describe('should cache the nested fetch request (but not the API route itself)', () => {
        let counter: number;
        let subCounter: number;

        it('should deduplicate requests to the sub-fetch-request, but not to the API route itself', async () => {
          // make two requests, both should return the same subFetchData but different counter
          const res1 = await fetch(
            NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
          );
          const res2 = await fetch(
            NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
          );
          const [data1, data2]: any[] = await Promise.all([
            res1.json(),
            res2.json(),
          ]);

          // API route counter itself increments for each request
          // But we do not know which request is first and which is second
          if (data1.counter < data2.counter) {
            expect(data2.counter).toBeGreaterThan(data1.counter);
            counter = data2.counter;
          } else {
            expect(data1.counter).toBeGreaterThan(data2.counter);
            counter = data1.counter;
          }

          // API route counter of revalidated sub-fetch-request should be the same (request deduplication of fetch requests)
          expect(data1.subFetchData.counter).toBe(data2.subFetchData.counter);
          subCounter = data1.subFetchData.counter;
        });

        if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
          it('should return the same subFetchData after 2 seconds (regular caching within revalidation interval (=3s) works)', async () => {
            // make another request after 2 seconds, it should return the same subFetchData
            await delay(2000); // 2s < 3s (revalidate interval)
            const res = await fetch(
              NEXT_START_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const data: any = await res.json();

            expect(data.counter).toBe(counter + 1);
            expect(data.subFetchData.counter).toBe(subCounter);
          });

          it('should return the same subFetchData after 2 seconds and new data after another 2 seconds (caching while revalidation works)', async () => {
            // make another request after another 2 seconds, it should return the same subFetchData (caching while revalidation works)
            await delay(2000); // 2s+2s < 3s*2 (=TTL = revalidate=3s*2)
            const res1 = await fetch(
              NEXT_START_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const data1: any = await res1.json();
            expect(data1.counter).toBe(counter + 2);
            expect(data1.subFetchData.counter).toBe(subCounter);

            // make another request directly after first request which was still in TTL, it should return new data (caching while revalidation works)
            await delay(REDIS_BACKGROUND_SYNC_DELAY);
            const res2 = await fetch(
              NEXT_START_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const data2: any = await res2.json();
            expect(data2.counter).toBe(counter + 3);
            expect(data2.subFetchData.counter).toBe(subCounter + 1);
          });

          it('A request to revalidatePage API should remove the route from redis (string and hashmap)', async () => {
            const revalidateRes = await fetch(
              NEXT_START_URL +
                '/api/revalidatePath?path=/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const revalidateResJson: any = await revalidateRes.json();
            expect(revalidateResJson.success).toBe(true);
            await delay(REDIS_BACKGROUND_SYNC_DELAY);

            // check Redis keys
            const keys = await redisClient.keys(
              process.env.VERCEL_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            expect(keys.length).toBe(0);

            const hashmap = await redisClient.hGet(
              process.env.VERCEL_URL + '__sharedTags__',
              '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            expect(hashmap).toBeNull();
          });

          it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
            const res = await fetch(
              NEXT_START_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const data: any = await res.json();
            expect(data.counter).toBe(counter + 4);
            expect(data.subFetchData.counter).toBe(subCounter + 2);
          });

          it('After the new request was made the redis key and hashmap should be set again', async () => {
            await delay(REDIS_BACKGROUND_SYNC_DELAY);

            // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
            // So it should stay the same unless nextjs will change something in there implementation
            const cacheEntryKey =
              '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';

            // check Redis keys
            const keys = await redisClient.keys(
              process.env.VERCEL_URL + cacheEntryKey,
            );
            expect(keys.length).toBe(1);

            const hashmap = (await redisClient.hGet(
              process.env.VERCEL_URL + '__sharedTags__',
              cacheEntryKey,
            )) as string;
            expect(JSON.parse(hashmap)).toEqual([
              'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
            ]);
          });

          it('A request to revalidateTag API should remove the route from redis (string and hashmap)', async () => {
            const revalidateRes = await fetch(
              NEXT_START_URL +
                '/api/revalidateTag?tag=revalidated-fetch-revalidate3-nested-fetch-in-api-route',
            );
            const revalidateResJson: any = await revalidateRes.json();
            expect(revalidateResJson.success).toBe(true);
            await delay(REDIS_BACKGROUND_SYNC_DELAY);

            // check Redis keys
            const keys = await redisClient.keys(
              process.env.VERCEL_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            expect(keys.length).toBe(0);

            const hashmap = await redisClient.hGet(
              process.env.VERCEL_URL + '__sharedTags__',
              '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            expect(hashmap).toBeNull();
          });

          it('Another new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
            const res = await fetch(
              NEXT_START_URL +
                '/api/nested-fetch-in-api-route/revalidated-fetch',
            );
            const data: any = await res.json();
            expect(data.counter).toBe(counter + 5);
            expect(data.subFetchData.counter).toBe(subCounter + 3);
          });

          it('After the new request was made the redis key and hashmap should be set again', async () => {
            await delay(REDIS_BACKGROUND_SYNC_DELAY);
            // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
            // So it should stay the same unless nextjs will change something in there implementation
            const cacheEntryKey =
              '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';

            // check Redis keys
            const keys = await redisClient.keys(
              process.env.VERCEL_URL + cacheEntryKey,
            );
            expect(keys.length).toBe(1);

            const hashmap = (await redisClient.hGet(
              process.env.VERCEL_URL + '__sharedTags__',
              cacheEntryKey,
            )) as string;
            expect(JSON.parse(hashmap)).toEqual([
              'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
            ]);
          });
        }
      });
    });

    // describe('With a API route that has a unstable_cacheTag', () => {
    //   // TODO: implement API route for this test as well as the test itself
    // });

    // describe('With a API route that has a unstable_cacheLife', () => {
    //   // TODO: implement API route for this test as well as the test itself
    // });
  });

  describe('should have the correct caching behavior for pages', () => {
    describe('Without any fetch requests inside the page', () => {
      describe('With default page configuration for revalidate and dynamic values', () => {
        let timestamp1: string | undefined;

        it('Two parallel requests should return the same timestamp (because requests are deduplicated)', async () => {
          // First request (should increment counter)
          const [pageRes1, pageRes2] = await Promise.all([
            fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
            fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
          ]);

          const pageText1 = await pageRes1.text();
          timestamp1 = pageText1.match(/Timestamp: <!-- -->(\d+)/)?.[1];
          expect(timestamp1).toBeDefined();

          const pageText2 = await pageRes2.text();
          const timestamp2 = pageText2.match(/Timestamp: <!-- -->(\d+)/)?.[1];
          expect(timestamp2).toBeDefined();
          expect(timestamp1).toBe(timestamp2);
        });

        it('Redis should have a key for the page which should have a TTL set to 28 days (2 * 14 days default revalidate time)', async () => {
          await delay(REDIS_BACKGROUND_SYNC_DELAY);
          // check Redis keys
          const ttl = await redisClient.ttl(
            process.env.VERCEL_URL + '/pages/no-fetch/default-page',
          );
          // 14 days is default revalidate for pages -> expiration time is 2 * revalidate time -> -10 seconds for testing offset stability
          expect(ttl).toBeGreaterThan(2 * 14 * 24 * 60 * 60 - 30);
          expect(ttl).toBeLessThanOrEqual(2 * 14 * 24 * 60 * 60);
        });

        it('The data in the redis key should match the expected format', async () => {
          const data = (await redisClient.get(
            process.env.VERCEL_URL + '/pages/no-fetch/default-page',
          )) as string;
          expect(data).toBeDefined();
          const cacheEntry: CacheEntry = JSON.parse(data);

          // The format should be as expected. We intentionally do not assert on an optional status field here
          // so that different Next.js versions (which may include or omit it) are both supported.
          expect(cacheEntry).toMatchObject({
            value: {
              kind: 'APP_PAGE',
              html: expect.any(String),
              rscData: {
                $binary: expect.any(String),
              },
              headers: {
                'x-nextjs-stale-time': expect.any(String),
                'x-next-cache-tags':
                  '_N_T_/layout,_N_T_/pages/layout,_N_T_/pages/no-fetch/layout,_N_T_/pages/no-fetch/default-page/layout,_N_T_/pages/no-fetch/default-page/page,_N_T_/pages/no-fetch/default-page',
              },
            },
            lastModified: expect.any(Number),
            tags: [
              '_N_T_/layout',
              '_N_T_/pages/layout',
              '_N_T_/pages/no-fetch/layout',
              '_N_T_/pages/no-fetch/default-page/layout',
              '_N_T_/pages/no-fetch/default-page/page',
              '_N_T_/pages/no-fetch/default-page',
            ],
          });
        });

        if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
          it('A new request after 3 seconds should return the same timestamp (because the page was cached in in-memory cache)', async () => {
            await delay(3_000);
            const pageRes3 = await fetch(
              NEXT_START_URL + '/pages/no-fetch/default-page',
            );
            const pageText3 = await pageRes3.text();
            const timestamp3 = pageText3.match(/Timestamp: <!-- -->(\d+)/)?.[1];
            expect(timestamp3).toBeDefined();
            expect(timestamp1).toBe(timestamp3);
          });

          it('A new request after 11 seconds should return the same timestamp (because the page was cached in redis cache)', async () => {
            await delay(11_000);
            const pageRes4 = await fetch(
              NEXT_START_URL + '/pages/no-fetch/default-page',
            );
            const pageText4 = await pageRes4.text();
            const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
            expect(timestamp4).toBeDefined();
            expect(timestamp1).toBe(timestamp4);
          }, 15_000);
        }

        it('A request to revalidatePage API should remove the page from redis (string and hashmap)', async () => {
          const revalidateRes = await fetch(
            NEXT_START_URL +
              '/api/revalidatePath?path=/pages/no-fetch/default-page',
          );
          const revalidateResJson: any = await revalidateRes.json();
          expect(revalidateResJson.success).toBe(true);
          await delay(REDIS_BACKGROUND_SYNC_DELAY);

          // check Redis keys
          const keys = await redisClient.keys(
            process.env.VERCEL_URL + '/pages/no-fetch/default-page',
          );
          expect(keys.length).toBe(0);

          const hashmap = await redisClient.hGet(
            process.env.VERCEL_URL + '__sharedTags__',
            '/pages/no-fetch/default-page',
          );
          expect(hashmap).toBeNull();
        });

        it('A new request after the revalidation should return a new timestamp (because the page was recreated)', async () => {
          const pageRes4 = await fetch(
            NEXT_START_URL + '/pages/no-fetch/default-page',
          );
          const pageText4 = await pageRes4.text();
          const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
          expect(timestamp4).toBeDefined();
          expect(Number(timestamp4)).toBeGreaterThan(Number(timestamp1));
        });

        it('After the new request was made the redis key and hashmap should be set again', async () => {
          // check Redis keys
          await delay(REDIS_BACKGROUND_SYNC_DELAY);
          const keys = await redisClient.keys(
            process.env.VERCEL_URL + '/pages/no-fetch/default-page',
          );
          expect(keys.length).toBe(1);

          const hashmap = (await redisClient.hGet(
            process.env.VERCEL_URL + '__sharedTags__',
            '/pages/no-fetch/default-page',
          )) as string;
          expect(JSON.parse(hashmap)).toEqual([
            '_N_T_/layout',
            '_N_T_/pages/layout',
            '_N_T_/pages/no-fetch/layout',
            '_N_T_/pages/no-fetch/default-page/layout',
            '_N_T_/pages/no-fetch/default-page/page',
            '_N_T_/pages/no-fetch/default-page',
          ]);
        });
      });
    });

    // describe('With a cached static fetch request inside a page', () => {
    //   // TODO: implement test for `test/integration/next-app/src/app/pages/cached-static-fetch`
    // });

    describe('With a cached revalidation fetch request inside a page', () => {
      let firstTimestamp: string;
      let firstCounter: string;

      it('should set all cache entries for this page after request is finished', async () => {
        const pageRes = await fetch(
          NEXT_START_URL +
            '/pages/revalidated-fetch/revalidate15--default-page',
        );
        const pageText = await pageRes.text();
        const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
        const counter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
        expect(timestamp).toBeDefined();
        expect(counter).toBeDefined();
        firstTimestamp = timestamp!;
        firstCounter = counter!;

        await delay(REDIS_BACKGROUND_SYNC_DELAY);

        // test cache entry for 3 keys are set
        const keys1 = await redisClient.keys(
          process.env.VERCEL_URL +
            '/pages/revalidated-fetch/revalidate15--default-page',
        );
        expect(keys1.length).toBe(1);
        const keys2 = await redisClient.keys(
          process.env.VERCEL_URL +
            'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
        );
        expect(keys2.length).toBe(1);
        const keys3 = await redisClient.keys(
          process.env.VERCEL_URL + '/api/revalidated-fetch',
        );
        expect(keys3.length).toBe(1);

        // test shared tag hashmap to be set for all keys
        const hashmap1 = (await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/pages/revalidated-fetch/revalidate15--default-page',
        )) as string;
        expect(JSON.parse(hashmap1)).toEqual([
          '_N_T_/layout',
          '_N_T_/pages/layout',
          '_N_T_/pages/revalidated-fetch/layout',
          '_N_T_/pages/revalidated-fetch/revalidate15--default-page/layout',
          '_N_T_/pages/revalidated-fetch/revalidate15--default-page/page',
          '_N_T_/pages/revalidated-fetch/revalidate15--default-page',
          'revalidated-fetch-revalidate15-default-page',
        ]);
        const hashmap2 = (await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
        )) as string;
        expect(JSON.parse(hashmap2)).toEqual([
          'revalidated-fetch-revalidate15-default-page',
        ]);
        const hashmap3 = (await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/api/revalidated-fetch',
        )) as string;
        expect(JSON.parse(hashmap3)).toEqual([
          '_N_T_/layout',
          '_N_T_/api/layout',
          '_N_T_/api/revalidated-fetch/layout',
          '_N_T_/api/revalidated-fetch/route',
          '_N_T_/api/revalidated-fetch',
        ]);
      });

      it('a new request should return the same timestamp as the first request', async () => {
        const pageRes = await fetch(
          NEXT_START_URL +
            '/pages/revalidated-fetch/revalidate15--default-page',
        );
        const pageText = await pageRes.text();
        const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
        expect(timestamp).toBeDefined();
        expect(timestamp).toBe(firstTimestamp);
      });

      it('A request to revalidatePath API should remove the page from redis (string and hashmap) but not the api route', async () => {
        const revalidateRes = await fetch(
          NEXT_START_URL +
            '/api/revalidatePath?path=/pages/revalidated-fetch/revalidate15--default-page',
        );
        const revalidateResJson: any = await revalidateRes.json();
        expect(revalidateResJson.success).toBe(true);
        await delay(REDIS_BACKGROUND_SYNC_DELAY);

        // test no cache entry for 2 keys
        const keys1 = await redisClient.keys(
          process.env.VERCEL_URL +
            '/pages/revalidated-fetch/revalidate15--default-page',
        );
        expect(keys1.length).toBe(0);

        const hashmap1 = await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/pages/revalidated-fetch/revalidate15--default-page',
        );
        expect(hashmap1).toBeNull();

        // sub-fetch-request is not removed directly but will be removed on next get request
        const keys2 = await redisClient.keys(
          process.env.VERCEL_URL +
            'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
        );
        expect(keys2.length).toBe(1);
        const hashmap2 = await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
        );
        expect(hashmap2).toBeDefined();

        // the page should also be in revalidatedTagsMap so that the nested fetch requests knows that the page was invalidated
        const revalidationTimestamp = await redisClient.hGet(
          process.env.VERCEL_URL + '__revalidated_tags__',
          '_N_T_/pages/revalidated-fetch/revalidate15--default-page',
        );
        const ts = Number(revalidationTimestamp);
        expect(ts).toBeGreaterThan(1);
        expect(ts).toBeLessThan(Number(Date.now()));

        // API route should still be cached
        const keys3 = await redisClient.keys(
          process.env.VERCEL_URL + '/api/revalidated-fetch',
        );
        expect(keys3.length).toBe(1);
        const hashmap3 = (await redisClient.hGet(
          process.env.VERCEL_URL + '__sharedTags__',
          '/api/revalidated-fetch',
        )) as string;
        expect(JSON.parse(hashmap3)).toEqual([
          '_N_T_/layout',
          '_N_T_/api/layout',
          '_N_T_/api/revalidated-fetch/layout',
          '_N_T_/api/revalidated-fetch/route',
          '_N_T_/api/revalidated-fetch',
        ]);
      });

      it('a new request should return a newer timestamp as the first request (which was invalidated by revalidatePath)', async () => {
        const pageRes = await fetch(
          NEXT_START_URL +
            '/pages/revalidated-fetch/revalidate15--default-page',
        );
        const pageText = await pageRes.text();
        const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
        const secondCounter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
        expect(timestamp).toBeDefined();
        expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));

        //but the new request should not have a higher counter than the first request (because the cache of the API route should not be invalidated)
        expect(secondCounter).toBeDefined();
        expect(secondCounter).toBe(firstCounter);
      });
    });

    // describe('With a uncached fetch request inside a page', () => {
    //   // TODO: implement test for `test/integration/next-app/src/app/pages/uncached-fetch`
    //
    // });

    // describe('With a page that has a unstable_cacheTag', () => {
    //   // TODO: implement page for this test as well as the test itself
    // });

    // describe('With a page that has a unstable_cacheLife', () => {
    //   // TODO: implement page for this test as well as the test itself
    // });
  });
});
