import { AgentIntegrationConfig } from '@n8n/api-types';
import type { RichCardComponentType } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import type { Thread, Author } from 'chat';
import { createHmac } from 'crypto';
import { InstanceSettings } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';

import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UrlService } from '@/services/url.service';

import { AgentRepository } from '../../repositories/agent.repository';
import {
	AgentChatIntegration,
	type AgentChannelPreconditionContext,
	type AgentChatIntegrationContext,
	type ActionDecisionMessageParams,
	type BridgeExecutionContext,
	type BridgeMessageContextParams,
	type BridgeResumeExecutionContext,
} from '../agent-chat-integration';
import type { SuspendComponent } from '../component-mapper';
import { assertCredentialNotClaimed } from '../credential-claim';
import { loadTelegramAdapter } from '../esm-loader';
import { resolveIntegrationActionDefinitions } from '../integration-tool-definitions';
import {
	createTelegramBridgeExecutionContext,
	createTelegramResumeExecutionContext,
} from './telegram-bridge-behavior';

/**
 * Telegram platform integration.
 *
 * Two capability flags are enabled here because of Telegram constraints:
 * - {@link needsShortCallbackData} — `callback_data` is capped at 64 bytes, so the
 *   bridge stores full payloads in a CallbackStore and emits short 8-char keys as
 *   button IDs.
 * - {@link disableStreaming} — streaming Markdown edits are unstable: intermediate
 *   frames carry half-formed markup that Telegram rejects or renders inconsistently.
 *   The bridge buffers agent output and posts it as a single message per flush,
 *   guaranteeing well-formed Markdown on every post.
 *
 * The adapter runs in webhook mode when a public `WEBHOOK_URL` is configured,
 * otherwise it falls back to polling for local dev.
 */
@Service()
export class TelegramIntegration extends AgentChatIntegration {
	readonly type = 'telegram';

	readonly credentialTypes = ['telegramApi'];

	readonly displayLabel = 'Telegram';

	readonly displayIcon = 'telegram';

	readonly builderGuidance = {
		capabilities: [
			'Receive Telegram messages as agent triggers.',
			'Respond in Telegram conversations and send direct Telegram messages.',
			'Edit existing messages in the current Telegram conversation.',
			'Render Telegram-compatible cards with buttons.',
		],
		useIntegrationWhen: [
			'The agent should be chatted with from Telegram or act as a Telegram bot.',
			'The agent needs to reply to Telegram users in the same conversation context.',
			'The agent needs to update a Telegram message in the current conversation.',
			'The agent should send Telegram messages as the connected Telegram bot.',
			'A scheduled Agent task should proactively send a direct message to a known Telegram user ID.',
		],
		useNodeToolWhen: [
			'Telegram is only a backend API step and the agent does not need to be connected as a Telegram chat surface.',
			'The Telegram operation is performed by a non-Agent workflow, or the exact operation is not listed in the Agent integration capabilities.',
		],
	};

	readonly supportedComponents: readonly RichCardComponentType[] = [
		'section',
		'button',
		'divider',
		'fields',
	];

	readonly actionToolDefinitions = resolveIntegrationActionDefinitions([
		'respond',
		'send_dm',
		'edit_message',
	]);

	readonly actionToolGuidance = [
		'For scheduled tasks without an inbound conversation, use send_dm with the known Telegram user ID. The integration action does not require current message context.',
		'For edit_message, pass the messageId returned by a previous Telegram action or get_current_message_context. The current Telegram conversation is selected automatically.',
	];

	readonly needsShortCallbackData = true;

	readonly deleteActionMessageBeforeResume = false;

	readonly disableStreaming = true;

	formatActionDecisionMessage({
		approved,
		selectedLabel,
		raw,
		user,
	}: ActionDecisionMessageParams): string {
		const originalText =
			isRecord(raw) && isRecord(raw.message) && typeof raw.message.text === 'string'
				? raw.message.text
				: '';
		const responder = user.fullName || user.userName || user.userId;
		const outcome =
			approved === undefined
				? `✅ ${selectedLabel || 'Action'} selected by ${responder}`
				: approved
					? `✅ Approved by ${responder}`
					: `🚫 Declined by ${responder}`;
		return originalText ? `${originalText}\n\n${outcome}` : outcome;
	}

	readonly formatThreadId = {
		fromSdk: (thread: Thread<unknown, unknown>) => {
			const adapter = thread.adapter;
			const botUserId = adapter.botUserId;
			if (!botUserId) {
				throw new Error('Telegram bot user ID is not set');
			}
			// thread.id is simply user id, which means connecting agent to another bot user will result in the same thread id
			return `chat:${botUserId}-${thread.id}`;
		},
		toSdk: (threadId: string) => {
			if (!threadId.includes('-')) {
				return threadId;
			}
			return threadId.split('-').slice(1).join('-');
		},
	};

	constructor(
		private readonly logger: Logger,
		private readonly urlService: UrlService,
		private readonly agentRepository: AgentRepository,
		private readonly instanceSettings: InstanceSettings,
		private readonly outboundHttp: OutboundHttp,
	) {
		super();
	}

	async createAdapter(ctx: AgentChatIntegrationContext): Promise<unknown> {
		const botToken = this.extractBotToken(ctx.credential);
		const mode = ctx.ingressEnabled ? this.getMode() : 'webhook';
		const secretToken = this.deriveSecretToken(ctx.agentId, ctx.credentialId);
		const { createTelegramAdapter } = await loadTelegramAdapter();
		return createTelegramAdapter({ botToken, mode, secretToken });
	}

	validateConfig(integration: AgentIntegrationConfig): void {
		if (integration.type === this.type && !integration.settings) {
			throw new BadRequestError('Telegram integration settings are required');
		}
	}

	/**
	 * In polling mode the Chat SDK adapter long-polls Telegram, which must be
	 * done by exactly one main — otherwise multiple instances race for the same
	 * updates. Webhook mode is safe on every main.
	 *
	 * Mirrors `createAdapter`, which forces webhook mode when ingress is off: an
	 * outbound connection opens no poll loop, so it is not leader-bound.
	 */
	override requiresLeader({ ingressEnabled } = { ingressEnabled: true }): boolean {
		return ingressEnabled && this.getMode() === 'polling';
	}

	/**
	 * We deliberately don't probe Telegram for an existing webhook here —
	 * `onAfterConnect` overwrites whatever URL Telegram has on file, so a stale
	 * webhook from elsewhere isn't a connect blocker. That leaves the claim
	 * check, which reads only our own DB, so publishing can run it as a
	 * preflight.
	 */
	async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise<void> {
		await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
	}

	async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
		await this.assertStartupPreconditions(ctx);
	}

	async onAfterConnect(ctx: AgentChatIntegrationContext): Promise<void> {
		if (this.getMode() !== 'webhook') return;
		const webhookUrl = ctx.webhookUrlFor('telegram');
		const secretToken = this.deriveSecretToken(ctx.agentId, ctx.credentialId);
		const response = await this.botApiRequest(ctx.credential, 'setWebhook', {
			url: webhookUrl,
			secret_token: secretToken,
		});
		if (!this.isOkStatus(response.statusCode)) {
			throw new Error(`Failed to register Telegram webhook: ${this.stringifyBody(response.body)}`);
		}
		this.logger.info(`[TelegramIntegration] Webhook registered: ${webhookUrl}`);
	}

	/**
	 * Mirror of `onAfterConnect`: clear the webhook we registered on Telegram so
	 * the bot is free for another agent or another application. Polling-mode
	 * connections never registered a webhook with Telegram, so skip.
	 */
	async onBeforeDisconnect(ctx: AgentChatIntegrationContext): Promise<void> {
		if (this.getMode() !== 'webhook') return;
		const response = await this.botApiRequest(ctx.credential, 'deleteWebhook');
		if (!this.isOkStatus(response.statusCode)) {
			throw new Error(
				`Failed to deregister Telegram webhook: ${this.stringifyBody(response.body)}`,
			);
		}
		this.logger.info(
			`[TelegramIntegration] Webhook deregistered for agent ${ctx.agentId}, credential ${ctx.credentialId}`,
		);
	}

	/**
	 * Enforce the Private-mode allowlist. Public mode (or legacy connections
	 * without saved settings) accepts every Telegram user; Private mode only
	 * accepts senders whose numeric user ID or username appears in `allowedUsers`.
	 * Stored values may carry a leading "@" (saved verbatim from user input), so
	 * they are normalized by stripping "@" before comparison. The SDK delivers
	 * both userId and userName without "@".
	 */
	isUserAllowed(author: Author, integration: AgentIntegrationConfig | undefined): boolean {
		if (!integration) return true;
		if (integration?.type !== 'telegram') {
			throw new UnexpectedError(
				`TelegramIntegration received settings with type "${integration?.type}"`,
			);
		}
		if (!integration.settings) return true;

		if (integration.settings.accessMode === 'public') return true;
		return integration.settings.allowedUsers.some((allowed) => {
			const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed;
			return normalized === author.userId || normalized === author.userName;
		});
	}

	async createBridgeExecutionContext(
		params: BridgeMessageContextParams,
	): Promise<BridgeExecutionContext> {
		return await Promise.resolve(createTelegramBridgeExecutionContext(params));
	}

	async createResumeExecutionContext(params: {
		thread: BridgeMessageContextParams['thread'];
		logger: BridgeMessageContextParams['logger'];
		agentId: string;
	}): Promise<BridgeResumeExecutionContext> {
		return await Promise.resolve(createTelegramResumeExecutionContext(params));
	}

	normalizeComponents(components: SuspendComponent[]): SuspendComponent[] {
		const normalized: SuspendComponent[] = [];
		for (const c of components) {
			switch (c.type) {
				case 'select':
				case 'radio_select':
					// Convert select options to individual buttons
					for (const opt of c.options ?? []) {
						normalized.push({ type: 'button', label: opt.label, value: opt.value });
					}
					break;
				case 'image':
					// Convert image to a section with a markdown link
					if (c.url) {
						normalized.push({ type: 'section', text: `[${c.altText ?? 'Image'}](${c.url})` });
					}
					break;
				default:
					normalized.push(c);
			}
		}
		return normalized;
	}

	/** Webhook when we have a public URL, polling otherwise (local dev). */
	private getMode(): 'webhook' | 'polling' {
		const baseUrl = this.urlService.getWebhookBaseUrl();
		const isPublic = baseUrl.startsWith('https://') && !baseUrl.includes('localhost');
		return isPublic ? 'webhook' : 'polling';
	}

	/**
	 * Per-integration webhook secret derived deterministically from the cluster
	 * encryption key. In multi-main mode the webhook is registered exactly once by the
	 * leader, but every main must verify incoming POSTs. HMAC over an
	 * `(agentId, credentialId)` lets every main reach the identical secret with
	 * zero coordination.
	 */
	private deriveSecretToken(agentId: string, credentialId: string): string {
		return createHmac('sha256', this.instanceSettings.encryptionKey)
			.update(`telegram:${agentId}:${credentialId}`)
			.digest('hex');
	}

	private extractBotToken(credential: Record<string, unknown>): string {
		const token = credential.accessToken;
		if (typeof token === 'string' && token) {
			return token;
		}
		throw new Error(
			'Could not extract a bot token from the Telegram credential. ' +
				'Please ensure the credential has a valid access token from BotFather.',
		);
	}

	/**
	 * Build a Bot API URL honoring the credential's `baseUrl` (defaults to
	 * `https://api.telegram.org`). Lets users on a self-hosted Bot API server
	 * register/deregister webhooks against their own endpoint.
	 */
	private botApiUrl(credential: Record<string, unknown>, method: string): string {
		const botToken = this.extractBotToken(credential);
		const raw = credential.baseUrl;
		const baseUrl =
			typeof raw === 'string' && raw.trim()
				? raw.trim().replace(/\/+$/, '')
				: 'https://api.telegram.org';
		return `${baseUrl}/bot${botToken}/${method}`;
	}

	private async botApiRequest(
		credential: Record<string, unknown>,
		method: string,
		body?: Record<string, unknown>,
	) {
		return await this.outboundHttp
			// the Bot API host is user-configurable, so the default safe mode applies
			.requests()
			.request({
				method: 'POST',
				url: this.botApiUrl(credential, method),
				...(body ? { body, json: true } : {}),
				returnFullResponse: true,
				ignoreHttpStatusErrors: true,
			});
	}

	private isOkStatus(statusCode: number): boolean {
		return statusCode >= 200 && statusCode < 300;
	}

	private stringifyBody(body: unknown): string {
		return typeof body === 'string' ? body : JSON.stringify(body);
	}
}
