/*********************************************************************
 * Copyright (C) 2025 Red Hat, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 ********************************************************************/

import { existsSync } from 'node:fs';
import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { beforeEach, describe, expect, test, vi } from 'vitest';

import { ImageRegistry } from '/@/plugin/image-registry.js';
import product from '/@product.json' with { type: 'json' };

import type { RemoteExtension } from './download-remote-extensions.js';
import {
  DIGEST_FILENAME,
  downloadExtension,
  getRemoteExtensionFromProductJSON,
  main,
  NAME_ARG,
  OCI_ARG,
  parseArgs,
} from './download-remote-extensions.js';

vi.mock(import('node:fs/promises'));
vi.mock(import('node:fs'));
vi.mock(import('node:os'));
vi.mock(import('/@/plugin/image-registry.js'));
vi.mock(import('/@product.json'));

const TMP_DIR = 'tmp-dir';
const ABS_DEST_DIR = '/dest-dir';
const REMOTE_INFO_MOCK: RemoteExtension = {
  name: 'dummy-extension',
  oci: 'localhost/dummy-extension:latest',
};

const MANIFEST_MOCK = {
  config: {
    digest: 'sha256:00',
  },
};

const TMP_EXTENSION_DEST = join(TMP_DIR, REMOTE_INFO_MOCK.name);
const TMP_EXTENSION_PACKAGE_JSON = join(TMP_EXTENSION_DEST, 'extension', 'package.json');

const FINAL_EXTENSION_DEST = join(ABS_DEST_DIR, REMOTE_INFO_MOCK.name);
const FINAL_EXTENSION_DIGEST_FILE = join(FINAL_EXTENSION_DEST, DIGEST_FILENAME);

beforeEach(() => {
  vi.resetAllMocks();

  vi.mocked(tmpdir).mockReturnValue(TMP_DIR);
  (vi.mocked(product).extensions.remote as RemoteExtension[]) = [];

  vi.mocked(ImageRegistry.prototype.getManifestFromImageName).mockResolvedValue(MANIFEST_MOCK);

  // mock final check to be valid
  vi.mocked(existsSync).mockImplementation(path => {
    switch (path) {
      case TMP_EXTENSION_PACKAGE_JSON:
        return true;
      default:
        return false;
    }
  });
});

describe('downloadExtension', () => {
  test('should call ImageRegistry#downloadAndExtractImage with appropriate argument', async () => {
    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    expect(ImageRegistry.prototype.downloadAndExtractImage).toHaveBeenCalledExactlyOnceWith(
      REMOTE_INFO_MOCK.oci,
      expect.stringContaining(TMP_DIR),
      expect.any(Function),
    );
  });

  test('cache hit should not re-download', async () => {
    // mock cache hit
    vi.mocked(readFile).mockResolvedValue(MANIFEST_MOCK.config.digest);

    // mock final check to be valid
    vi.mocked(existsSync).mockImplementation(path => {
      switch (path) {
        case FINAL_EXTENSION_DEST: // mock  the final destination folder already exists
          return true;
        default:
          return false;
      }
    });

    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    // ensure we read the digest file
    expect(readFile).toHaveBeenCalledExactlyOnceWith(FINAL_EXTENSION_DIGEST_FILE, {
      encoding: 'utf-8',
    });
    // ensure we did not call ImageRegistry#downloadAndExtractImage
    expect(ImageRegistry.prototype.downloadAndExtractImage).not.toHaveBeenCalled();
  });

  test('different digest should rm & download OCI', async () => {
    // mock cache hit
    vi.mocked(readFile).mockResolvedValue('sha256:01');

    // mock final check to be valid
    vi.mocked(existsSync).mockImplementation(path => {
      switch (path) {
        case TMP_EXTENSION_PACKAGE_JSON: // mock the tmp extension package.json already exists
        case FINAL_EXTENSION_DEST: // mock the final destination folder already exists
          return true;
        default:
          return false;
      }
    });

    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    // ensure we read the digest file
    expect(readFile).toHaveBeenCalledOnce();

    // ensure we rm the final destination
    expect(rm).toHaveBeenCalledExactlyOnceWith(FINAL_EXTENSION_DEST, {
      recursive: true,
    });

    // ensure we did call ImageRegistry#downloadAndExtractImage
    expect(ImageRegistry.prototype.downloadAndExtractImage).toHaveBeenCalled();
  });

  test('should rename tmp directory to destination', async () => {
    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    expect(rename).toHaveBeenCalledExactlyOnceWith(
      join(TMP_DIR, REMOTE_INFO_MOCK.name, 'extension'),
      join(ABS_DEST_DIR, REMOTE_INFO_MOCK.name),
    );

    expect(cp).not.toHaveBeenCalled();
    expect(rm).not.toHaveBeenCalled();
  });

  test('should write digest file to destination', async () => {
    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    expect(writeFile).toHaveBeenCalledExactlyOnceWith(FINAL_EXTENSION_DIGEST_FILE, MANIFEST_MOCK.config.digest, {
      encoding: 'utf-8',
    });
  });

  test('if rename throw ErrnoException', async () => {
    vi.mocked(rename).mockRejectedValue({
      code: 'EXDEV',
    } as NodeJS.ErrnoException);

    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    const tmpFolder = join(TMP_DIR, REMOTE_INFO_MOCK.name, 'extension');

    expect(rename).toHaveBeenCalledOnce();
    expect(cp).toHaveBeenCalledExactlyOnceWith(tmpFolder, join(ABS_DEST_DIR, REMOTE_INFO_MOCK.name), {
      recursive: true,
    });
    expect(rm).toHaveBeenCalledExactlyOnceWith(tmpFolder, {
      recursive: true,
    });
  });

  test('should mkdir the destination directory', async () => {
    await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);

    expect(mkdir).toHaveBeenCalledExactlyOnceWith(ABS_DEST_DIR, {
      recursive: true,
    });
  });

  test('extracted content without package.json should throw an error', async () => {
    // mock existsSync for path ending with 'package.json' should return false
    vi.mocked(existsSync).mockImplementation(path => {
      return !String(path).endsWith('package.json');
    });

    await expect(async () => {
      await downloadExtension(ABS_DEST_DIR, REMOTE_INFO_MOCK);
    }).rejects.toThrowError(
      `extension ${REMOTE_INFO_MOCK.name} has malformed content: the OCI image should contains a "package.json" file in the extension folder`,
    );
  });
});

describe('parseArgs', () => {
  test.each<{
    name: string;
    args: unknown[];
    error: string;
  }>([
    {
      name: 'missing --output should throw an error',
      args: [],
      error: 'missing output argument',
    },
    {
      name: 'non absolute --output should throw an error',
      args: ['--output', './foo'],
      error: 'the output should be an absolute directory',
    },
    // missing one arg
    {
      name: `having --${NAME_ARG} without --${OCI_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${NAME_ARG}`, 'foo'],
      error: `when specifying --${OCI_ARG} or --${NAME_ARG}, both should be provided as valid string`,
    },
    {
      name: `having --${OCI_ARG} without --${NAME_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${OCI_ARG}`, 'ghcr.io/org/my-user:latest'],
      error: `when specifying --${OCI_ARG} or --${NAME_ARG}, both should be provided as valid string`,
    },
    // zero length args
    {
      name: `zero length --${NAME_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${NAME_ARG}`, '', `--${OCI_ARG}`, 'ghcr.io/org/foo:latest'],
      error: `when specifying --${OCI_ARG} or --${NAME_ARG}, both should be provided as valid string`,
    },
    {
      name: `zero length --${OCI_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${OCI_ARG}`, '', `--${NAME_ARG}`, 'foo'],
      error: `when specifying --${OCI_ARG} or --${NAME_ARG}, both should be provided as valid string`,
    },
    // too many args
    {
      name: `multiple --${OCI_ARG} should throw an error`,
      args: [
        '--output',
        ABS_DEST_DIR,
        `--${OCI_ARG}`,
        'ghcr.io/org/foo:latest',
        `--${OCI_ARG}`,
        'ghcr.io/org/bar:latest',
        `--${NAME_ARG}`,
        'foo',
      ],
      error: `when specifying --${OCI_ARG} and --${NAME_ARG}, only one is allowed`,
    },
    {
      name: `multiple --${NAME_ARG} should throw an error`,
      args: [
        '--output',
        ABS_DEST_DIR,
        `--${OCI_ARG}`,
        'ghcr.io/org/foo:latest',
        `--${NAME_ARG}`,
        'foo',
        `--${NAME_ARG}`,
        'bar',
      ],
      error: `when specifying --${OCI_ARG} and --${NAME_ARG}, only one is allowed`,
    },
    // wrong type args
    {
      name: `boolean --${NAME_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${NAME_ARG}`, true, `--${OCI_ARG}`, 'ghcr.io/org/foo:latest'],
      error: `when specifying --${OCI_ARG} and --${NAME_ARG}, should be valid strings`,
    },
    {
      name: `boolean --${OCI_ARG} should throw an error`,
      args: ['--output', ABS_DEST_DIR, `--${NAME_ARG}`, 'foo', `--${OCI_ARG}`, true],
      error: `when specifying --${OCI_ARG} and --${NAME_ARG}, should be valid strings`,
    },
  ])('$name', ({ args, error }) => {
    expect(() => {
      parseArgs(args as string[]);
    }).toThrowError(error);
  });

  test(`should handle no --${NAME_ARG} & --${OCI_ARG} args`, () => {
    expect(parseArgs(['--output', ABS_DEST_DIR])).toStrictEqual({
      output: ABS_DEST_DIR,
    });
  });

  test('should handle single string value for --oci arg', () => {
    expect(
      parseArgs([
        // output arg
        '--output',
        ABS_DEST_DIR,
        // oci arg
        `--${OCI_ARG}`,
        'ghcr.io/org/my-user:latest',
        // name arg
        `--${NAME_ARG}`,
        'my-user',
      ]),
    ).toStrictEqual({
      output: ABS_DEST_DIR,
      extension: {
        oci: 'ghcr.io/org/my-user:latest',
        name: 'my-user',
      },
    });
  });
});

describe('getRemoteExtensionFromProductJSON', () => {
  test('expect to read product.json#extensions#remote', () => {
    (vi.mocked(product).extensions.remote as RemoteExtension[]) = [REMOTE_INFO_MOCK];

    expect(getRemoteExtensionFromProductJSON()).toStrictEqual([REMOTE_INFO_MOCK]);
  });
});

describe('main', () => {
  test(`cli args should be preferred to product.json`, async () => {
    (vi.mocked(product).extensions.remote as RemoteExtension[]) = [REMOTE_INFO_MOCK];

    await main([
      '--output',
      ABS_DEST_DIR,
      `--${OCI_ARG}`,
      'localhost/dummy-extension:latest',
      `--${NAME_ARG}`,
      'dummy-extension',
    ]);

    expect(rename).toHaveBeenCalledExactlyOnceWith(
      join(TMP_DIR, 'dummy-extension', 'extension'),
      join(ABS_DEST_DIR, 'dummy-extension'),
    );
  });

  test('empty remoteExtensions in product.json should not call any ImageRegistry#downloadAndExtractImage', async () => {
    expect(ImageRegistry.prototype.downloadAndExtractImage).not.toHaveBeenCalled();

    await main(['--output', ABS_DEST_DIR]);

    expect(ImageRegistry.prototype.downloadAndExtractImage).not.toHaveBeenCalled();
  });

  test('--output argument should be used as destination', async () => {
    (vi.mocked(product).extensions.remote as RemoteExtension[]) = [REMOTE_INFO_MOCK];

    await main(['--output', ABS_DEST_DIR]);

    expect(rename).toHaveBeenCalledExactlyOnceWith(
      join(TMP_DIR, REMOTE_INFO_MOCK.name, 'extension'),
      join(ABS_DEST_DIR, REMOTE_INFO_MOCK.name),
    );
  });
});
