import { Type } from "@sinclair/typebox";
import {
  buildSearchCacheKey,
  DEFAULT_SEARCH_COUNT,
  enablePluginInConfig,
  getScopedCredentialValue,
  mergeScopedSearchConfig,
  parseIsoDateRange,
  readCachedSearchPayload,
  readConfiguredSecretString,
  readNumberParam,
  readProviderEnvValue,
  readStringParam,
  resolveProviderWebSearchPluginConfig,
  resolveSearchCacheTtlMs,
  resolveSearchTimeoutSeconds,
  resolveSiteName,
  setProviderWebSearchPluginConfigValue,
  setScopedCredentialValue,
  type SearchConfigRecord,
  type WebSearchProviderPlugin,
  type WebSearchProviderToolDefinition,
  withTrustedWebSearchEndpoint,
  wrapWebContent,
  writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
  normalizeOptionalLowercaseString,
  normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";

const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;

type ExaConfig = {
  apiKey?: string;
};

type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];

type ExaTextContentsOption = boolean | { maxCharacters?: number };
type ExaHighlightsContentsOption =
  | boolean
  | {
      maxCharacters?: number;
      query?: string;
      numSentences?: number;
      highlightsPerUrl?: number;
    };
type ExaSummaryContentsOption = boolean | { query?: string };

type ExaContentsArgs = {
  highlights?: ExaHighlightsContentsOption;
  text?: ExaTextContentsOption;
  summary?: ExaSummaryContentsOption;
};

type ExaSearchResult = {
  title?: unknown;
  url?: unknown;
  publishedDate?: unknown;
  highlights?: unknown;
  highlightScores?: unknown;
  summary?: unknown;
  text?: unknown;
};

type ExaSearchResponse = {
  results?: unknown;
};

function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
  const trimmed = normalizeOptionalLowercaseString(value);
  if (!trimmed) {
    return undefined;
  }
  return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
    ? (trimmed as ExaFreshness)
    : undefined;
}

function optionalStringEnum<T extends readonly string[]>(values: T, description: string) {
  return Type.Optional(
    Type.Unsafe<T[number]>({
      type: "string",
      enum: [...values],
      description,
    }),
  );
}

function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
  const exa = searchConfig?.exa;
  return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
}

function resolveExaApiKey(exa?: ExaConfig): string | undefined {
  return (
    readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
    readProviderEnvValue(["EXA_API_KEY"])
  );
}

function resolveExaDescription(result: ExaSearchResult): string {
  const highlights = result.highlights;
  if (Array.isArray(highlights)) {
    const highlightText = highlights
      .map((entry) => normalizeOptionalString(entry))
      .filter((entry): entry is string => Boolean(entry))
      .join("\n");
    if (highlightText) {
      return highlightText;
    }
  }
  const summary = normalizeOptionalString(result.summary);
  if (summary) {
    return summary;
  }
  return normalizeOptionalString(result.text) ?? "";
}

function parsePositiveInteger(value: unknown): number | undefined {
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}

function invalidContentsPayload(message: string) {
  return {
    error: "invalid_contents",
    message,
    docs: "https://docs.openclaw.ai/tools/web",
  };
}

function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
  return Boolean(
    value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
  );
}

function resolveExaSearchCount(value: unknown, fallback: number): number {
  const parsed = typeof value === "number" ? value : Number(value);
  if (!Number.isFinite(parsed)) {
    return fallback;
  }
  return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
}

function parseExaContents(
  rawContents: unknown,
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
  if (rawContents === undefined) {
    return { value: undefined };
  }
  if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
    return invalidContentsPayload(
      "contents must be an object with optional text, highlights, and summary fields.",
    );
  }

  const raw = rawContents as Record<string, unknown>;
  const allowedKeys = new Set(["text", "highlights", "summary"]);
  for (const key of Object.keys(raw)) {
    if (!allowedKeys.has(key)) {
      return invalidContentsPayload(
        `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`,
      );
    }
  }

  const parsed: ExaContentsArgs = {};

  const parseText = (
    value: unknown,
  ): ExaTextContentsOption | { error: string; message: string; docs: string } => {
    if (typeof value === "boolean") {
      return value;
    }
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      return invalidContentsPayload("contents.text must be a boolean or an object.");
    }
    const obj = value as Record<string, unknown>;
    for (const key of Object.keys(obj)) {
      if (key !== "maxCharacters") {
        return invalidContentsPayload(
          `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
        );
      }
    }
    if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
      return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
    }
    return parsePositiveInteger(obj.maxCharacters)
      ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
      : {};
  };

  const parseHighlights = (
    value: unknown,
  ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
    if (typeof value === "boolean") {
      return value;
    }
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      return invalidContentsPayload("contents.highlights must be a boolean or an object.");
    }
    const obj = value as Record<string, unknown>;
    const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
    for (const key of Object.keys(obj)) {
      if (!allowed.has(key)) {
        return invalidContentsPayload(
          `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
        );
      }
    }
    if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
      return invalidContentsPayload(
        "contents.highlights.maxCharacters must be a positive integer.",
      );
    }
    if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
      return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
    }
    if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
      return invalidContentsPayload(
        "contents.highlights.highlightsPerUrl must be a positive integer.",
      );
    }
    if ("query" in obj && typeof obj.query !== "string") {
      return invalidContentsPayload("contents.highlights.query must be a string.");
    }
    return {
      ...(parsePositiveInteger(obj.maxCharacters)
        ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
        : {}),
      ...(typeof obj.query === "string" ? { query: obj.query } : {}),
      ...(parsePositiveInteger(obj.numSentences)
        ? { numSentences: parsePositiveInteger(obj.numSentences) }
        : {}),
      ...(parsePositiveInteger(obj.highlightsPerUrl)
        ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
        : {}),
    };
  };

  const parseSummary = (
    value: unknown,
  ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
    if (typeof value === "boolean") {
      return value;
    }
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      return invalidContentsPayload("contents.summary must be a boolean or an object.");
    }
    const obj = value as Record<string, unknown>;
    for (const key of Object.keys(obj)) {
      if (key !== "query") {
        return invalidContentsPayload(
          `contents.summary has unknown field "${key}". Only "query" is allowed.`,
        );
      }
    }
    if ("query" in obj && typeof obj.query !== "string") {
      return invalidContentsPayload("contents.summary.query must be a string.");
    }
    return typeof obj.query === "string" ? { query: obj.query } : {};
  };

  if ("text" in raw) {
    const parsedText = parseText(raw.text);
    if (isErrorPayload(parsedText)) {
      return parsedText;
    }
    parsed.text = parsedText;
  }
  if ("highlights" in raw) {
    const parsedHighlights = parseHighlights(raw.highlights);
    if (isErrorPayload(parsedHighlights)) {
      return parsedHighlights;
    }
    parsed.highlights = parsedHighlights;
  }
  if ("summary" in raw) {
    const parsedSummary = parseSummary(raw.summary);
    if (isErrorPayload(parsedSummary)) {
      return parsedSummary;
    }
    parsed.summary = parsedSummary;
  }

  return { value: parsed };
}

function normalizeExaResults(payload: unknown): ExaSearchResult[] {
  if (!payload || typeof payload !== "object") {
    return [];
  }
  const results = (payload as ExaSearchResponse).results;
  if (!Array.isArray(results)) {
    return [];
  }
  return results.filter((entry): entry is ExaSearchResult =>
    Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
  );
}

function resolveFreshnessStartDate(freshness: ExaFreshness): string {
  const now = new Date();
  if (freshness === "day") {
    now.setUTCDate(now.getUTCDate() - 1);
    return now.toISOString();
  }
  if (freshness === "week") {
    now.setUTCDate(now.getUTCDate() - 7);
    return now.toISOString();
  }
  if (freshness === "month") {
    const currentDay = now.getUTCDate();
    now.setUTCDate(1);
    now.setUTCMonth(now.getUTCMonth() - 1);
    const lastDayOfTargetMonth = new Date(
      Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
    ).getUTCDate();
    now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
    return now.toISOString();
  }
  now.setUTCFullYear(now.getUTCFullYear() - 1);
  return now.toISOString();
}

async function runExaSearch(params: {
  apiKey: string;
  query: string;
  count: number;
  freshness?: ExaFreshness;
  dateAfter?: string;
  dateBefore?: string;
  type: ExaSearchType;
  contents?: ExaContentsArgs;
  timeoutSeconds: number;
}): Promise<ExaSearchResult[]> {
  const body: Record<string, unknown> = {
    query: params.query,
    numResults: params.count,
    type: params.type,
    contents: params.contents ?? { highlights: true },
  };

  if (params.dateAfter) {
    body.startPublishedDate = params.dateAfter;
  } else if (params.freshness) {
    body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
  }
  if (params.dateBefore) {
    body.endPublishedDate = params.dateBefore;
  }

  return withTrustedWebSearchEndpoint(
    {
      url: EXA_SEARCH_ENDPOINT,
      timeoutSeconds: params.timeoutSeconds,
      init: {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          "x-api-key": params.apiKey,
          "x-exa-integration": "openclaw",
        },
        body: JSON.stringify(body),
      },
    },
    async (res) => {
      if (!res.ok) {
        const detail = await res.text();
        throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
      }
      try {
        return normalizeExaResults(await res.json());
      } catch (error) {
        throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
      }
    },
  );
}

function createExaSchema() {
  return Type.Object(
    {
      query: Type.String({ description: "Search query string." }),
      count: Type.Optional(
        Type.Number({
          description: "Number of results to return (1-100, subject to Exa search-type limits).",
          minimum: 1,
          maximum: EXA_MAX_SEARCH_COUNT,
        }),
      ),
      freshness: optionalStringEnum(
        EXA_FRESHNESS_VALUES,
        'Filter by time: "day", "week", "month", or "year".',
      ),
      date_after: Type.Optional(
        Type.String({
          description: "Only results published after this date (YYYY-MM-DD).",
        }),
      ),
      date_before: Type.Optional(
        Type.String({
          description: "Only results published before this date (YYYY-MM-DD).",
        }),
      ),
      type: optionalStringEnum(
        EXA_SEARCH_TYPES,
        'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
      ),
      contents: Type.Optional(
        Type.Object(
          {
            highlights: Type.Optional(
              Type.Unsafe<ExaHighlightsContentsOption>({
                description:
                  "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
              }),
            ),
            text: Type.Optional(
              Type.Unsafe<ExaTextContentsOption>({
                description: "Text config: true, or an object with maxCharacters.",
              }),
            ),
            summary: Type.Optional(
              Type.Unsafe<ExaSummaryContentsOption>({
                description: "Summary config: true, or an object with query.",
              }),
            ),
          },
          { additionalProperties: false },
        ),
      ),
    },
    { additionalProperties: false },
  );
}

function missingExaKeyPayload() {
  return {
    error: "missing_exa_api_key",
    message:
      "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
    docs: "https://docs.openclaw.ai/tools/web",
  };
}

function createExaToolDefinition(
  searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
  return {
    description:
      "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
    parameters: createExaSchema(),
    execute: async (args) => {
      const params = args;
      const exaConfig = resolveExaConfig(searchConfig);
      const apiKey = resolveExaApiKey(exaConfig);
      if (!apiKey) {
        return missingExaKeyPayload();
      }

      const query = readStringParam(params, "query", { required: true });
      const rawType = readStringParam(params, "type");
      const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
        ? (rawType as ExaSearchType)
        : "auto";
      const count =
        readNumberParam(params, "count", { integer: true }) ??
        searchConfig?.maxResults ??
        undefined;
      const rawFreshness = readStringParam(params, "freshness");
      const freshness = normalizeExaFreshness(rawFreshness);
      if (rawFreshness && !freshness) {
        return {
          error: "invalid_freshness",
          message: 'freshness must be one of "day", "week", "month", or "year".',
          docs: "https://docs.openclaw.ai/tools/web",
        };
      }

      const rawDateAfter = readStringParam(params, "date_after");
      const rawDateBefore = readStringParam(params, "date_before");
      if (freshness && (rawDateAfter || rawDateBefore)) {
        return {
          error: "conflicting_time_filters",
          message:
            "freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
          docs: "https://docs.openclaw.ai/tools/web",
        };
      }
      const parsedDateRange = parseIsoDateRange({
        rawDateAfter,
        rawDateBefore,
        invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
        invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
        invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
      });
      if ("error" in parsedDateRange) {
        return parsedDateRange;
      }
      const { dateAfter, dateBefore } = parsedDateRange;

      const parsedContents = parseExaContents(params.contents);
      if (isErrorPayload(parsedContents)) {
        return parsedContents;
      }
      const contents =
        parsedContents.value && Object.keys(parsedContents.value).length > 0
          ? parsedContents.value
          : undefined;

      const cacheKey = buildSearchCacheKey([
        "exa",
        type,
        query,
        resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
        freshness,
        dateAfter,
        dateBefore,
        contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
        contents?.text ? JSON.stringify(contents.text) : undefined,
        contents?.summary ? JSON.stringify(contents.summary) : undefined,
      ]);
      const cached = readCachedSearchPayload(cacheKey);
      if (cached) {
        return cached;
      }

      const start = Date.now();
      const results = await runExaSearch({
        apiKey,
        query,
        count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
        freshness,
        dateAfter,
        dateBefore,
        type,
        contents,
        timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
      });

      const payload = {
        query,
        provider: "exa",
        count: results.length,
        tookMs: Date.now() - start,
        externalContent: {
          untrusted: true,
          source: "web_search",
          provider: "exa",
          wrapped: true,
        },
        results: results.map((entry) => {
          const title = typeof entry.title === "string" ? entry.title : "";
          const url = typeof entry.url === "string" ? entry.url : "";
          const description = resolveExaDescription(entry);
          const summary = normalizeOptionalString(entry.summary) ?? "";
          const highlightScores = Array.isArray(entry.highlightScores)
            ? entry.highlightScores.filter(
                (score): score is number => typeof score === "number" && Number.isFinite(score),
              )
            : [];
          const published =
            typeof entry.publishedDate === "string" && entry.publishedDate
              ? entry.publishedDate
              : undefined;
          return {
            title: title ? wrapWebContent(title, "web_search") : "",
            url,
            description: description ? wrapWebContent(description, "web_search") : "",
            published,
            siteName: resolveSiteName(url) || undefined,
            ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
            ...(highlightScores.length > 0 ? { highlightScores } : {}),
          };
        }),
      };

      writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
      return payload;
    },
  };
}

export function createExaWebSearchProvider(): WebSearchProviderPlugin {
  return {
    id: "exa",
    label: "Exa Search",
    hint: "Neural + keyword search with date filters and content extraction",
    onboardingScopes: ["text-inference"],
    credentialLabel: "Exa API key",
    envVars: ["EXA_API_KEY"],
    placeholder: "exa-...",
    signupUrl: "https://exa.ai/",
    docsUrl: "https://docs.openclaw.ai/tools/web",
    autoDetectOrder: 65,
    credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
    inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"],
    getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"),
    setCredentialValue: (searchConfigTarget, value) =>
      setScopedCredentialValue(searchConfigTarget, "exa", value),
    getConfiguredCredentialValue: (config) =>
      resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey,
    setConfiguredCredentialValue: (configTarget, value) => {
      setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value);
    },
    applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config,
    createTool: (ctx) =>
      createExaToolDefinition(
        mergeScopedSearchConfig(
          ctx.searchConfig,
          "exa",
          resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
        ),
      ),
  };
}

export const __testing = {
  normalizeExaResults,
  normalizeExaFreshness,
  parseExaContents,
  resolveExaApiKey,
  resolveExaConfig,
  resolveExaDescription,
  resolveExaSearchCount,
  resolveFreshnessStartDate,
} as const;
