import {createHash} from 'crypto';
import fs from 'fs';
import path from 'path';
import * as cache from '@actions/cache';
import * as core from '@actions/core';
import {
  ChecksumMetadata,
  JavaDownloadRelease
} from './distributions/base-models.js';

const STATE_JDK_RESOLUTIONS = 'jdk-resolutions';
const JDK_RESOLUTION_KEY_VERSION = 1;
const RESOLUTION_DIRECTORY = 'setup-java-jdk-resolution';
const RESOLUTION_FILE_NAME = 'release.json';

/**
 * Everything that identifies a resolution request before any remote metadata is
 * fetched. Distribution-specific inputs are already folded into `distribution`
 * (for example Temurin's `jvm-impl`) or `packageType` (`jdk+jmods`), so these
 * fields fully determine which artifact a distribution would resolve.
 */
export interface JdkResolutionRequest {
  distribution: string;
  packageType: string;
  architecture: string;
  versionSpec: string;
  stable: boolean;
}

export interface RestoredJdkResolution {
  release: JavaDownloadRelease;
  /**
   * Whether the entry was written within the current freshness window. A stale
   * entry is only a fallback for the case where the vendor metadata API is
   * unreachable, so a floating version spec cannot be pinned indefinitely.
   */
  fresh: boolean;
}

interface JdkResolutionState {
  key: string;
  path: string;
  /**
   * The payload the key was computed for. A restore in a later step writes to
   * the same path, so the post-job save rewrites the file from state instead of
   * uploading whatever happens to be on disk.
   */
  release: string;
}

const pendingResolutions: JdkResolutionState[] = [];

/**
 * Restores a previously resolved release so a distribution can skip its vendor
 * metadata API.
 *
 * The cache path deliberately excludes the freshness window: `@actions/cache`
 * derives
 * a cache version by hashing the requested paths, so a bucket-independent path
 * is what allows the restore keys to fall back to an older bucket.
 */
export async function restoreJdkResolution(
  request: JdkResolutionRequest
): Promise<RestoredJdkResolution | undefined> {
  // Deliberately not `isCacheFeatureAvailable()`: this is an optional
  // optimization, and the JDK cache already warns once when the service is
  // unreachable.
  if (!cache.isFeatureAvailable()) {
    return undefined;
  }

  const cachePath = getResolutionCachePath(request);
  if (!cachePath) {
    return undefined;
  }

  const keyPrefix = getResolutionKeyPrefix(request);
  const primaryKey = `${keyPrefix}${getFreshnessBucket()}`;

  let matchedKey: string | undefined;
  try {
    matchedKey = await cache.restoreCache([cachePath], primaryKey, [keyPrefix]);
  } catch (error) {
    core.debug(
      `Failed to restore the JDK resolution cache: ${getErrorMessage(error)}`
    );
    return undefined;
  }

  if (!matchedKey) {
    return undefined;
  }

  let release: JavaDownloadRelease;
  try {
    const contents = fs.readFileSync(
      path.join(cachePath, RESOLUTION_FILE_NAME),
      'utf8'
    );
    release = parseResolvedRelease(contents);
  } catch (error) {
    core.debug(
      `Ignoring the JDK resolution cache entry ${matchedKey}: ${getErrorMessage(error)}`
    );
    return undefined;
  }

  return {release, fresh: matchedKey === primaryKey};
}

/**
 * Persists a freshly resolved release for later jobs. The entry is written to
 * disk immediately and uploaded by the post-job step.
 */
export function registerJdkResolution(
  request: JdkResolutionRequest,
  release: JavaDownloadRelease
): void {
  if (!cache.isFeatureAvailable()) {
    return;
  }

  const cachePath = getResolutionCachePath(request);
  if (!cachePath) {
    return;
  }

  const payload = JSON.stringify(release);
  try {
    fs.mkdirSync(cachePath, {recursive: true});
    fs.writeFileSync(path.join(cachePath, RESOLUTION_FILE_NAME), payload);
  } catch (error) {
    core.debug(
      `Failed to record the JDK resolution cache entry: ${getErrorMessage(error)}`
    );
    return;
  }

  const key = `${getResolutionKeyPrefix(request)}${getFreshnessBucket()}`;
  if (!pendingResolutions.some(item => item.key === key)) {
    pendingResolutions.push({key, path: cachePath, release: payload});
  }
  core.saveState(STATE_JDK_RESOLUTIONS, JSON.stringify(pendingResolutions));
}

export async function saveJdkResolutionCaches(): Promise<void> {
  const state = core.getState(STATE_JDK_RESOLUTIONS);
  if (!state) {
    return;
  }

  let resolutions: JdkResolutionState[];
  try {
    resolutions = parseJdkResolutionState(state);
  } catch (error) {
    core.debug(
      `Invalid JDK resolution cache state, not saving: ${getErrorMessage(error)}`
    );
    return;
  }

  for (const resolution of resolutions) {
    // A restore performed by a later step overwrites this path, so the payload
    // the key was computed for is written again rather than trusted to still be
    // on disk.
    try {
      fs.mkdirSync(resolution.path, {recursive: true});
      fs.writeFileSync(
        path.join(resolution.path, RESOLUTION_FILE_NAME),
        resolution.release
      );
    } catch (error) {
      core.debug(
        `Failed to write the JDK resolution cache entry for the key ${resolution.key}: ${getErrorMessage(error)}`
      );
      continue;
    }

    try {
      await cache.saveCache([resolution.path], resolution.key);
    } catch (error) {
      // A matrix of jobs resolving the same JDK races on the same daily key, so
      // an already-reserved key is the expected outcome rather than a problem.
      core.debug(
        `Failed to save the JDK resolution cache with the key ${resolution.key}: ${getErrorMessage(error)}`
      );
    }
  }
}

function getResolutionCachePath(
  request: JdkResolutionRequest
): string | undefined {
  const runnerTemp = process.env['RUNNER_TEMP'];
  if (!runnerTemp) {
    return undefined;
  }
  return path.join(
    runnerTemp,
    RESOLUTION_DIRECTORY,
    getResolutionIdentity(request)
  );
}

function getResolutionIdentity(request: JdkResolutionRequest): string {
  const identity = JSON.stringify({
    keyVersion: JDK_RESOLUTION_KEY_VERSION,
    runnerOs: getRunnerOs(),
    distribution: request.distribution.toLowerCase(),
    packageType: request.packageType.toLowerCase(),
    architecture: request.architecture.toLowerCase(),
    versionSpec: request.versionSpec,
    stable: request.stable
  });
  return createHash('sha256').update(identity).digest('hex');
}

function getResolutionKeyPrefix(request: JdkResolutionRequest): string {
  const architecture = request.architecture.toLowerCase();
  const digest = getResolutionIdentity(request);
  return `setup-java-jdkres-v${JDK_RESOLUTION_KEY_VERSION}-${getRunnerOs()}-${architecture}-${digest}-`;
}

function getRunnerOs(): string {
  return process.env['RUNNER_OS'] ?? process.platform;
}

/**
 * Start of the seven-day window the entry was resolved in, which bounds how long
 * a floating version spec such as `21` can keep resolving to an already known
 * release.
 *
 * Seven days is the longest usable window: GitHub evicts cache entries that have
 * not been accessed for seven days, so a longer one would mean the previous
 * entry is already gone when the window rolls over, taking the stale-fallback
 * path with it. It also comfortably covers the real release cadence, which is
 * monthly at its fastest and usually quarterly.
 */
function getFreshnessBucket(): string {
  const week = 7 * 24 * 60 * 60 * 1000;
  return new Date(Math.floor(Date.now() / week) * week)
    .toISOString()
    .slice(0, 10);
}

/**
 * The restored payload drives a download, so it is validated as untrusted input
 * rather than trusted because it came back from the cache service.
 */
function parseResolvedRelease(contents: string): JavaDownloadRelease {
  const value: unknown = JSON.parse(contents);
  if (typeof value !== 'object' || value === null) {
    throw new Error('The cached resolution is not an object.');
  }

  const candidate = value as Record<string, unknown>;
  const version = candidate['version'];
  const url = candidate['url'];
  const signatureUrl = candidate['signatureUrl'];

  if (typeof version !== 'string' || !version) {
    throw new Error('The cached resolution has no version.');
  }
  assertHttpsUrl(url, 'url');
  if (signatureUrl !== undefined) {
    assertHttpsUrl(signatureUrl, 'signatureUrl');
  }

  const release: JavaDownloadRelease = {
    version,
    url: url as string
  };
  if (signatureUrl !== undefined) {
    release.signatureUrl = signatureUrl as string;
  }

  const checksum = candidate['checksum'];
  if (checksum !== undefined) {
    release.checksum = parseChecksum(checksum);
  }

  return release;
}

function parseChecksum(value: unknown): ChecksumMetadata {
  if (typeof value !== 'object' || value === null) {
    throw new Error('The cached checksum is not an object.');
  }

  const candidate = value as Record<string, unknown>;
  const algorithm = candidate['algorithm'];
  const checksumValue = candidate['value'];
  const source = candidate['source'];

  if (algorithm !== 'sha256' && algorithm !== 'sha512') {
    throw new Error(`Unsupported cached checksum algorithm: ${algorithm}`);
  }
  if (typeof checksumValue !== 'string' || !checksumValue) {
    throw new Error('The cached checksum has no value.');
  }
  if (source !== undefined && typeof source !== 'string') {
    throw new Error('The cached checksum source is not a string.');
  }

  const checksum: ChecksumMetadata = {algorithm, value: checksumValue};
  if (source !== undefined) {
    checksum.source = source;
  }
  return checksum;
}

function assertHttpsUrl(value: unknown, field: string): void {
  if (typeof value !== 'string' || !value) {
    throw new Error(`The cached resolution has no ${field}.`);
  }
  let parsed: URL;
  try {
    parsed = new URL(value);
  } catch {
    throw new Error(`The cached resolution has a malformed ${field}.`);
  }
  if (parsed.protocol !== 'https:') {
    throw new Error(
      `The cached resolution ${field} does not use HTTPS: ${parsed.protocol}`
    );
  }
}

function parseJdkResolutionState(state: string): JdkResolutionState[] {
  const value: unknown = JSON.parse(state);
  if (
    !Array.isArray(value) ||
    !value.every(
      item =>
        typeof item === 'object' &&
        item !== null &&
        typeof (item as JdkResolutionState).key === 'string' &&
        typeof (item as JdkResolutionState).path === 'string' &&
        typeof (item as JdkResolutionState).release === 'string'
    )
  ) {
    throw new Error('Invalid JDK resolution information retrieved from state.');
  }
  return value as JdkResolutionState[];
}

function getErrorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}
