/**
 * @wrnexus/ai — a tiny, zero-dependency Claude (Anthropic) client for WrNexus apps.
 *
 * Use it in API routes, jobs, or anywhere server-side to generate text with Claude.
 * It talks to the Anthropic Messages API over `fetch` (no SDK dependency, Bun-native),
 * and defaults to the most capable model, `claude-opus-4-8`.
 *
 *   import { createAI } from "@wrnexus/ai";
 *   const ai = createAI();                       // reads ANTHROPIC_API_KEY
 *   const text = await ai.generate("Write a haiku about Bun.");
 *
 * Streaming (great for API routes):
 *   export const POST = async (ctx) => ai.streamResponse(await ctx.req.text());
 */

export type Role = "user" | "assistant";

export interface Message {
  role: Role;
  content: string;
}

/** Reasoning effort — higher means deeper thinking + more tokens. */
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";

export interface AIConfig {
  /** Anthropic API key. Default: `ANTHROPIC_API_KEY` from the environment. */
  apiKey?: string;
  /** Model id. Default: `claude-opus-4-8` (the most capable Claude model). */
  model?: string;
  /** Default max output tokens. Default: 4096. */
  maxTokens?: number;
  /** API base URL. Default: `https://api.anthropic.com`. */
  baseURL?: string;
  /** `anthropic-version` header. Default: `2023-06-01`. */
  version?: string;
}

export interface GenerateOptions {
  /** System prompt — sets the assistant's role/behavior. */
  system?: string;
  /** Override the model for this call. */
  model?: string;
  /** Override max output tokens for this call. */
  maxTokens?: number;
  /** Enable adaptive extended thinking (slower, deeper reasoning). */
  thinking?: boolean;
  /** Reasoning effort / token spend (`output_config.effort`). */
  effort?: Effort;
  /** Full message history — supersedes the `prompt` argument when provided. */
  messages?: Message[];
  /** Abort the request. */
  signal?: AbortSignal;
}

/** Thrown when the API returns a non-2xx response or refuses the request. */
export class AIError extends Error {
  readonly status: number;
  readonly type: string;
  constructor(message: string, status = 0, type = "api_error") {
    super(message);
    this.name = "AIError";
    this.status = status;
    this.type = type;
  }
}

export interface AI {
  /** Generate a full text response (non-streaming). */
  generate(prompt: string | Message[], opts?: GenerateOptions): Promise<string>;
  /** Stream the response as text deltas, as they arrive. */
  stream(prompt: string | Message[], opts?: GenerateOptions): AsyncGenerator<string, void, unknown>;
  /** Stream straight to a `Response` (text/plain) — drop-in for an API route return. */
  streamResponse(prompt: string | Message[], opts?: GenerateOptions): Response;
}

const DEFAULT_MODEL = "claude-opus-4-8";
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_BASE_URL = "https://api.anthropic.com";
const DEFAULT_VERSION = "2023-06-01";

function envKey(): string | undefined {
  // Prefer Bun.env; fall back to process.env (works under Node-compatible runtimes too).
  const g = globalThis as {
    Bun?: { env: Record<string, string | undefined> };
    process?: { env: Record<string, string | undefined> };
  };
  return g.Bun?.env?.ANTHROPIC_API_KEY ?? g.process?.env?.ANTHROPIC_API_KEY;
}

function toMessages(prompt: string | Message[], opts?: GenerateOptions): Message[] {
  if (opts?.messages?.length) return opts.messages;
  if (typeof prompt === "string") return [{ role: "user", content: prompt }];
  return prompt;
}

/** Create a Claude client. Reads `ANTHROPIC_API_KEY` from the environment by default. */
export function createAI(config: AIConfig = {}): AI {
  const baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
  const version = config.version ?? DEFAULT_VERSION;
  const defaultModel = config.model ?? DEFAULT_MODEL;
  const defaultMaxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;

  const buildBody = (
    prompt: string | Message[],
    opts: GenerateOptions | undefined,
    stream: boolean,
  ) => {
    // NOTE: temperature/top_p/top_k are intentionally omitted — they are rejected
    // (400) on claude-opus-4-8 and the current Claude models. Steer via prompting.
    const body: Record<string, unknown> = {
      model: opts?.model ?? defaultModel,
      max_tokens: opts?.maxTokens ?? defaultMaxTokens,
      messages: toMessages(prompt, opts),
      stream,
    };
    if (opts?.system) body.system = opts.system;
    if (opts?.thinking) body.thinking = { type: "adaptive" };
    if (opts?.effort) body.output_config = { effort: opts.effort };
    return body;
  };

  const request = async (
    prompt: string | Message[],
    opts: GenerateOptions | undefined,
    stream: boolean,
  ): Promise<Response> => {
    const apiKey = config.apiKey ?? envKey();
    if (!apiKey) {
      throw new AIError(
        "Missing Anthropic API key. Set ANTHROPIC_API_KEY in your environment or pass { apiKey } to createAI().",
        0,
        "authentication_error",
      );
    }
    const res = await fetch(`${baseURL}/v1/messages`, {
      method: "POST",
      headers: {
        "x-api-key": apiKey,
        "anthropic-version": version,
        "content-type": "application/json",
      },
      body: JSON.stringify(buildBody(prompt, opts, stream)),
      signal: opts?.signal,
    });
    if (!res.ok) {
      let detail = `${res.status} ${res.statusText}`;
      let type = "api_error";
      try {
        const err = (await res.json()) as { error?: { message?: string; type?: string } };
        if (err.error?.message) detail = err.error.message;
        if (err.error?.type) type = err.error.type;
      } catch {
        /* non-JSON error body */
      }
      throw new AIError(detail, res.status, type);
    }
    return res;
  };

  const generate: AI["generate"] = async (prompt, opts) => {
    const res = await request(prompt, opts, false);
    const data = (await res.json()) as {
      stop_reason?: string;
      content?: Array<{ type: string; text?: string }>;
    };
    if (data.stop_reason === "refusal") {
      throw new AIError("The model declined to respond to this request.", 200, "refusal");
    }
    return (data.content ?? [])
      .filter((b) => b.type === "text" && typeof b.text === "string")
      .map((b) => b.text)
      .join("");
  };

  async function* stream(
    prompt: string | Message[],
    opts?: GenerateOptions,
  ): AsyncGenerator<string, void, unknown> {
    const res = await request(prompt, opts, true);
    if (!res.body) return;
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    const textDelta = (line: string): string | undefined => {
      if (!line.startsWith("data:")) return undefined;
      const payload = line.slice(5).trim();
      if (!payload || payload === "[DONE]") return undefined;
      try {
        const evt = JSON.parse(payload) as {
          type?: string;
          delta?: { type?: string; text?: string };
        };
        return evt.type === "content_block_delta" && evt.delta?.type === "text_delta"
          ? evt.delta.text
          : undefined;
      } catch {
        return undefined;
      }
    };
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        buf += decoder.decode();
        const final = textDelta(buf.trimEnd());
        if (final) yield final;
        break;
      }
      buf += decoder.decode(value, { stream: true });
      // SSE frames are separated by blank lines; process complete `data:` lines.
      let nl: number;
      while ((nl = buf.indexOf("\n")) !== -1) {
        const line = buf.slice(0, nl).trimEnd();
        buf = buf.slice(nl + 1);
        const delta = textDelta(line);
        if (delta) yield delta;
      }
    }
  }

  const streamResponse: AI["streamResponse"] = (prompt, opts) => {
    const iterator = stream(prompt, opts);
    const body = new ReadableStream<Uint8Array>({
      async pull(controller) {
        try {
          const { value, done } = await iterator.next();
          if (done) {
            controller.close();
            return;
          }
          controller.enqueue(new TextEncoder().encode(value));
        } catch (err) {
          controller.error(err);
        }
      },
    });
    return new Response(body, {
      headers: {
        "content-type": "text/plain; charset=utf-8",
        "cache-control": "no-cache",
        "x-accel-buffering": "no",
      },
    });
  };

  return { generate, stream, streamResponse };
}
export {
  anthropicProvider,
  aiProvider,
  createAIClient,
  deterministicAIProvider,
} from "./providers.ts";
export type {
  AIAttemptEvent,
  AICircuitBreakerOptions,
  AIClient,
  AIClientOptions,
  AIProvider,
  AIProviderCapabilities,
  AIResult,
  AIRetryOptions,
  AITool,
  AIToolCall,
  AIUsage,
  DeterministicAIProviderOptions,
} from "./providers.ts";
export {
  aiRateLimiter,
  createRagPipeline,
  evaluateAI,
  googleAIProvider,
  guardedProvider,
  localAIProvider,
  maxPromptLength,
  memoryConversationStore,
  memoryVectorStore,
  openAIEmbeddings,
  openAIProvider,
  promptTemplate,
} from "./platform.ts";
export type {
  AIGuardrail,
  ConversationStore,
  EmbeddingProvider,
  HttpAIProviderOptions,
  VectorMatch,
  VectorRecord,
  VectorStore,
} from "./platform.ts";
