// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { Buffer } from 'node:buffer';
import path from 'node:path';
import util from 'node:util';
import { TSDocConfiguration } from '@microsoft/tsdoc';
import { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference.js';
import { TSDocConfigFile } from '@microsoft/tsdoc-config';
import {
	JsonFile,
	type IJsonFileSaveOptions,
	PackageJsonLookup,
	type IPackageJson,
	type JsonObject,
	FileSystem,
} from '@rushstack/node-core-library';
import { ApiDocumentedItem, type IApiDocumentedItemOptions } from '../items/ApiDocumentedItem.js';
import { ApiItem, ApiItemKind, type IApiItemJson } from '../items/ApiItem.js';
import { ApiItemContainerMixin, type IApiItemContainerMixinOptions } from '../mixins/ApiItemContainerMixin.js';
import { type IApiNameMixinOptions, ApiNameMixin } from '../mixins/ApiNameMixin.js';
import type { ApiEntryPoint } from './ApiEntryPoint.js';
import { DeserializerContext, ApiJsonSchemaVersion } from './DeserializerContext.js';

/**
 * Constructor options for {@link ApiPackage}.
 *
 * @public
 */
export interface IApiPackageOptions
	extends IApiItemContainerMixinOptions, IApiNameMixinOptions, IApiDocumentedItemOptions {
	dependencies?: Record<string, string> | undefined;
	projectFolderUrl?: string | undefined;
	tsdocConfiguration: TSDocConfiguration;
}

const MinifyJSONMapping = {
	canonicalReference: 'c',
	constraintTokenRange: 'ctr',
	dependencies: 'dp',
	defaultTypeTokenRange: 'dtr',
	defaultValue: 'dv',
	docComment: 'd',
	endIndex: 'en',
	excerptTokens: 'ex',
	extendsTokenRange: 'etr',
	extendsTokenRanges: 'etrs',
	fileColumn: 'co',
	fileLine: 'l',
	fileUrlPath: 'pat',
	implementsTokenRanges: 'itrs',
	initializerTokenRange: 'itr',
	isAbstract: 'ab',
	isOptional: 'op',
	isProtected: 'pr',
	isReadonly: 'ro',
	isRest: 'rs',
	isStatic: 'sta',
	kind: 'k',
	members: 'ms',
	metadata: 'meta',
	name: 'n',
	oldestForwardsCompatibleVersion: 'ov',
	overloadIndex: 'oi',
	parameterName: 'pn',
	parameterTypeTokenRange: 'ptr',
	parameters: 'ps',
	preserveMemberOrder: 'pmo',
	projectFolderUrl: 'pdir',
	propertyTypeTokenRange: 'prtr',
	releaseTag: 'r',
	returnTypeTokenRange: 'rtr',
	schemaVersion: 'v',
	startIndex: 'st',
	text: 't',
	toolPackage: 'tpk',
	toolVersion: 'tv',
	tsdocConfig: 'ts',
	typeParameterName: 'tp',
	typeParameters: 'tps',
	typeTokenRange: 'ttr',
	variableTypeTokenRange: 'vtr',
};

export interface IApiPackageMetadataJson {
	/**
	 * To support forwards compatibility, the `oldestForwardsCompatibleVersion` field tracks the oldest schema version
	 * whose corresponding deserializer could safely load this file.
	 *
	 * @remarks
	 * Normally api-extractor-model should refuse to load a schema version that is newer than the latest version
	 * that its deserializer understands.  However, sometimes a schema change may merely introduce some new fields
	 * without modifying or removing any existing fields.  In this case, an older api-extractor-model library can
	 * safely deserialize the newer version (by ignoring the extra fields that it doesn't recognize).  The newer
	 * serializer can use this field to communicate that.
	 *
	 * If present, the `oldestForwardsCompatibleVersion` must be less than or equal to
	 * `IApiPackageMetadataJson.schemaVersion`.
	 */
	oldestForwardsCompatibleVersion?: ApiJsonSchemaVersion;

	/**
	 * The schema version for the .api.json file format.  Used for determining whether the file format is
	 * supported, and for backwards compatibility.
	 */
	schemaVersion: ApiJsonSchemaVersion;

	/**
	 * The NPM package name for the tool that wrote the *.api.json file.
	 * For informational purposes only.
	 */
	toolPackage: string;

	/**
	 * The NPM package version for the tool that wrote the *.api.json file.
	 * For informational purposes only.
	 */
	toolVersion: string;

	/**
	 * The TSDoc configuration that was used when analyzing the API for this package.
	 *
	 * @remarks
	 *
	 * The structure of this objet is defined by the `@microsoft/tsdoc-config` library.
	 * Normally this configuration is loaded from the project's tsdoc.json file.  It is stored
	 * in the .api.json file so that doc comments can be parsed accurately when loading the file.
	 */
	tsdocConfig?: JsonObject;
}

export interface IApiPackageJson extends IApiItemJson {
	/**
	 * Names of packages in the same monorepo this one uses mapped to the version of said package.
	 */
	dependencies?: Record<string, string>;

	/**
	 * A file header that stores metadata about the tool that wrote the *.api.json file.
	 */
	metadata: IApiPackageMetadataJson;

	/**
	 * The base URL where the project's source code can be viewed on a website such as GitHub or
	 * Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk. Provided via the
	 * `api-extractor.json` config.
	 */
	projectFolderUrl?: string;
}

/**
 * Options for {@link ApiPackage.saveToJsonFile}.
 *
 * @public
 */
export interface IApiPackageSaveOptions extends IJsonFileSaveOptions {
	/**
	 * Set to true to not have indentation or newlines in resulting JSON.
	 */
	minify?: boolean;

	/**
	 * Set to true only when invoking API Extractor's test harness.
	 *
	 * @remarks
	 * When `testMode` is true, the `toolVersion` field in the .api.json file is assigned an empty string
	 * to prevent spurious diffs in output files tracked for tests.
	 */
	testMode?: boolean;

	/**
	 * Optionally specifies a value for the "toolPackage" field in the output .api.json data file;
	 * otherwise, the value will be "api-extractor-model".
	 */
	toolPackage?: string;

	/**
	 * Optionally specifies a value for the "toolVersion" field in the output .api.json data file;
	 * otherwise, the value will be the current version of the api-extractor-model package.
	 */
	toolVersion?: string;
}

/**
 * Represents an NPM package containing API declarations.
 *
 * @remarks
 *
 * This is part of the {@link ApiModel} hierarchy of classes, which are serializable representations of
 * API declarations.
 * @public
 */
export class ApiPackage extends ApiItemContainerMixin(ApiNameMixin(ApiDocumentedItem)) {
	private readonly _tsdocConfiguration: TSDocConfiguration;

	private readonly _projectFolderUrl?: string | undefined;

	private readonly _dependencies?: Record<string, string> | undefined;

	public constructor(options: IApiPackageOptions) {
		super(options);

		this._tsdocConfiguration = options.tsdocConfiguration;
		this._projectFolderUrl = options.projectFolderUrl;

		if (options.dependencies) {
			this._dependencies = options.dependencies;
		} else {
			const packageJson = PackageJsonLookup.instance.tryLoadPackageJsonFor('.');
			if (packageJson?.dependencies) {
				this._dependencies = {};
				for (const [pack, semVer] of Object.entries(packageJson.dependencies)) {
					const pathToPackage = path.join('..', pack.includes('/') ? pack.slice(pack.lastIndexOf('/')) : pack);
					if (semVer === 'workspace:^') {
						this._dependencies[pack] =
							PackageJsonLookup.instance.tryLoadPackageJsonFor(pathToPackage)?.version ?? 'unknown';
					} else {
						// if (FileSystem.exists(pathToPackage))
						this._dependencies[pack] = semVer.replace(/^[\^~]/, '');
					}
				}
			}
		}
	}

	/**
	 * @override
	 */
	public static override onDeserializeInto(
		options: Partial<IApiPackageOptions>,
		context: DeserializerContext,
		jsonObject: IApiPackageJson,
	): void {
		super.onDeserializeInto(options, context, jsonObject);

		options.projectFolderUrl = jsonObject.projectFolderUrl;
		options.dependencies = jsonObject.dependencies;
	}

	public static loadFromJsonFile(apiJsonFilename: string): ApiPackage {
		return this.loadFromJson(JsonFile.load(apiJsonFilename), apiJsonFilename);
	}

	public static loadFromJson(rawJson: any, apiJsonFilename: string = ''): ApiPackage {
		const jsonObject =
			MinifyJSONMapping.metadata in rawJson ? this._mapFromMinified(rawJson) : (rawJson as IApiPackageJson);
		if (!jsonObject?.metadata) throw new Error(util.inspect(rawJson, { depth: 2 }));
		if (!jsonObject?.metadata || typeof jsonObject.metadata.schemaVersion !== 'number') {
			throw new Error(
				`Error loading ${apiJsonFilename}:` +
					`\nThe file format is not recognized; the "metadata.schemaVersion" field is missing or invalid`,
			);
		}

		const schemaVersion: number = jsonObject.metadata.schemaVersion;

		if (schemaVersion < ApiJsonSchemaVersion.OLDEST_SUPPORTED) {
			throw new Error(
				`Error loading ${apiJsonFilename}:` +
					`\nThe file format is version ${schemaVersion},` +
					` whereas ${ApiJsonSchemaVersion.OLDEST_SUPPORTED} is the oldest version supported by this tool`,
			);
		}

		let oldestForwardsCompatibleVersion: number = schemaVersion;
		if (jsonObject.metadata.oldestForwardsCompatibleVersion) {
			// Sanity check
			if (jsonObject.metadata.oldestForwardsCompatibleVersion > schemaVersion) {
				throw new Error(
					`Error loading ${apiJsonFilename}:` +
						`\nInvalid file format; "oldestForwardsCompatibleVersion" cannot be newer than "schemaVersion"`,
				);
			}

			oldestForwardsCompatibleVersion = jsonObject.metadata.oldestForwardsCompatibleVersion;
		}

		let versionToDeserialize: number = schemaVersion;
		if (versionToDeserialize > ApiJsonSchemaVersion.LATEST) {
			// If the file format is too new, can we treat it as some earlier compatible version
			// as indicated by oldestForwardsCompatibleVersion?
			versionToDeserialize = Math.max(oldestForwardsCompatibleVersion, ApiJsonSchemaVersion.LATEST);

			if (versionToDeserialize > ApiJsonSchemaVersion.LATEST) {
				// Nope, still too new
				throw new Error(
					`Error loading ${apiJsonFilename}:` +
						`\nThe file format version ${schemaVersion} was written by a newer release of` +
						` the api-extractor-model library; you may need to upgrade your software`,
				);
			}
		}

		const tsdocConfiguration: TSDocConfiguration = new TSDocConfiguration();

		if (
			versionToDeserialize >= ApiJsonSchemaVersion.V_1004 &&
			'tsdocConfig' in jsonObject.metadata &&
			'$schema' in jsonObject.metadata.tsdocConfig
		) {
			const tsdocConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromObject(jsonObject.metadata.tsdocConfig);
			if (tsdocConfigFile.hasErrors) {
				throw new Error(`Error loading ${apiJsonFilename}:\n` + tsdocConfigFile.getErrorSummary());
			}

			tsdocConfigFile.configureParser(tsdocConfiguration);
		}

		const context: DeserializerContext = new DeserializerContext({
			apiJsonFilename,
			toolPackage: jsonObject.metadata.toolPackage,
			toolVersion: jsonObject.metadata.toolVersion,
			versionToDeserialize,
			tsdocConfiguration,
		});

		return ApiItem.deserialize(jsonObject, context) as ApiPackage;
	}

	/**
	 * @override
	 */
	public override get kind(): ApiItemKind {
		return ApiItemKind.Package;
	}

	/**
	 * @override
	 */
	public override get containerKey(): string {
		// No prefix needed, because ApiPackage is the only possible member of an ApiModel
		return this.name;
	}

	public get entryPoints(): readonly ApiEntryPoint[] {
		return this.members as readonly ApiEntryPoint[];
	}

	public get dependencies(): Record<string, string> | undefined {
		return this._dependencies;
	}

	/**
	 * The TSDoc configuration that was used when analyzing the API for this package.
	 *
	 * @remarks
	 *
	 * Normally this configuration is loaded from the project's tsdoc.json file.  It is stored
	 * in the .api.json file so that doc comments can be parsed accurately when loading the file.
	 */
	public get tsdocConfiguration(): TSDocConfiguration {
		return this._tsdocConfiguration;
	}

	public get projectFolderUrl(): string | undefined {
		return this._projectFolderUrl;
	}

	/**
	 * @override
	 */
	public override addMember(member: ApiEntryPoint): void {
		if (member.kind !== ApiItemKind.EntryPoint) {
			throw new Error('Only items of type ApiEntryPoint may be added to an ApiPackage');
		}

		super.addMember(member);
	}

	public findEntryPointsByPath(importPath: string): readonly ApiEntryPoint[] {
		return this.findMembersByName(importPath) as readonly ApiEntryPoint[];
	}

	public saveToJsonFile(apiJsonFilename: string, options?: IApiPackageSaveOptions): void {
		const ioptions = options ?? {};

		const packageJson: IPackageJson = PackageJsonLookup.loadOwnPackageJson(__dirname);

		const tsdocConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromParser(this.tsdocConfiguration);
		const tsdocConfig: JsonObject = tsdocConfigFile.saveToObject();

		const jsonObject: IApiPackageJson = {
			metadata: {
				toolPackage: ioptions.toolPackage ?? packageJson.name,
				// In test mode, we don't write the real version, since that would cause spurious diffs whenever
				// the version is bumped.  Instead we write a placeholder string.
				toolVersion: ioptions.testMode ? '[test mode]' : (ioptions.toolVersion ?? packageJson.version),
				schemaVersion: ApiJsonSchemaVersion.LATEST,
				oldestForwardsCompatibleVersion: ApiJsonSchemaVersion.OLDEST_FORWARDS_COMPATIBLE,
				tsdocConfig,
			},
		} as IApiPackageJson;

		if (this.projectFolderUrl) {
			jsonObject.projectFolderUrl = this.projectFolderUrl;
		}

		if (this._dependencies) {
			jsonObject.dependencies = this._dependencies;
		}

		this.serializeInto(jsonObject);
		if (ioptions.minify) {
			FileSystem.writeFile(apiJsonFilename, Buffer.from(JSON.stringify(this._mapToMinified(jsonObject)), 'utf8'), {
				ensureFolderExists: ioptions.ensureFolderExists ?? true,
			});
		} else {
			JsonFile.save(jsonObject, apiJsonFilename, ioptions);
		}
	}

	/**
	 * @beta @override
	 */
	public override buildCanonicalReference(): DeclarationReference {
		return DeclarationReference.package(this.name);
	}

	private _mapToMinified(jsonObject: IApiPackageJson) {
		const mapper = (item: any): any => {
			if (Array.isArray(item)) return item.map(mapper);
			else {
				const result: any = {};
				for (const key of Object.keys(item)) {
					if (key === 'dependencies') {
						result[MinifyJSONMapping.dependencies] = item.dependencies;
					} else if (key === 'tsdocConfig') {
						result[MinifyJSONMapping.tsdocConfig] = item.tsdocConfig;
					} else
						result[MinifyJSONMapping[key as keyof typeof MinifyJSONMapping]] =
							typeof item[key] === 'object' ? mapper(item[key]) : item[key];
				}

				return result;
			}
		};

		return mapper(jsonObject);
	}

	private static _mapFromMinified(jsonObject: any): IApiPackageJson {
		const mapper = (item: any): any => {
			if (Array.isArray(item)) return item.map(mapper);
			else {
				const result: any = {};
				for (const key of Object.keys(item)) {
					if (key === MinifyJSONMapping.dependencies) {
						result.dependencies = item[MinifyJSONMapping.dependencies];
					} else if (key === MinifyJSONMapping.tsdocConfig) {
						result.tsdocConfig = item[MinifyJSONMapping.tsdocConfig];
					} else
						result[
							Object.keys(MinifyJSONMapping).find(
								(look) => MinifyJSONMapping[look as keyof typeof MinifyJSONMapping] === key,
							)!
						] = typeof item[key] === 'object' ? mapper(item[key]) : item[key];
				}

				return result;
			}
		};

		return mapper(jsonObject) as IApiPackageJson;
	}
}
