// import type { EnrichedAutolink } from './annotations/autolinks.js';
import type { Account } from '@gitlens/git/models/author.js';
import type { DefaultBranch } from '@gitlens/git/models/defaultBranch.js';
import type { Issue } from '@gitlens/git/models/issue.js';
import type { IssueOrPullRequest, IssueOrPullRequestType } from '@gitlens/git/models/issueOrPullRequest.js';
import type { PullRequest } from '@gitlens/git/models/pullRequest.js';
import type { RepositoryMetadata } from '@gitlens/git/models/repositoryMetadata.js';
import type { ResourceDescriptor } from '@gitlens/git/models/resourceDescriptor.js';
import { isPromise } from '@gitlens/utils/promise.js';
import { CacheController } from '@gitlens/utils/promiseCache.js';
import type { Disposable } from './api/gitlens.d.js';
import type { Container } from './container.js';
import type { GitHostIntegration } from './plus/integrations/models/gitHostIntegration.js';
import type { IntegrationBase } from './plus/integrations/models/integration.js';

type Caches = {
	defaultBranch: { key: `repo:${string}`; value: DefaultBranch };
	// enrichedAutolinksBySha: { key: `sha:${string}:${string}`; value: Map<string, EnrichedAutolink> };
	issuesById: { key: `id:${string}:${string}`; value: Issue };
	issuesByIdAndResource: { key: `id:${string}:${string}:${string}`; value: Issue };
	issuesOrPrsById: { key: `id:${string}:${string}`; value: IssueOrPullRequest };
	issuesOrPrsByIdAndRepo: { key: `id:${string}:${string}:${string}`; value: IssueOrPullRequest };
	prByBranch: { key: `branch:${string}:${string}`; value: PullRequest };
	prsById: { key: `id:${string}:${string}`; value: PullRequest };
	prsBySha: { key: `sha:${string}:${string}`; value: PullRequest };
	repoMetadata: { key: `repo:${string}`; value: RepositoryMetadata };
	currentAccount: { key: `id:${string}`; value: Account };
};

type Cache = keyof Caches;
type CacheKey<T extends Cache> = Caches[T]['key'];
type CacheValue<T extends Cache> = Caches[T]['value'];
type CacheResult<T> = Promise<T | undefined> | T | undefined;

type Cacheable<T> = (cacheable: CacheController) => { value: CacheResult<T>; expiresAt?: number };
type Cached<T> =
	| {
			value: T | undefined;
			cachedAt: number;
			expiresAt?: number;
			etag?: string;
	  }
	| {
			value: Promise<T | undefined>;
			cachedAt: number;
			expiresAt?: never; // Don't set an expiration on promises as they will resolve to a value with the desired expiration
			etag?: string;
	  };

type ExpiryOptions = { expiryOverride?: boolean | number; expireOnError?: boolean };

export class CacheProvider implements Disposable {
	private readonly _cache = new Map<`${Cache}:${CacheKey<Cache>}`, Cached<CacheResult<CacheValue<Cache>>>>();

	// eslint-disable-next-line @typescript-eslint/no-useless-constructor
	constructor(_container: Container) {}

	dispose(): void {
		this._cache.clear();
	}

	delete<T extends Cache>(cache: T, key: CacheKey<T>): void {
		this._cache.delete(`${cache}:${key}`);
	}

	/** Returns the resolved cached value without triggering a fetch on cache miss */
	private peek<T extends Cache>(cache: T, key: CacheKey<T>): CacheValue<T> | undefined {
		const item = this._cache.get(`${cache}:${key}`);
		if (item == null || isPromise(item.value)) return undefined;
		return item.value as CacheValue<T> | undefined;
	}

	get<T extends Cache>(
		cache: T,
		key: CacheKey<T>,
		etag: string | undefined,
		cacheable: Cacheable<CacheValue<T>>,
		options?: ExpiryOptions,
	): CacheResult<CacheValue<T>> {
		const item = this._cache.get(`${cache}:${key}`);

		// Allow the caller to override the expiry
		let expiry;
		if (item != null) {
			if (typeof options?.expiryOverride === 'number' && options.expiryOverride > 0) {
				expiry = item.cachedAt + options.expiryOverride;
			} else {
				expiry = item.expiresAt;
			}
		}

		if (
			item == null ||
			options?.expiryOverride === true ||
			(expiry != null && expiry > 0 && expiry < Date.now()) ||
			(item.etag != null && item.etag !== etag)
		) {
			const cacheController = new CacheController();
			const { value, expiresAt } = cacheable(cacheController);
			const setResult = this.set<T>(cache, key, value, etag, expiresAt, options?.expireOnError);
			void setResult?.promise?.finally(() => {
				if (cacheController.invalidated) {
					this.delete(cache, key);
				}
			});
			return setResult?.item.value as CacheResult<CacheValue<T>>;
		}

		return item.value as CacheResult<CacheValue<T>>;
	}

	getCurrentAccount(
		integration: IntegrationBase,
		cacheable: Cacheable<Account>,
		options?: ExpiryOptions,
	): CacheResult<Account> {
		const { key, etag } = this.getIntegrationKeyAndEtag(integration);
		return this.get('currentAccount', `id:${key}`, etag, cacheable, options);
	}

	// getEnrichedAutolinks(
	// 	sha: string,
	// 	remoteOrProvider: Integration,
	// 	cacheable: Cacheable<Map<string, EnrichedAutolink>>,
	// 	options?: { force?: boolean },
	// ): CacheResult<Map<string, EnrichedAutolink>> {
	// 	const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider);
	// 	return this.get('enrichedAutolinksBySha', `sha:${sha}:${key}`, etag, cacheable, options);
	// }

	getIssueOrPullRequest(
		id: string,
		type: IssueOrPullRequestType | undefined,
		resource: ResourceDescriptor,
		integration: IntegrationBase | undefined,
		cacheable: Cacheable<IssueOrPullRequest>,
		options?: ExpiryOptions,
	): CacheResult<IssueOrPullRequest> {
		const { key, etag } = this.getResourceKeyAndEtag(resource, integration);

		if (resource == null) {
			return this.get('issuesOrPrsById', `id:${id}:${key}:${type ?? 'unknown'}`, etag, cacheable, options);
		}
		return this.get(
			'issuesOrPrsByIdAndRepo',
			`id:${id}:${key}:${type ?? 'unknown'}:${JSON.stringify(resource)}}`,
			etag,
			cacheable,
			options,
		);
	}

	getIssue(
		id: string,
		resource: ResourceDescriptor,
		integration: IntegrationBase | undefined,
		cacheable: Cacheable<Issue>,
		options?: ExpiryOptions,
	): CacheResult<Issue> {
		const { key, etag } = this.getResourceKeyAndEtag(resource, integration);

		if (resource == null) {
			return this.get(
				'issuesById',
				`id:${id}:${key}:${'issue' satisfies IssueOrPullRequestType}`,
				etag,
				cacheable,
				options,
			);
		}
		return this.get(
			'issuesByIdAndResource',
			`id:${id}:${key}:${'issue' satisfies IssueOrPullRequestType}:${JSON.stringify(resource)}}`,
			etag,
			cacheable,
			options,
		);
	}

	getPullRequest(
		id: string,
		resource: ResourceDescriptor,
		integration: IntegrationBase | undefined,
		cacheable: Cacheable<PullRequest>,
		options?: ExpiryOptions,
	): CacheResult<PullRequest> {
		const { key, etag } = this.getResourceKeyAndEtag(resource, integration);

		if (resource == null) {
			return this.get(
				'prsById',
				`id:${id}:${key}:${'pullrequest' satisfies IssueOrPullRequestType}`,
				etag,
				cacheable,
				options,
			);
		}
		return this.get(
			'prsById',
			`id:${id}:${key}:${'pullrequest' satisfies IssueOrPullRequestType}:${JSON.stringify(resource)}}`,
			etag,
			cacheable,
			options,
		);
	}

	getPullRequestForBranch(
		branch: string,
		repo: ResourceDescriptor,
		integration: GitHostIntegration | undefined,
		cacheable: Cacheable<PullRequest>,
		options?: ExpiryOptions,
	): CacheResult<PullRequest> {
		const { key, etag } = this.getResourceKeyAndEtag(repo, integration);
		// Wrap the cacheable so we can also add the result to the issuesOrPrsById cache
		return this.get(
			'prByBranch',
			`branch:${branch}:${key}`,
			etag,
			this.wrapPullRequestCacheable(cacheable, key, etag),
			options,
		);
	}

	getPullRequestForSha(
		sha: string,
		repo: ResourceDescriptor,
		integration: GitHostIntegration | undefined,
		cacheable: Cacheable<PullRequest>,
		options?: ExpiryOptions,
	): CacheResult<PullRequest> {
		const { key, etag } = this.getResourceKeyAndEtag(repo, integration);
		// Wrap the cacheable so we can also add the result to the issuesOrPrsById cache
		return this.get(
			'prsBySha',
			`sha:${sha}:${key}`,
			etag,
			this.wrapPullRequestCacheable(cacheable, key, etag),
			options,
		);
	}

	getRepositoryDefaultBranch(
		repo: ResourceDescriptor,
		integration: GitHostIntegration | undefined,
		cacheable: Cacheable<DefaultBranch>,
		options?: ExpiryOptions,
	): CacheResult<DefaultBranch> {
		const { key, etag } = this.getResourceKeyAndEtag(repo, integration);
		return this.get('defaultBranch', `repo:${key}`, etag, cacheable, options);
	}

	getRepositoryMetadata(
		repo: ResourceDescriptor,
		integration: GitHostIntegration | undefined,
		cacheable: Cacheable<RepositoryMetadata>,
		options?: ExpiryOptions,
	): CacheResult<RepositoryMetadata> {
		const { key, etag } = this.getResourceKeyAndEtag(repo, integration);
		return this.get('repoMetadata', `repo:${key}`, etag, cacheable, options);
	}

	private set<T extends Cache>(
		cache: T,
		key: CacheKey<T>,
		value: CacheResult<CacheValue<T>>,
		etag: string | undefined,
		expiresAt?: number,
		expireOnError?: boolean,
	): { item: Cached<CacheResult<CacheValue<T>>>; promise: undefined | Promise<void> } {
		let item: Cached<CacheResult<CacheValue<T>>>;
		let promise: undefined | Promise<void>;
		if (isPromise(value)) {
			promise = value.then(v => {
				if (this._cache.get(`${cache}:${key}`)?.value === value) {
					this.set(cache, key, v, etag, expiresAt, expireOnError);
				}
			});
			promise = promise.catch(
				expireOnError !== false
					? () => this.delete(cache, key)
					: () => {
							// catch error and do nothing,
							// This is to make the behaviour consistent and independent from the `expireOnError` option,
							// because otherwise the promise gets handled and resolved to success in once case
							// and be an unhandled rejection in another case.
							// By adding this empty handler we make it always handled.
						},
			);

			item = { value: value, etag: etag, cachedAt: Date.now() };
		} else {
			item = {
				value: value,
				etag: etag,
				cachedAt: Date.now(),
				expiresAt: expiresAt ?? getExpiresAt<T>(cache, value),
			};
		}

		this._cache.set(`${cache}:${key}`, item);
		return { item: item, promise: promise };
	}

	private wrapPullRequestCacheable(
		cacheable: Cacheable<PullRequest>,
		key: string,
		etag: string | undefined,
	): Cacheable<PullRequest> {
		return cacheController => {
			const item = cacheable(cacheController);
			if (isPromise(item.value)) {
				void item.value.then(v => {
					if (v != null) {
						this.set(
							'issuesOrPrsById',
							`id:${v.id}:${key}:${'pullrequest' satisfies IssueOrPullRequestType}`,
							v,
							etag,
						);
					}
				});
			}

			return item;
		};
	}

	private getIntegrationKeyAndEtag(integration: IntegrationBase) {
		return {
			key: integration.id,
			etag: `${integration.id}:${integration.maybeConnected ?? false}:${integration.sessionFingerprint ?? ''}`,
		};
	}

	/** ETag for resource caches — uses cached account ID when available, falls back to sessionFingerprint */
	private getResourceKeyAndEtag(resource: ResourceDescriptor, integration?: GitHostIntegration | IntegrationBase) {
		let fingerprint = '';
		if (integration != null) {
			fingerprint =
				this.peek('currentAccount', `id:${integration.id}`)?.id ?? integration.sessionFingerprint ?? '';
		}
		return {
			key: resource.key,
			etag: `${resource.key}:${integration?.maybeConnected ?? false}:${fingerprint}`,
		};
	}
}

function getExpiresAt<T extends Cache>(cache: T, value: CacheValue<T> | undefined): number {
	const now = Date.now();
	const defaultExpiresAt = now + 60 * 60 * 1000; // 1 hour

	switch (cache) {
		case 'defaultBranch':
		case 'repoMetadata':
		case 'currentAccount':
			return 0; // Never expires
		case 'issuesById':
		case 'issuesByIdAndResource': {
			if (value == null) return 0; // Never expires

			// Open issues expire after 1 hour, but closed issues expire after 12 hours unless recently updated and then expire in 1 hour

			const issue = value as CacheValue<'issuesById'>;
			if (!issue.closed) return defaultExpiresAt;

			const updatedAgo = now - (issue.closedDate ?? issue.updatedDate).getTime();
			return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000;
		}
		case 'issuesOrPrsById':
		case 'issuesOrPrsByIdAndRepo': {
			if (value == null) return 0; // Never expires

			// Open issues expire after 1 hour, but closed issues expire after 12 hours unless recently updated and then expire in 1 hour

			const issueOrPr = value as CacheValue<'issuesOrPrsById'>;
			if (!issueOrPr.closed) return defaultExpiresAt;

			const updatedAgo = now - (issueOrPr.closedDate ?? issueOrPr.updatedDate).getTime();
			return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000;
		}
		case 'prByBranch':
		case 'prsById':
		case 'prsBySha': {
			if (value == null) return cache === 'prByBranch' ? defaultExpiresAt : 0 /* Never expires */;

			// Open prs expire after 1 hour, but closed/merge prs expire after 12 hours unless recently updated and then expire in 1 hour

			const pr = value as CacheValue<'prByBranch' | 'prsById' | 'prsBySha'>;
			if (pr.state === 'opened') return defaultExpiresAt;

			const updatedAgo = now - (pr.closedDate ?? pr.mergedDate ?? pr.updatedDate).getTime();
			return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000;
		}
		// case 'enrichedAutolinksBySha':
		default:
			return value == null ? 0 /* Never expires */ : defaultExpiresAt;
	}
}
