/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {Generator, processNavigationUrls} from '../src/generator';
import {AssetGroup} from '../src/in';
import {HashTrackingMockFilesystem, MockFilesystem} from '../testing/mock';

describe('Generator', () => {
  beforeEach(() => spyOn(Date, 'now').and.returnValue(1234567890123));

  it('generates a correct config', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
      '/main.css': 'This is a CSS file',
      '/main.js': 'This is a JS file',
      '/main.ts': 'This is a TS file',
      '/test.txt': 'Another test',
      '/foo/test.html': 'Another test',
      '/ignored/x.html': 'should be ignored',
    });
    const gen = new Generator(fs, '/test');
    const config = await gen.process({
      appData: {
        test: true,
      },
      index: '/index.html',
      assetGroups: [
        {
          name: 'test',
          resources: {
            files: ['/**/*.html', '/**/*.?s', '!/ignored/**', '/**/*.txt'],
            urls: ['/absolute/**', '/some/url?with+escaped+chars', 'relative/*.txt'],
          },
        },
      ],
      dataGroups: [
        {
          name: 'other',
          urls: ['/api/**', 'relapi/**', 'https://example.com/**/*?with+escaped+chars'],
          cacheConfig: {
            maxSize: 100,
            maxAge: '3d',
            timeout: '1m',
          },
        },
      ],
      navigationUrls: [
        '/included/absolute/**',
        '!/excluded/absolute/**',
        '/included/some/url/with+escaped+chars',
        '!excluded/relative/*.txt',
        '!/api/?*',
        'http://example.com/included',
        '!http://example.com/excluded',
      ],
      applicationMaxAge: '1d',
    });

    expect(config).toEqual({
      configVersion: 1,
      timestamp: 1234567890123,
      appData: {
        test: true,
      },
      index: '/test/index.html',
      assetGroups: [
        {
          name: 'test',
          installMode: 'prefetch',
          updateMode: 'prefetch',
          urls: [
            '/test/foo/test.html',
            '/test/index.html',
            '/test/main.js',
            '/test/main.ts',
            '/test/test.txt',
          ],
          patterns: [
            '\\/absolute\\/.*',
            '\\/some\\/url\\?with\\+escaped\\+chars',
            '\\/test\\/relative\\/[^/]*\\.txt',
          ],
          cacheQueryOptions: {ignoreVary: true},
        },
      ],
      dataGroups: [
        {
          name: 'other',
          patterns: [
            '\\/api\\/.*',
            '\\/test\\/relapi\\/.*',
            'https:\\/\\/example\\.com\\/(?:.+\\/)?[^/]*\\?with\\+escaped\\+chars',
          ],
          strategy: 'performance',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: 60000,
          version: 1,
          refreshAheadMs: undefined,
          cacheOpaqueResponses: undefined,
          cacheQueryOptions: {ignoreVary: true},
        },
      ],
      navigationUrls: [
        {positive: true, regex: '^\\/included\\/absolute\\/.*$'},
        {positive: false, regex: '^\\/excluded\\/absolute\\/.*$'},
        {positive: true, regex: '^\\/included\\/some\\/url\\/with\\+escaped\\+chars$'},
        {positive: false, regex: '^\\/test\\/excluded\\/relative\\/[^/]*\\.txt$'},
        {positive: false, regex: '^\\/api\\/[^/][^/]*$'},
        {positive: true, regex: '^http:\\/\\/example\\.com\\/included$'},
        {positive: false, regex: '^http:\\/\\/example\\.com\\/excluded$'},
      ],
      navigationRequestStrategy: 'performance',
      applicationMaxAge: 86400000,
      hashTable: {
        '/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643',
        '/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19',
        '/test/main.js': '41347a66676cdc0516934c76d9d13010df420f2c',
        '/test/main.ts': '7d333e31f0bfc4f8152732bb211a93629484c035',
        '/test/test.txt': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643',
      },
    });
  });

  it('assigns files to the first matching asset-group (unaffected by file-system access delays)', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
      '/foo/script-1.js': 'This is script 1',
      '/foo/script-2.js': 'This is script 2',
      '/bar/script-3.js': 'This is script 3',
      '/bar/script-4.js': 'This is script 4',
      '/qux/script-5.js': 'This is script 5',
    });

    // Simulate fluctuating file-system access delays.
    const allFiles = await fs.list('/');
    spyOn(fs, 'list').and.returnValues(
      new Promise((resolve) => setTimeout(resolve, 2000, allFiles.slice())),
      new Promise((resolve) => setTimeout(resolve, 3000, allFiles.slice())),
      new Promise((resolve) => setTimeout(resolve, 1000, allFiles.slice())),
    );

    const gen = new Generator(fs, '');
    const config = await gen.process({
      index: '/index.html',
      assetGroups: [
        {
          name: 'group-foo',
          resources: {files: ['/foo/**/*.js']},
        },
        {
          name: 'group-bar',
          resources: {files: ['/bar/**/*.js']},
        },
        {
          name: 'group-fallback',
          resources: {files: ['/**/*.js']},
        },
      ],
    });

    expect(config).toEqual({
      configVersion: 1,
      timestamp: 1234567890123,
      appData: undefined,
      index: '/index.html',
      assetGroups: [
        {
          name: 'group-foo',
          installMode: 'prefetch',
          updateMode: 'prefetch',
          cacheQueryOptions: {ignoreVary: true},
          urls: ['/foo/script-1.js', '/foo/script-2.js'],
          patterns: [],
        },
        {
          name: 'group-bar',
          installMode: 'prefetch',
          updateMode: 'prefetch',
          cacheQueryOptions: {ignoreVary: true},
          urls: ['/bar/script-3.js', '/bar/script-4.js'],
          patterns: [],
        },
        {
          name: 'group-fallback',
          installMode: 'prefetch',
          updateMode: 'prefetch',
          cacheQueryOptions: {ignoreVary: true},
          urls: ['/qux/script-5.js'],
          patterns: [],
        },
      ],
      dataGroups: [],
      hashTable: {
        '/bar/script-3.js': 'bc0a9b488b5707757c491ddac66f56304310b6b1',
        '/bar/script-4.js': 'b7782e97a285f1f6e62feca842384babaa209040',
        '/foo/script-1.js': '3cf257d7ef7e991898f8506fd408cab4f0c2de91',
        '/foo/script-2.js': '9de2ba54065bb9d610bce51beec62e35bea870a7',
        '/qux/script-5.js': '3dceafdc0a1b429718e45fbf8e3005dd767892de',
      },
      navigationUrls: [
        {positive: true, regex: '^\\/.*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
      ],
      navigationRequestStrategy: 'performance',
      applicationMaxAge: undefined,
    });
  });

  it('uses default `navigationUrls` if not provided', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
    });
    const gen = new Generator(fs, '/test');
    const config = await gen.process({
      index: '/index.html',
    });

    expect(config).toEqual({
      configVersion: 1,
      timestamp: 1234567890123,
      appData: undefined,
      index: '/test/index.html',
      assetGroups: [],
      dataGroups: [],
      navigationUrls: [
        {positive: true, regex: '^\\/.*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
      ],
      navigationRequestStrategy: 'performance',
      applicationMaxAge: undefined,
      hashTable: {},
    });
  });

  it('throws if the obsolete `versionedFiles` is used', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
      '/main.js': 'This is a JS file',
    });
    const gen = new Generator(fs, '/test');

    try {
      await gen.process({
        index: '/index.html',
        assetGroups: [
          {
            name: 'test',
            resources: {
              files: ['/*.html'],
              versionedFiles: ['/*.js'],
            } as AssetGroup['resources'] & {versionedFiles: string[]},
          },
        ],
      });
      throw new Error("Processing should have failed due to 'versionedFiles'.");
    } catch (err) {
      expect(err).toEqual(
        new Error(
          "Asset-group 'test' in 'ngsw-config.json' uses the 'versionedFiles' option, " +
            "which is no longer supported. Use 'files' instead.",
        ),
      );
    }
  });

  it('generates a correct config with `cacheOpaqueResponses`', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
    });
    const gen = new Generator(fs, '/');
    const config = await gen.process({
      index: '/index.html',
      dataGroups: [
        {
          name: 'freshness-undefined',
          urls: ['/api/1/**'],
          cacheConfig: {
            maxAge: '3d',
            maxSize: 100,
            strategy: 'freshness',
          },
        },
        {
          name: 'freshness-false',
          urls: ['/api/2/**'],
          cacheConfig: {
            cacheOpaqueResponses: false,
            maxAge: '3d',
            maxSize: 100,
            strategy: 'freshness',
          },
        },
        {
          name: 'freshness-true',
          urls: ['/api/3/**'],
          cacheConfig: {
            cacheOpaqueResponses: true,
            maxAge: '3d',
            maxSize: 100,
            strategy: 'freshness',
          },
        },
        {
          name: 'performance-undefined',
          urls: ['/api/4/**'],
          cacheConfig: {
            maxAge: '3d',
            maxSize: 100,
            strategy: 'performance',
          },
        },
        {
          name: 'performance-false',
          urls: ['/api/5/**'],
          cacheConfig: {
            cacheOpaqueResponses: false,
            maxAge: '3d',
            maxSize: 100,
            strategy: 'performance',
          },
        },
        {
          name: 'performance-true',
          urls: ['/api/6/**'],
          cacheConfig: {
            cacheOpaqueResponses: true,
            maxAge: '3d',
            maxSize: 100,
            strategy: 'performance',
          },
        },
      ],
    });

    expect(config).toEqual({
      configVersion: 1,
      appData: undefined,
      timestamp: 1234567890123,
      index: '/index.html',
      assetGroups: [],
      dataGroups: [
        {
          name: 'freshness-undefined',
          patterns: ['\\/api\\/1\\/.*'],
          strategy: 'freshness',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: undefined,
          cacheQueryOptions: {ignoreVary: true},
        },
        {
          name: 'freshness-false',
          patterns: ['\\/api\\/2\\/.*'],
          strategy: 'freshness',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: false,
          cacheQueryOptions: {ignoreVary: true},
        },
        {
          name: 'freshness-true',
          patterns: ['\\/api\\/3\\/.*'],
          strategy: 'freshness',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: true,
          cacheQueryOptions: {ignoreVary: true},
        },
        {
          name: 'performance-undefined',
          patterns: ['\\/api\\/4\\/.*'],
          strategy: 'performance',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: undefined,
          cacheQueryOptions: {ignoreVary: true},
        },
        {
          name: 'performance-false',
          patterns: ['\\/api\\/5\\/.*'],
          strategy: 'performance',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: false,
          cacheQueryOptions: {ignoreVary: true},
        },
        {
          name: 'performance-true',
          patterns: ['\\/api\\/6\\/.*'],
          strategy: 'performance',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: undefined,
          refreshAheadMs: undefined,
          version: 1,
          cacheOpaqueResponses: true,
          cacheQueryOptions: {ignoreVary: true},
        },
      ],
      navigationUrls: [
        {positive: true, regex: '^\\/.*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
      ],
      navigationRequestStrategy: 'performance',
      applicationMaxAge: undefined,
      hashTable: {},
    });
  });

  it('generates a correct config with `cacheQueryOptions`', async () => {
    const fs = new MockFilesystem({
      '/index.html': 'This is a test',
      '/main.js': 'This is a JS file',
    });
    const gen = new Generator(fs, '/');
    const config = await gen.process({
      index: '/index.html',
      assetGroups: [
        {
          name: 'test',
          resources: {
            files: ['/**/*.html', '/**/*.?s'],
          },
          cacheQueryOptions: {ignoreSearch: true},
        },
      ],
      dataGroups: [
        {
          name: 'other',
          urls: ['/api/**'],
          cacheConfig: {
            maxAge: '3d',
            maxSize: 100,
            strategy: 'performance',
            timeout: '1m',
            refreshAhead: '1h',
          },
          cacheQueryOptions: {ignoreSearch: false},
        },
      ],
    });

    expect(config).toEqual({
      configVersion: 1,
      appData: undefined,
      timestamp: 1234567890123,
      index: '/index.html',
      assetGroups: [
        {
          name: 'test',
          installMode: 'prefetch',
          updateMode: 'prefetch',
          urls: ['/index.html', '/main.js'],
          patterns: [],
          cacheQueryOptions: {ignoreSearch: true, ignoreVary: true},
        },
      ],
      dataGroups: [
        {
          name: 'other',
          patterns: ['\\/api\\/.*'],
          strategy: 'performance',
          maxSize: 100,
          maxAge: 259200000,
          timeoutMs: 60000,
          refreshAheadMs: 3600000,
          version: 1,
          cacheOpaqueResponses: undefined,
          cacheQueryOptions: {ignoreSearch: false, ignoreVary: true},
        },
      ],
      navigationUrls: [
        {positive: true, regex: '^\\/.*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
      ],
      navigationRequestStrategy: 'performance',
      applicationMaxAge: undefined,
      hashTable: {
        '/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19',
        '/main.js': '41347a66676cdc0516934c76d9d13010df420f2c',
      },
    });
  });

  it("doesn't exceed concurrency limit", async () => {
    const fileCount = 600;
    const files = [...Array(fileCount).keys()].reduce(
      (acc: Record<string, string>, _, i) => {
        acc[`/test${i}.js`] = `This is a test ${i}`;
        return acc;
      },
      {'/index.html': 'This is a test'},
    );
    const fs = new HashTrackingMockFilesystem(files);
    const gen = new Generator(fs, '/');
    const config = await gen.process({
      index: '/index.html',
      assetGroups: [
        {
          name: 'test',
          resources: {files: ['/*.js']},
        },
      ],
    });
    expect(fs.maxConcurrentHashings).toBeLessThanOrEqual(500);
    expect(fs.maxConcurrentHashings).toBeGreaterThan(1);
    expect(Object.keys((config as any).hashTable).length).toBe(fileCount);
  });

  describe('processNavigationUrls()', () => {
    const customNavigationUrls = [
      'https://host/positive/external/**',
      '!https://host/negative/external/**',
      '/positive/absolute/**',
      '!/negative/absolute/**',
      'positive/relative/**',
      '!negative/relative/**',
    ];

    it('uses the default `navigationUrls` if not provided', () => {
      expect(processNavigationUrls('/')).toEqual([
        {positive: true, regex: '^\\/.*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
        {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
      ]);
    });

    it('prepends `baseHref` to relative URL patterns only', () => {
      expect(processNavigationUrls('/base/href/', customNavigationUrls)).toEqual([
        {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'},
        {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'},
        {positive: true, regex: '^\\/positive\\/absolute\\/.*$'},
        {positive: false, regex: '^\\/negative\\/absolute\\/.*$'},
        {positive: true, regex: '^\\/base\\/href\\/positive\\/relative\\/.*$'},
        {positive: false, regex: '^\\/base\\/href\\/negative\\/relative\\/.*$'},
      ]);
    });

    it('strips a leading single `.` from a relative `baseHref`', () => {
      expect(processNavigationUrls('./relative/base/href/', customNavigationUrls)).toEqual([
        {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'},
        {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'},
        {positive: true, regex: '^\\/positive\\/absolute\\/.*$'},
        {positive: false, regex: '^\\/negative\\/absolute\\/.*$'},
        {positive: true, regex: '^\\/relative\\/base\\/href\\/positive\\/relative\\/.*$'},
        {positive: false, regex: '^\\/relative\\/base\\/href\\/negative\\/relative\\/.*$'},
      ]);

      // We can't correctly handle double dots in `baseHref`, so leave them as literal matches.
      expect(processNavigationUrls('../double/dots/', customNavigationUrls)).toEqual([
        {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'},
        {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'},
        {positive: true, regex: '^\\/positive\\/absolute\\/.*$'},
        {positive: false, regex: '^\\/negative\\/absolute\\/.*$'},
        {positive: true, regex: '^\\.\\.\\/double\\/dots\\/positive\\/relative\\/.*$'},
        {positive: false, regex: '^\\.\\.\\/double\\/dots\\/negative\\/relative\\/.*$'},
      ]);
    });
  });
});
