import { Resource, ResourceKind } from "../resource.js";
import { logger } from "../util/logger.js";
import { poll } from "../util/poll.js";
import { sleep } from "../util/sleep.js";
import { snakeToCamelObjectDeep, } from "../util/snake-to-camel.js";
import { isAiSearchNamespace, } from "./ai-search-namespace.js";
import { AiSearchToken } from "./ai-search-token.js";
import { CloudflareApiError, isCloudflareApiError } from "./api-error.js";
import { extractCloudflareResult, } from "./api-response.js";
import { createCloudflareApi, } from "./api.js";
import { getBucket, isBucket, } from "./bucket.js";
import { deleteIndex } from "./vectorize-index.js";
/**
 * Type guard for AiSearch
 */
export function isAiSearch(resource) {
    return (typeof resource === "object" &&
        resource !== null &&
        resource[ResourceKind] === "cloudflare::AiSearch");
}
/**
 * An AI Search instance: a managed search index with optional built-in
 * storage and optional external data source (R2 or web crawler).
 *
 * @see https://developers.cloudflare.com/ai-search/
 *
 * @example
 * ## Built-in storage (no source)
 *
 * Creates an instance whose content is uploaded directly via the items API.
 *
 * ```ts
 * const kb = await AiSearch("knowledge-base", {
 *   name: "knowledge-base",
 * });
 * ```
 *
 * @example
 * ## R2-backed instance
 *
 * ```ts
 * const bucket = await R2Bucket("docs");
 * const search = await AiSearch("docs-search", {
 *   name: "docs-search",
 *   source: bucket,
 * });
 * ```
 *
 * @example
 * ## Instance in a custom namespace
 *
 * ```ts
 * const ns = await AiSearchNamespace("tenants", { name: "tenants" });
 * const search = await AiSearch("tenant-a", {
 *   name: "tenant-a",
 *   namespace: ns,
 * });
 * ```
 */
export const AiSearch = Resource("cloudflare::AiSearch", async function (id, props) {
    const adopt = props.adopt ?? this.scope.adopt;
    // Resolve namespace: AiSearchNamespace resource → string, default to "default"
    const namespace = resolveNamespace(props.namespace);
    // NOTE: AI Search is an always-remote binding (no Miniflare-native
    // implementation). `alchemy dev` wires the worker binding via
    // `remote-binding-proxy`, which requires the instance to actually exist
    // on Cloudflare at preview-token creation time — a locally mocked
    // resource would cause the Worker deploy to fail with error 10360
    // ("instance … not found"). Follow the same pattern as Vectorize and
    // skip the `scope.local` mock branch entirely.
    const api = await createCloudflareApi(props);
    const validateBucketSource = async (bucket, jurisdiction = "default") => {
        let name;
        if (typeof bucket === "string") {
            name = bucket;
        }
        else {
            if (this.scope.local && !bucket.dev?.remote) {
                throw new Error([
                    `AI Search "${id}" depends on an R2Bucket that is running locally, but AI Search requires the bucket to be deployed.`,
                    "Add `dev: { remote: true }` to the R2Bucket to use it with AI Search.",
                ].join("\n"));
            }
            name = bucket.name;
        }
        try {
            await getBucket(api, name, { jurisdiction });
        }
        catch (error) {
            throw new Error(`Failed to validate R2 bucket "${name}" (${jurisdiction}) for AI search "${id}": ${error instanceof Error ? error.message : String(error)}`, { cause: error });
        }
    };
    const normalizeSource = async (source) => {
        if (!source)
            return undefined;
        if (isBucket(source)) {
            await validateBucketSource(source, source.jurisdiction);
            return {
                type: "r2",
                bucket: source.name,
                jurisdiction: source.jurisdiction,
            };
        }
        else if (source.type === "r2" && isBucket(source.bucket)) {
            await validateBucketSource(source.bucket, source.bucket.jurisdiction);
            return {
                ...source,
                type: "r2",
                bucket: source.bucket.name,
                jurisdiction: source.bucket.jurisdiction,
            };
        }
        else if (source.type === "web-crawler") {
            await validateWebCrawlerSourceDomain(api, source.domain);
            return source;
        }
        else {
            await validateBucketSource(source.bucket, source.jurisdiction);
            return source;
        }
    };
    const validateTokenId = (id) => {
        if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
            throw new Error(`Invalid token ID: "${id}"\n` +
                "The token ID must be a valid UUID for an AI Search service token.\n" +
                "See https://developers.cloudflare.com/ai-search/get-started/api/#2-create-a-service-api-token");
        }
    };
    /**
     * Only resolve token for R2 sources. Web-crawler and built-in storage
     * instances do not require a token.
     */
    const normalizeTokenId = async (source) => {
        // Token only needed for R2 sources
        if (!source || source.type !== "r2") {
            return undefined;
        }
        if ("tokenId" in props) {
            validateTokenId(props.tokenId);
            return props.tokenId;
        }
        else if ("token" in props && props.token) {
            validateTokenId(props.token.tokenId);
            return props.token.tokenId;
        }
        else {
            // Auto-created tokens are an implementation detail of this resource —
            // `adopt: true` so updates reuse a prior auto-created token with the
            // same stable child id. We forward `delete: false` when the user
            // preserves the instance, because Cloudflare rejects token deletion
            // while the token is still referenced by the preserved instance
            // (error 7076 `token_in_use_by_instances`). When the instance IS
            // being deleted normally, the token deletes with it.
            const token = await AiSearchToken("token", {
                baseUrl: props.baseUrl,
                profile: props.profile,
                apiKey: props.apiKey,
                apiToken: props.apiToken,
                accountId: props.accountId,
                email: props.email,
                adopt: true,
                ...(props.delete === false ? { delete: false } : {}),
            });
            return token.tokenId;
        }
    };
    if (this.phase === "delete") {
        if (props.delete !== false && this.output?.id) {
            if (this.output.vectorizeName) {
                await deleteIndex(api, this.output.vectorizeName);
            }
            const deleteNs = this.output.namespace ?? "default";
            await deleteAiSearchInstance(api, deleteNs, this.output.id);
        }
        return this.destroy();
    }
    const name = props.name ?? this.scope.createPhysicalName(id, "-", 32);
    if (name.length < 1 || name.length > 32) {
        throw new Error(`AI Search instance name must be 1-32 characters, got ${name.length} ("${name}")`);
    }
    const source = await normalizeSource(props.source);
    const tokenId = await normalizeTokenId(source);
    const payload = {
        id: name,
        source: source
            ? source.type === "r2"
                ? source.bucket
                : source.domain
            : undefined,
        type: source?.type,
        ai_search_model: props.aiSearchModel,
        source_params: source
            ? {
                include_items: source.includePaths,
                exclude_items: source.excludePaths,
                ...(source.type === "r2"
                    ? {
                        r2_jurisdiction: source.jurisdiction !== "default"
                            ? source.jurisdiction
                            : undefined,
                        prefix: source.prefix,
                    }
                    : {
                        web_crawler: {
                            parse_type: source.parseType,
                            parse_options: source.parseOptions,
                            store_options: source.storeOptions,
                        },
                    }),
            }
            : undefined,
        embedding_model: props.embeddingModel,
        chunk: props.chunk,
        chunk_size: props.chunkSize,
        chunk_overlap: props.chunkOverlap,
        max_num_results: props.maxNumResults,
        score_threshold: props.scoreThreshold,
        reranking: props.reranking,
        reranking_model: props.rerankingModel,
        rewrite_query: props.rewriteQuery,
        rewrite_model: props.rewriteModel,
        cache: props.cache,
        cache_threshold: props.cacheThreshold,
        metadata: props.metadata,
        token_id: tokenId,
        index_method: props.indexMethod,
        fusion_method: props.fusionMethod,
    };
    let instance;
    if (this.phase === "update" && this.output?.id) {
        const replace = "source" in this.output &&
            (payload.type !== this.output.type ||
                payload.source !== this.output.source);
        // the development version of this resource had different properties, so check those to avoid an unnecessary replacement
        const replaceLegacy = "sourceType" in this.output &&
            (payload.type !== this.output.sourceType ||
                (payload.type === "r2" &&
                    "sourceBucket" in this.output &&
                    payload.source !== this.output.sourceBucket) ||
                (payload.type === "web-crawler" &&
                    "sourceDomain" in this.output &&
                    payload.source !== this.output.sourceDomain));
        // Namespace is immutable: moving an instance between namespaces must
        // replace (delete old, create new) rather than attempt an in-place
        // update against a non-existent `PUT /namespaces/{new}/instances/{id}`.
        // Default to "default" for legacy state files that predate the
        // namespace prop.
        const currentNamespace = ("namespace" in this.output && this.output.namespace) || "default";
        const namespaceChanged = currentNamespace !== namespace;
        if (replace || replaceLegacy || namespaceChanged) {
            return this.replace(true);
        }
        instance = await updateAiSearchInstance(api, namespace, this.output.id, payload);
    }
    else {
        // Pre-flight adopt: if `adopt: true` and an instance already exists,
        // adopt it directly via GET→PUT. This avoids racing on 400/7022 error
        // codes and the fragile `errorData as CloudflareApiErrorPayload[]` cast.
        if (adopt) {
            const existing = await getAiSearchInstance(api, namespace, name).catch((e) => {
                if (e instanceof CloudflareApiError && e.status === 404) {
                    return undefined;
                }
                throw e;
            });
            if (existing) {
                instance = await updateAiSearchInstance(api, namespace, existing.id, payload);
            }
            else {
                instance = await createAiSearchInstance(api, namespace, payload);
            }
        }
        else {
            try {
                instance = await createAiSearchInstance(api, namespace, payload);
            }
            catch (error) {
                // Wrap "already exists" errors with a clearer message pointing at
                // the adoption path (AGENTS.md convention).
                const errorData = Array.isArray(error?.errorData)
                    ? error
                        .errorData
                    : [];
                const isAlreadyExistsError = error instanceof CloudflareApiError &&
                    error.status === 400 &&
                    errorData.some((e) => e.code === 7022);
                if (isAlreadyExistsError) {
                    throw new Error(`AI Search instance "${name}" already exists in namespace "${namespace}". Use \`adopt: true\` to adopt it.`, { cause: error });
                }
                throw error;
            }
        }
        if (props.indexOnCreate !== false && source) {
            await runAiSearchJob(api, namespace, instance.id, (message) => logger.task(id, {
                prefix: "index",
                prefixColor: "gray",
                resource: id,
                message,
            }));
        }
        else if (props.indexOnCreate === true && !source) {
            logger.warn(`AI Search "${id}": \`indexOnCreate: true\` has no effect because no \`source\` was provided.`);
        }
    }
    return {
        ...snakeToCamelObjectDeep(instance),
        name: instance.id,
        // The API response may include a `namespace` field; explicitly use the
        // resolved local value to ensure the output always matches the prop
        // (and defaults to "default" when unspecified).
        namespace,
    };
});
/**
 * Resolve a namespace prop to a string name.
 */
function resolveNamespace(ns) {
    if (!ns)
        return "default";
    if (typeof ns === "string")
        return ns;
    if (isAiSearchNamespace(ns))
        return ns.namespace;
    return "default";
}
/**
 * Validate that a domain string is a valid domain format (not a URL).
 * Throws a helpful error if the input looks like a URL.
 */
async function validateWebCrawlerSourceDomain(api, domain) {
    if (domain.includes("://")) {
        throw new Error(`Invalid domain format "${domain}". Provide just the domain (e.g., "docs.example.com"), not a URL. ` +
            `For URL-based crawling, use AiCrawler instead of AiSearch.`);
    }
    if (domain.includes("/")) {
        throw new Error(`Invalid domain format "${domain}". Provide just the domain without paths (e.g., "docs.example.com"). ` +
            `Use includePaths to filter specific paths, or use AiCrawler for URL-based crawling.`);
    }
    // The Cloudflare dashboard uses this undocumented endpoint to validate domains.
    // If we don't validate here, the create request fails with a 500 error.
    const response = await api.post(`/accounts/${api.accountId}/ai-search/domains`, {
        domain,
    });
    // The error message is more like an error code. This mapping is from the Cloudflare dashboard.
    const errorMap = {
        not_a_valid_domain: "Not a valid domain.",
        invalid_domain: "Invalid domain. The domain needs to belong to this account.",
        fail_to_find_domain_info: "Failed to find domain information.",
        missing_sitemap: "Sitemap not found. Please check your robots.txt.",
        domain_not_owned_by_user: "The domain needs to belong to this account.",
        forbidden_robots_txt: "Failed to fetch robots.txt: The file is inaccessible.",
        forbidden_sitemap: "Failed to fetch your sitemap: The file is inaccessible.",
    };
    const json = (await response.json());
    if (json.success)
        return;
    throw new Error([
        `Failed to validate domain "${domain}" (${response.status}):`,
        ...json.errors.map((e) => `- [${e.code}] ${e.message in errorMap
            ? errorMap[e.message]
            : e.message}`),
        "Learn more: https://developers.cloudflare.com/ai-search/configuration/data-source/website/",
    ].join("\n"));
}
// ─── Namespace-scoped API Helper Functions ───────────────────────────────────
/**
 * Base path for namespace-scoped instance operations
 */
function aiSearchInstanceBasePath(api, namespace) {
    return `/accounts/${api.accountId}/ai-search/namespaces/${namespace}/instances`;
}
export async function listAiSearchInstances(api, namespace = "default") {
    return await extractCloudflareResult("list AI Search instances", api.get(aiSearchInstanceBasePath(api, namespace)));
}
export async function createAiSearchInstance(api, namespace, payload) {
    return await extractCloudflareResult(`create AI Search instance "${payload.id}"`, api.post(aiSearchInstanceBasePath(api, namespace), payload));
}
export async function getAiSearchInstance(api, namespace, id) {
    return await extractCloudflareResult(`get AI Search instance "${id}"`, api.get(`${aiSearchInstanceBasePath(api, namespace)}/${id}`));
}
export async function updateAiSearchInstance(api, namespace, id, payload) {
    return await extractCloudflareResult(`update AI Search instance "${id}"`, api.put(`${aiSearchInstanceBasePath(api, namespace)}/${id}`, payload));
}
export async function deleteAiSearchInstance(api, namespace, id) {
    try {
        await extractCloudflareResult(`delete AI Search instance "${id}"`, api.delete(`${aiSearchInstanceBasePath(api, namespace)}/${id}`));
    }
    catch (error) {
        if (error instanceof CloudflareApiError && error.status === 404) {
            return;
        }
        throw error;
    }
    // Cloudflare's DELETE returns 204 before the instance fully disappears
    // from the backing services. A subsequent GET can return the stale row
    // for a bounded window — same-colo is invalidated immediately via the
    // edge cache, cross-colo is bounded by a 60s KV TTL.
    //
    // Actively wait here so the destroy phase presents a strongly-
    // consistent "instance is gone" guarantee to its callers (child token
    // delete, user-facing teardown assertions, dependent resources).
    await poll({
        description: `wait for AI Search instance "${id}" deletion to propagate`,
        fn: () => api.get(`${aiSearchInstanceBasePath(api, namespace)}/${id}`),
        predicate: (res) => res.status === 404,
        initialDelay: 500,
        maxDelay: 5000,
        timeout: 90_000,
    });
}
export async function listAiSearchJobs(api, namespace, aiSearchId) {
    return await extractCloudflareResult(`list AI Search jobs for instance "${aiSearchId}"`, api.get(`${aiSearchInstanceBasePath(api, namespace)}/${aiSearchId}/jobs`));
}
export async function createAiSearchJob(api, namespace, aiSearchId) {
    return await extractCloudflareResult(`create AI Search job for instance "${aiSearchId}"`, api.post(`${aiSearchInstanceBasePath(api, namespace)}/${aiSearchId}/jobs`, {}));
}
export async function getAiSearchJob(api, namespace, aiSearchId, jobId) {
    return await extractCloudflareResult(`get AI Search job "${jobId}" for instance "${aiSearchId}"`, api.get(`${aiSearchInstanceBasePath(api, namespace)}/${aiSearchId}/jobs/${jobId}`));
}
export async function listAiSearchJobLogs(api, namespace, aiSearchId, jobId) {
    try {
        return await extractCloudflareResult(`list AI Search job logs for job "${jobId}" for instance "${aiSearchId}"`, api.get(`${aiSearchInstanceBasePath(api, namespace)}/${aiSearchId}/jobs/${jobId}/logs?per_page=500`));
    }
    catch (error) {
        if (isCloudflareApiError(error, { code: 7002 }) // ai_search_not_found
        ) {
            return [];
        }
        throw error;
    }
}
export async function runAiSearchJob(api, namespace, aiSearchId, log) {
    log("Preparing to index...");
    const job = await createAiSearchJob(api, namespace, aiSearchId);
    let lastLogId = 0;
    let done = false;
    const resultPromise = poll({
        description: `run AI Search job "${job.id}" for instance "${aiSearchId}"`,
        fn: () => getAiSearchJob(api, namespace, aiSearchId, job.id),
        predicate: (result) => result.ended_at !== null,
    });
    pollLogs();
    const result = await resultPromise;
    done = true;
    log(`Sync completed: ${result.end_reason}`);
    async function pollLogs() {
        const logs = await listAiSearchJobLogs(api, namespace, aiSearchId, job.id);
        for (let i = logs.length - 1; i >= 0; i--) {
            const item = logs[i];
            if (item.id > lastLogId) {
                lastLogId = item.id;
                log(item.message);
            }
        }
        if (!done) {
            await sleep(3000);
            await pollLogs();
        }
    }
}
//# sourceMappingURL=ai-search.js.map