/**
 * Consolidated research tool — web-search + fetch-url.
 */
import { Tool } from '@n8n/agents';
import { z } from 'zod';

import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import {
	checkDomainAccess,
	checkWebSearchAccess,
	applyDomainAccessResume,
	applyWebSearchAccessResume,
	domainGatingSuspendSchema,
	domainGatingResumeSchema,
} from '../domain-access';
import type { InstanceAiContext } from '../types';
import { sanitizeWebContent, wrapUntrustedData } from './web-research/sanitize-web-content';

/** True when both URLs share an eTLD+1 (per Public Suffix List) and target is HTTPS. */
async function isSameRegistrableDomainOverHttps(
	originalUrl: string,
	redirectUrl: string,
): Promise<boolean> {
	let originalHost: string;
	let redirectUrlObj: URL;
	try {
		originalHost = new URL(originalUrl).hostname;
		redirectUrlObj = new URL(redirectUrl);
	} catch {
		return false;
	}
	if (redirectUrlObj.protocol !== 'https:') return false;
	const { get: pslGet } = await import('psl');
	const originalDomain = pslGet(originalHost);
	const redirectDomain = pslGet(redirectUrlObj.hostname);
	return originalDomain !== null && redirectDomain !== null && originalDomain === redirectDomain;
}

// ── Action schemas ──────────────────────────────────────────────────────────

const webSearchAction = z.object({
	action: z.literal('web-search').describe('Search the web for information'),
	query: z
		.string()
		.describe('Search query. Be specific — include service names, API versions, error codes.'),
	maxResults: z
		.number()
		.int()
		.min(1)
		.max(20)
		.default(5)
		.optional()
		.describe('Maximum number of results to return (default 5, max 20)'),
	includeDomains: z
		.array(z.string())
		.optional()
		.describe('Restrict results to these domains, e.g. ["docs.stripe.com"]'),
});

const fetchUrlAction = z.object({
	action: z.literal('fetch-url').describe('Fetch a web page and extract its content as markdown'),
	url: z.string().url().describe('URL of the page to fetch'),
	maxContentLength: z
		.number()
		.int()
		.positive()
		.max(100_000)
		.default(30_000)
		.optional()
		.describe('Maximum content length in characters (default 30000)'),
});

const inputSchema = sanitizeInputSchema(
	z.discriminatedUnion('action', [webSearchAction, fetchUrlAction]),
);

type Input = z.infer<typeof inputSchema>;
type DomainGatingResumeData = z.infer<typeof domainGatingResumeSchema>;
type DomainGatingSuspendPayload = z.infer<typeof domainGatingSuspendSchema>;
interface DomainGatingToolContext {
	resumeData: DomainGatingResumeData | undefined;
	suspend?: (payload: DomainGatingSuspendPayload) => Promise<never>;
}

// ── Handlers ────────────────────────────────────────────────────────────────

async function handleWebSearch(
	context: InstanceAiContext,
	input: Extract<Input, { action: 'web-search' }>,
	ctx: DomainGatingToolContext,
) {
	if (!context.webResearchService?.search) {
		return { query: input.query, results: [] };
	}

	const resumeData = ctx.resumeData;

	// ── Resume path: apply user's decision ─────────────────────────
	if (resumeData !== undefined && resumeData !== null) {
		const { proceed } = applyWebSearchAccessResume({
			resumeData,
			tracker: context.domainAccessTracker,
			runId: context.runId,
		});
		if (!proceed) {
			return { query: input.query, results: [] };
		}
	}

	// ── Initial check: is web-search allowed? ──────────────────────
	if (resumeData === undefined || resumeData === null) {
		const check = checkWebSearchAccess({
			query: input.query,
			tracker: context.domainAccessTracker,
			permissionMode: context.permissions?.webSearch,
			runId: context.runId,
		});
		if (!check.allowed) {
			if (check.blocked) {
				return { query: input.query, results: [] };
			}
			if (ctx.suspend) return await ctx.suspend(check.suspendPayload!);
			return { query: input.query, results: [] };
		}
	}

	const result = await context.webResearchService.search(input.query, {
		maxResults: input.maxResults ?? undefined,
		includeDomains: input.includeDomains ?? undefined,
	});
	// Snippets come from arbitrary third-party pages — sanitize against hidden
	// payloads, then wrap so the LLM treats the content as data, not instructions.
	// The wrapper also escapes any closing boundary tag in the snippet to
	// prevent breakout into the surrounding prompt context.
	for (const r of result.results) {
		r.snippet = wrapUntrustedData(sanitizeWebContent(r.snippet), r.url, r.title);
	}
	return result;
}

async function handleFetchUrl(
	context: InstanceAiContext,
	input: Extract<Input, { action: 'fetch-url' }>,
	ctx: DomainGatingToolContext,
) {
	if (!context.webResearchService) {
		return {
			url: input.url,
			finalUrl: input.url,
			title: '',
			content: 'Web research is not available in this environment.',
			truncated: false,
			contentLength: 0,
		};
	}

	const resumeData = ctx.resumeData;

	// ── Resume path: apply user's domain decision ──────────────────
	if (resumeData !== undefined && resumeData !== null) {
		let host: string;
		try {
			host = new URL(input.url).hostname;
		} catch {
			host = input.url;
		}
		const { proceed } = applyDomainAccessResume({
			resumeData,
			host,
			tracker: context.domainAccessTracker,
			runId: context.runId,
		});
		if (!proceed) {
			return {
				url: input.url,
				finalUrl: input.url,
				title: '',
				content: 'User denied access to this URL.',
				truncated: false,
				contentLength: 0,
			};
		}
	}

	// ── Initial check: is the URL's host allowed? ──────────────────
	if (resumeData === undefined || resumeData === null) {
		const check = checkDomainAccess({
			url: input.url,
			tracker: context.domainAccessTracker,
			permissionMode: context.permissions?.fetchUrl,
			runId: context.runId,
		});
		if (!check.allowed) {
			if (check.blocked) {
				return {
					url: input.url,
					finalUrl: input.url,
					title: '',
					content: 'Action blocked by admin.',
					truncated: false,
					contentLength: 0,
				};
			}
			if (ctx.suspend) return await ctx.suspend(check.suspendPayload!);
			return {
				url: input.url,
				finalUrl: input.url,
				title: '',
				content: '',
				truncated: false,
				contentLength: 0,
			};
		}
	}

	// ── Execute fetch ──────────────────────────────────────────────
	// eslint-disable-next-line @typescript-eslint/require-await -- must be async to match authorizeUrl signature
	const authorizeUrl = async (targetUrl: string) => {
		const redirectCheck = checkDomainAccess({
			url: targetUrl,
			tracker: context.domainAccessTracker,
			permissionMode: context.permissions?.fetchUrl,
			runId: context.runId,
		});
		if (redirectCheck.allowed) return;
		if (redirectCheck.blocked) {
			throw new Error(`Access to ${new URL(targetUrl).hostname} is blocked by admin.`);
		}
		if (await isSameRegistrableDomainOverHttps(input.url, targetUrl)) return;
		throw new Error(
			`Redirect from ${new URL(input.url).hostname} to ${new URL(targetUrl).hostname} is not allowed. ` +
				'Skip this URL and try a different research strategy — retrying the same URL will not help.',
		);
	};

	const result = await context.webResearchService.fetchUrl(input.url, {
		maxContentLength: input.maxContentLength ?? undefined,
		authorizeUrl,
	});
	result.content = wrapUntrustedData(sanitizeWebContent(result.content), result.finalUrl);
	return result;
}

// ── Tool factory ────────────────────────────────────────────────────────────

export function createResearchTool(context: InstanceAiContext) {
	return new Tool('research')
		.description('Search the web or fetch page content.')
		.input(inputSchema)
		.suspend(domainGatingSuspendSchema)
		.resume(domainGatingResumeSchema)
		.handler(async (input: Input, ctx) => {
			switch (input.action) {
				case 'web-search':
					return await handleWebSearch(context, input, ctx);
				case 'fetch-url':
					return await handleFetchUrl(context, input, ctx);
			}
		})
		.build();
}
