/**
 * Kimi K2.6 grounding adapter (Azure AI Foundry).
 *
 * Sends a vision+text request to Foundry's Azure AI inference endpoint
 * for the `Kimi-K2.6` deployment. Translates the OpenAI-flavoured
 * `reasoning_effort` knob (which Moonshot's Foundry wrapper accepts as
 * 'none'|'low'|'medium'|'high') and Moonshot's `extra_body.thinking`
 * shape into a single wire request. Parses both `content` and
 * `reasoning_content` from the response.
 *
 * Why this is a thin wrapper and not "the smart layer": grounding
 * intelligence (caching, region cropping, retry-on-miss) belongs
 * one level up in the DesktopTool. This file's job is "the wire is
 * boring" — every Kimi-specific concern stays here.
 *
 * @typedef {import('./types.js').GroundOptions} GroundOptions
 * @typedef {import('./types.js').GroundResult}  GroundResult
 * @typedef {import('./types.js').KnobDef}       KnobDef
 * @typedef {import('./types.js').Capabilities}  Capabilities
 */

import { GroundingModel, makeError } from './GroundingModel.js';
import { parseCoords } from './groundingMetrics.js';
import {
  ReasoningEffort,
  REASONING_EFFORT_VALUES,
  DEFAULT_REASONING_EFFORT,
  SamplingDefaults,
  TokenBudget,
  Timeouts,
  ImageDetail,
  DEFAULT_IMAGE_DETAIL,
  Foundry,
  ModelId,
  KnobType,
  ErrorCode,
} from './constants.js';

/**
 * Effort levels for which the thinking-off sampling defaults apply.
 * Kept here (not in constants.js) because it's an internal detail
 * of Kimi: 'none' is the only setting that disables the reasoning
 * trace.
 */
const EFFORTS_WITHOUT_THINKING = new Set([ReasoningEffort.NONE]);

export class KimiAdapter extends GroundingModel {
  static displayName = 'Kimi K2.6 (Foundry)';
  static modelId     = ModelId.KIMI_K2_6;

  /**
   * Two transport modes:
   *
   *   - 'direct' (default): hit Foundry directly. Requires `endpoint`
   *     + `apiKey` (Foundry account key). Useful for the bench harness
   *     and headless integration tests.
   *
   *   - 'proxy': hit the Loxia backend's POST /llm/grounding route,
   *     which centralises billing + audit. Requires `backendUrl` +
   *     `userApiKey` (Loxia per-user token, NOT the Foundry key). This
   *     is the mode every production agent uses.
   *
   * @param {Object} cfg
   * @param {'direct'|'proxy'} [cfg.mode='direct']
   * @param {string} [cfg.endpoint]    - direct mode: Foundry base URL
   * @param {string} [cfg.apiKey]      - direct mode: Foundry account key
   * @param {string} [cfg.deployment]  - deployment name (defaults to kimi-k2.6)
   * @param {string} [cfg.backendUrl]  - proxy mode: e.g. https://api.loxia.ai
   * @param {string} [cfg.userApiKey]  - proxy mode: per-user Loxia token
   * @param {typeof fetch} [cfg.fetchImpl] - dependency injection for tests
   * @param {object} [cfg.logger]
   */
  constructor(cfg) {
    super();
    if (!cfg || typeof cfg !== 'object') {
      throw makeError(ErrorCode.INVALID_REQUEST,
        'KimiAdapter requires a config object');
    }
    this.mode = cfg.mode || 'direct';
    if (this.mode === 'direct') {
      if (!cfg.endpoint) throw makeError(ErrorCode.INVALID_REQUEST, 'endpoint is required in direct mode');
      if (!cfg.apiKey)   throw makeError(ErrorCode.AUTH_FAILED, 'apiKey is required in direct mode');
      this.endpoint = cfg.endpoint.endsWith('/') ? cfg.endpoint : cfg.endpoint + '/';
      this.apiKey   = cfg.apiKey;
    } else if (this.mode === 'proxy') {
      if (!cfg.backendUrl) throw makeError(ErrorCode.INVALID_REQUEST, 'backendUrl is required in proxy mode');
      if (!cfg.userApiKey) throw makeError(ErrorCode.AUTH_FAILED,    'userApiKey is required in proxy mode');
      this.backendUrl = cfg.backendUrl.replace(/\/+$/, '');
      this.userApiKey = cfg.userApiKey;
    } else {
      throw makeError(ErrorCode.INVALID_REQUEST,
        `unknown mode "${cfg.mode}" — must be 'direct' or 'proxy'`);
    }
    this.deployment = cfg.deployment || ModelId.KIMI_K2_6;
    this.fetchImpl  = cfg.fetchImpl  || globalThis.fetch;
    this.logger     = cfg.logger     || null;
  }

  capabilities() {
    return {
      pixelGrounding:        true,
      toolCalling:           true,
      multiTurnLoop:         false,
      visionInput:           true,
      maxImagePixels:        4_000_000,
      typicalLatencyMs:      80_000,
      typicalCostPerCallUSD: 0.02,
    };
  }

  supportedKnobs() {
    return [
      {
        id: 'reasoning_effort',
        label: 'Reasoning effort',
        type: KnobType.ENUM,
        default: DEFAULT_REASONING_EFFORT,
        options: REASONING_EFFORT_VALUES.map(v => ({ value: v })),
        doc:
          "Higher = more accurate but slower. 'low' is the empirical sweet " +
          "spot (~3px error / ~80s). 'none' is fast but unreliable on dense UIs.",
      },
      {
        id: 'temperature',
        label: 'Temperature',
        type: KnobType.NUMBER,
        default: SamplingDefaults.THINKING_ON.temperature,
        range: [0, 2],
        doc: "Moonshot recommends 1.0 with thinking, 0.6 without.",
        advanced: true,
      },
      {
        id: 'top_p',
        label: 'Top-p',
        type: KnobType.NUMBER,
        default: SamplingDefaults.THINKING_ON.top_p,
        range: [0, 1],
        advanced: true,
      },
      {
        id: 'image_detail',
        label: 'Image detail',
        type: KnobType.ENUM,
        default: DEFAULT_IMAGE_DETAIL,
        options: Object.values(ImageDetail).map(v => ({ value: v })),
      },
      {
        id: 'max_tokens',
        label: 'Max output tokens',
        type: KnobType.NUMBER,
        default: TokenBudget.THINKING_DEFAULT_MAX_TOKENS,
        range: [TokenBudget.INSTANT_DEFAULT_MAX_TOKENS, 32_000],
        advanced: true,
      },
      {
        id: 'seed',
        label: 'Seed (for reproducibility)',
        type: KnobType.NUMBER,
        default: null,
        advanced: true,
      },
    ];
  }

  validateKnobs(knobs) {
    // Start with the base check (rejects unknown keys).
    const base = super.validateKnobs(knobs);
    const errors = { ...base.errors };

    const k = knobs || {};
    if (k.reasoning_effort !== undefined
        && !REASONING_EFFORT_VALUES.includes(k.reasoning_effort)) {
      errors.reasoning_effort = `must be one of ${REASONING_EFFORT_VALUES.join(', ')}`;
    }
    if (k.temperature !== undefined && !inRange(k.temperature, 0, 2)) {
      errors.temperature = 'must be a number in [0, 2]';
    }
    if (k.top_p !== undefined && !inRange(k.top_p, 0, 1)) {
      errors.top_p = 'must be a number in [0, 1]';
    }
    if (k.image_detail !== undefined && !Object.values(ImageDetail).includes(k.image_detail)) {
      errors.image_detail = `must be one of ${Object.values(ImageDetail).join(', ')}`;
    }
    if (k.max_tokens !== undefined && (!Number.isInteger(k.max_tokens) || k.max_tokens < 1)) {
      errors.max_tokens = 'must be a positive integer';
    }
    return { valid: Object.keys(errors).length === 0, errors };
  }

  /**
   * @param {GroundOptions} opts
   * @returns {Promise<GroundResult>}
   */
  async ground(opts) {
    if (!opts || !Buffer.isBuffer(opts.screenshot)) {
      throw makeError(ErrorCode.INVALID_REQUEST, 'screenshot (Buffer) is required');
    }
    if (typeof opts.intent !== 'string' || !opts.intent.trim()) {
      throw makeError(ErrorCode.INVALID_REQUEST, 'intent (non-empty string) is required');
    }
    const knobs = opts.knobs || {};
    const validation = this.validateKnobs(knobs);
    if (!validation.valid) {
      throw makeError(ErrorCode.UNSUPPORTED_KNOB,
        `invalid knobs: ${JSON.stringify(validation.errors)}`,
        validation.errors);
    }

    const effort = knobs.reasoning_effort ?? DEFAULT_REASONING_EFFORT;
    const reasoningOn = !EFFORTS_WITHOUT_THINKING.has(effort);
    const sampling = reasoningOn ? SamplingDefaults.THINKING_ON : SamplingDefaults.THINKING_OFF;

    const timeoutMs = opts.timeoutMs
      || (reasoningOn ? Timeouts.GROUNDING_REQUEST_MS : Timeouts.GROUNDING_REQUEST_FAST_MS);

    const startedAt = Date.now();
    let data;
    if (this.mode === 'direct') {
      const body = this._buildRequestBody({ opts, knobs, effort, sampling });
      const url  = `${this.endpoint}${Foundry.CHAT_COMPLETIONS_PATH}?api-version=${Foundry.API_VERSION}`;
      data = await this._postDirect(url, body, timeoutMs);
    } else {
      const body = this._buildProxyBody({ opts, knobs, effort, sampling });
      data = await this._postProxy(body, timeoutMs);
    }
    const elapsed_ms = Date.now() - startedAt;
    return this._parseResponse(data, elapsed_ms);
  }

  // ─── private helpers ──────────────────────────────────────────────

  /** Build the OpenAI-shaped chat-completions body Foundry expects. */
  _buildRequestBody({ opts, knobs, effort, sampling }) {
    const detail   = knobs.image_detail ?? DEFAULT_IMAGE_DETAIL;
    const temperature = knobs.temperature ?? sampling.temperature;
    const top_p   = knobs.top_p ?? sampling.top_p;
    const max_tokens = knobs.max_tokens ?? TokenBudget.THINKING_DEFAULT_MAX_TOKENS;
    const b64 = opts.screenshot.toString('base64');

    const body = {
      model: this.deployment,
      reasoning_effort: effort,
      temperature,
      top_p,
      max_tokens,
      messages: [{
        role: 'user',
        content: [
          { type: 'text', text: this._formatIntent(opts) },
          { type: 'image_url', image_url: { url: `data:image/png;base64,${b64}`, detail } },
        ],
      }],
    };
    if (Array.isArray(opts.tools) && opts.tools.length > 0) {
      body.tools = opts.tools;
      body.tool_choice = 'auto';
    }
    if (Number.isFinite(knobs.seed)) {
      body.seed = knobs.seed;
    }
    return body;
  }

  /**
   * Add an optional image-dimension hint to the prompt. From the
   * Phase-0 bench: telling Kimi the resolution cuts mean error from
   * 666px → 41px on a single shot. Cheap, always include.
   */
  _formatIntent(opts) {
    const { intent, imageSize } = opts;
    if (imageSize?.width && imageSize?.height) {
      return `This is a ${imageSize.width}x${imageSize.height} screenshot. ${intent}`;
    }
    return intent;
  }

  /**
   * Build the request body the proxy expects (matches POST /llm/grounding).
   * Same messages shape as direct mode — the proxy doesn't transform.
   */
  _buildProxyBody({ opts, knobs, effort, sampling }) {
    const direct = this._buildRequestBody({ opts, knobs, effort, sampling });
    // The proxy uses its own deployment routing; strip the model
    // field we'd send to Foundry directly and surface the logical
    // model id instead. All knobs pass through.
    return {
      model:            this.deployment,
      messages:         direct.messages,
      reasoning_effort: direct.reasoning_effort,
      temperature:      direct.temperature,
      top_p:            direct.top_p,
      max_tokens:       direct.max_tokens,
      ...(direct.seed !== undefined        ? { seed:        direct.seed }        : {}),
      ...(direct.tools !== undefined       ? { tools:       direct.tools }       : {}),
      ...(direct.tool_choice !== undefined ? { tool_choice: direct.tool_choice } : {}),
    };
  }

  /** Post to Foundry directly (mode: 'direct'). */
  async _postDirect(url, body, timeoutMs) {
    return this._postJson(url, body, timeoutMs, {
      'Authorization': `Bearer ${this.apiKey}`,
    });
  }

  /**
   * Post via the Loxia backend (mode: 'proxy'). The backend signs the
   * request with the tenant-shared Foundry key and records billing,
   * then returns a normalised shape we coerce back to the Foundry
   * chat-completion contract for `_parseResponse`.
   */
  async _postProxy(body, timeoutMs) {
    const url = `${this.backendUrl}/llm/grounding`;
    const proxied = await this._postJson(url, body, timeoutMs, {
      'Authorization': `Bearer ${this.userApiKey}`,
    });
    // Re-coerce to chat-completion shape so _parseResponse stays
    // ignorant of the transport.
    return {
      choices: [{
        message: {
          content:           proxied.answer ?? '',
          reasoning_content: proxied.reasoning ?? undefined,
          tool_calls:        proxied.tool_calls ?? undefined,
        },
      }],
      usage: {
        prompt_tokens:     proxied.usage?.inputTokens  ?? 0,
        completion_tokens: proxied.usage?.outputTokens ?? 0,
      },
    };
  }

  async _postJson(url, body, timeoutMs, extraHeaders) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const res = await this.fetchImpl(url, {
        method: 'POST',
        headers: {
          'Content-Type':  'application/json',
          ...extraHeaders,
        },
        body: JSON.stringify(body),
        signal: controller.signal,
      });
      const text = await res.text();
      let parsed;
      try { parsed = JSON.parse(text); }
      catch {
        throw makeError(ErrorCode.PROVIDER_ERROR,
          `non-JSON response (status ${res.status}): ${text.slice(0, 300)}`);
      }
      if (!res.ok) {
        const code = res.status === 401 || res.status === 403
          ? ErrorCode.AUTH_FAILED
          : ErrorCode.PROVIDER_ERROR;
        // Identify the upstream accurately so a 404 from "the backend
        // route isn't deployed" doesn't read as "Foundry 404" (it isn't
        // Foundry — it's the proxy). The error text shapes the agent's
        // recovery + ours.
        const upstream = this.mode === 'proxy' ? 'Backend' : 'Foundry';
        const hint = (this.mode === 'proxy' && res.status === 404)
          ? ' — the /llm/grounding route appears to be undeployed on this backend. ' +
            'Set FOUNDRY_ENDPOINT + FOUNDRY_KEY env vars to fall back to direct mode.'
          : '';
        throw makeError(code,
          `${upstream} ${res.status}: ${parsed?.error?.message || parsed?.error?.code || text.slice(0, 200)}${hint}`,
          { status: res.status, body: parsed, mode: this.mode });
      }
      return parsed;
    } catch (err) {
      if (err.name === 'AbortError') {
        throw makeError(ErrorCode.TIMEOUT, `request exceeded ${timeoutMs}ms`);
      }
      // Re-throw typed errors as-is; wrap unknowns.
      if (err.code && Object.values(ErrorCode).includes(err.code)) throw err;
      throw makeError(ErrorCode.PROVIDER_ERROR, err.message || String(err));
    } finally {
      clearTimeout(timer);
    }
  }

  _parseResponse(data, elapsed_ms) {
    const choice = data?.choices?.[0];
    if (!choice) {
      throw makeError(ErrorCode.UNPARSEABLE_RESPONSE,
        'response has no choices[0]', { data });
    }
    const msg   = choice.message || {};
    const answer = (msg.content || '').trim();
    const reasoning_content = msg.reasoning_content || undefined;
    const tool_calls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0
      ? msg.tool_calls
      : undefined;
    const usage = {
      input_tokens:     numberOrZero(data.usage?.prompt_tokens),
      output_tokens:    numberOrZero(data.usage?.completion_tokens),
      reasoning_tokens: numberOrZero(data.usage?.reasoning_tokens
                                  ?? data.usage?.completion_tokens_details?.reasoning_tokens),
    };
    /** @type {import('./types.js').GroundResult} */
    const result = {
      answer,
      reasoning_content,
      tool_calls,
      usage,
      elapsed_ms,
      modelId: KimiAdapter.modelId,
      raw: data,
    };
    const coords = parseCoords(answer);
    if (coords) result.coords = coords;
    return result;
  }
}

// ── module-scoped helpers ────────────────────────────────────────────

function inRange(v, lo, hi) {
  return typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
}
function numberOrZero(v) {
  return Number.isFinite(v) ? v : 0;
}

export default KimiAdapter;
