import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import { buildVisionPrompt } from './visionPrompt.js';
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';

function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
  const msg = ((e as Error)?.message ?? '').toLowerCase();
  if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
  if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
  if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
  return 'other';
}

/**
 * v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
 * 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
 * 추출해서 JSON.parse 재시도.
 *
 * v0.3.14 — fail 시 throw 대신 `{}` 반환. schema 의 graceful coerce 가 빈 객체를
 * placeholder title/summary 로 채움 → 사용자 데이터 손실 없이 노트 보관 (raw_text 그대로).
 * 모델이 repetition loop 로 num_predict cap 도달해 JSON truncate 된 케이스에 robust.
 * 원본 응답 snippet 은 console.warn 으로 로그 (디버그성).
 */
function parseJsonLoose(raw: string): unknown {
  try { return JSON.parse(raw); } catch { /* fallback below */ }
  const first = raw.indexOf('{');
  const last = raw.lastIndexOf('}');
  if (first >= 0 && last > first) {
    const slice = raw.slice(first, last + 1);
    try { return JSON.parse(slice); } catch { /* fall through */ }
  }
  // 빈 객체 반환 → schema coerce 로 placeholder 적용. 원본 일부는 stderr 에 남김.
  console.warn(`[LocalOllamaProvider] unparseable response, falling back to {}: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
  return {};
}

export interface LocalOllamaOptions {
  endpoint?: string;
  model?: string;
  timeoutMs?: number;
  temperature?: number;
  numPredict?: number;
}

export class LocalOllamaProvider implements InferenceProvider {
  readonly name: string;
  private endpoint: string;
  private model: string;
  private timeoutMs: number;
  private temperature: number;
  private numPredict: number;
  private abortController: AbortController | null = null;

  constructor(opts: LocalOllamaOptions = {}) {
    this.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
    this.model = opts.model ?? DEFAULT_OLLAMA_MODEL;
    this.timeoutMs = opts.timeoutMs ?? 120_000;
    this.temperature = opts.temperature ?? 0.2;
    this.numPredict = opts.numPredict ?? 512;
    this.name = `local-ollama/${this.model}`;
  }

  async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
    const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
    const model = useVision ? opts!.visionModel! : this.model;
    const prompt = useVision
      ? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
      : buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);

    // v0.3.13 — vision model 은 cold-start (모델 load + 이미지 encoding) 가 매우 느려
    // 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision
    // 모델은 첫 generate 가 60-180s 소요. 5분 (300s) 으로 확장.
    const effectiveTimeout = useVision ? Math.max(this.timeoutMs, 300_000) : this.timeoutMs;
    this.abortController = new AbortController();
    const timer = setTimeout(() => this.abortController?.abort(), effectiveTimeout);
    try {
      const body: Record<string, unknown> = {
        model,
        prompt,
        format: 'json',
        stream: false,
        // v0.3.14 — repeat_penalty 추가. vision 모델 (특히 gemma 시리즈) 이 "기기기기..."
        // 같은 repetition loop 에 빠져 num_predict cap 도달 → JSON truncate → unparseable.
        // 1.15 는 Ollama 권장 범위 (1.0-1.3) 안쪽 conservative 값.
        options: { temperature: this.temperature, num_predict: this.numPredict, repeat_penalty: 1.15 }
      };
      if (useVision) {
        body.images = input.images!.map((i) => i.base64);
      }
      const res = await request(`${this.endpoint}/api/generate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body),
        signal: this.abortController.signal
      });
      if (res.statusCode < 200 || res.statusCode >= 300) {
        throw new Error(`ollama http ${res.statusCode}`);
      }
      const responseBody = (await res.body.json()) as { response?: string };
      if (!responseBody.response) throw new Error('missing response field');
      // v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
      const parsed = parseJsonLoose(responseBody.response);
      return parseAiResponse(parsed);
    } finally {
      clearTimeout(timer);
      this.abortController = null;
    }
  }

  /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
  abort(): void {
    this.abortController?.abort();
  }

  /**
   * v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용).
   * `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제.
   * abortController / timeout 은 generate() 와 동일 패턴.
   */
  async generateRaw(prompt: string): Promise<string> {
    this.abortController = new AbortController();
    const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
    try {
      const res = await request(`${this.endpoint}/api/generate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          model: this.model,
          prompt,
          format: 'json',
          stream: false,
          options: { temperature: this.temperature, num_predict: this.numPredict }
        }),
        signal: this.abortController.signal
      });
      if (res.statusCode < 200 || res.statusCode >= 300) {
        throw new Error(`ollama http ${res.statusCode}`);
      }
      const body = (await res.body.json()) as { response?: string };
      if (!body.response) throw new Error('missing response field');
      return body.response;
    } finally {
      clearTimeout(timer);
      this.abortController = null;
    }
  }

  async healthCheck(): Promise<HealthResult> {
    try {
      const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
      if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` };
      const body = (await res.body.json()) as { models?: Array<{ name: string }> };
      const found = body.models?.some((m) => m.name === this.model);
      return found ? { ok: true, model: this.model }
                   : { ok: false, reason: `${this.model} not installed` };
    } catch (err) {
      const cls = classifyFetchError(err);
      return { ok: false, reason: `unreachable:${cls}` };
    }
  }
}
