/**
 * tip-llm-guided.ts — Structured Inference Engine for tip-llm-v1
 *
 * DESIGN PRINCIPLE: tip-llm-v1 must NEVER think freely.
 * Every call goes through:
 *   1. Hard system prompt that locks the output format
 *   2. Schema validation of the response
 *   3. Repair retry (max 2x) with diff showing what was wrong
 *   4. Fallback to safe default (create_finding=false) on persistent failure
 *
 * Input:  source text/HTML + context (vendor, product, URL)
 * Output: validated TipLlmOutput JSON — always, never throws
 */

import { generate } from "./client";

// ─── Model config ─────────────────────────────────────────────────────────────

const TIP_LLM_MODEL    = process.env.TIP_LLM_MODEL    || "tip-llm-v1";
const TIP_OLLAMA_URL   = process.env.TIP_OLLAMA_URL   || process.env.OLLAMA_URL || "http://localhost:11434";
const TIP_LLM_TIMEOUT  = parseInt(process.env.TIP_LLM_TIMEOUT_MS || "30000");
const TIP_LLM_MAX_RETRY = 2;

// ─── Schema ───────────────────────────────────────────────────────────────────

export type TipLlmTask =
  | "research_plan"   // produce a crawl/search plan for a vendor/product
  | "extract"         // extract structured facts from provided HTML/text
  | "finding";        // produce a finding ticket from pre-gathered evidence

export interface TipLlmInput {
  task: TipLlmTask;
  /** Raw text or HTML snippet to analyse (max ~4000 chars recommended) */
  text?: string;
  /** Source URL the text was fetched from */
  url?: string;
  /** Vendor name context */
  vendor?: string;
  /** Product name / PID context */
  product?: string;
  /** Extra context string for the model */
  context?: string;
}

export interface EvidenceItem {
  /** Verbatim quote or data point found in the source */
  quote: string;
  /** Source URL or description (must be from the provided input, not invented) */
  source: string;
  /** How confident this item is: 0.0–1.0 */
  confidence: number;
}

export interface TipLlmOutput {
  /** Task that was executed */
  task: TipLlmTask;
  /** For research_plan: search queries to execute */
  search_patterns: string[];
  /** For extract: field names + XPath/CSS selectors or regex patterns */
  extraction_fields: Array<{
    field: string;
    selector_or_pattern: string;
    example?: string;
  }>;
  /** Rules for what counts as relevant vs noise */
  relevance_rules: string[];
  /** Concrete evidence items from the provided source text — EMPTY if nothing found */
  evidence: EvidenceItem[];
  /** Aggregate confidence 0.0–1.0. Must be 0 if evidence is empty. */
  confidence: number;
  /** True ONLY if evidence is non-empty AND confidence >= 0.4 */
  create_finding: boolean;
  /** Human-readable summary — one sentence max */
  summary: string;
  /** Raw sources referenced (URLs or "provided_text") */
  sources: string[];
}

// ─── Validation ───────────────────────────────────────────────────────────────

interface ValidationError {
  field: string;
  issue: string;
}

function validateOutput(raw: unknown): { ok: true; data: TipLlmOutput } | { ok: false; errors: ValidationError[] } {
  const errors: ValidationError[] = [];

  if (typeof raw !== "object" || raw === null) {
    return { ok: false, errors: [{ field: "root", issue: "Response is not a JSON object" }] };
  }

  const obj = raw as Record<string, unknown>;

  // task
  const validTasks: TipLlmTask[] = ["research_plan", "extract", "finding"];
  if (!validTasks.includes(obj.task as TipLlmTask)) {
    errors.push({ field: "task", issue: `Must be one of: ${validTasks.join(", ")}` });
  }

  // search_patterns
  if (!Array.isArray(obj.search_patterns)) {
    errors.push({ field: "search_patterns", issue: "Must be an array of strings" });
  } else if (obj.search_patterns.some((x) => typeof x !== "string")) {
    errors.push({ field: "search_patterns", issue: "All items must be strings" });
  }

  // extraction_fields
  if (!Array.isArray(obj.extraction_fields)) {
    errors.push({ field: "extraction_fields", issue: "Must be an array" });
  } else {
    for (const [i, ef] of obj.extraction_fields.entries()) {
      if (typeof ef !== "object" || ef === null) {
        errors.push({ field: `extraction_fields[${i}]`, issue: "Must be an object" });
        continue;
      }
      const e = ef as Record<string, unknown>;
      if (typeof e.field !== "string") errors.push({ field: `extraction_fields[${i}].field`, issue: "Must be a string" });
      if (typeof e.selector_or_pattern !== "string") errors.push({ field: `extraction_fields[${i}].selector_or_pattern`, issue: "Must be a string" });
    }
  }

  // relevance_rules
  if (!Array.isArray(obj.relevance_rules)) {
    errors.push({ field: "relevance_rules", issue: "Must be an array of strings" });
  }

  // evidence
  if (!Array.isArray(obj.evidence)) {
    errors.push({ field: "evidence", issue: "Must be an array" });
  } else {
    for (const [i, ev] of obj.evidence.entries()) {
      if (typeof ev !== "object" || ev === null) {
        errors.push({ field: `evidence[${i}]`, issue: "Must be an object" });
        continue;
      }
      const e = ev as Record<string, unknown>;
      if (typeof e.quote !== "string" || e.quote.trim().length === 0) {
        errors.push({ field: `evidence[${i}].quote`, issue: "Must be a non-empty string" });
      }
      if (typeof e.source !== "string" || e.source.trim().length === 0) {
        errors.push({ field: `evidence[${i}].source`, issue: "Must be a non-empty string" });
      }
      if (typeof e.confidence !== "number" || e.confidence < 0 || e.confidence > 1) {
        errors.push({ field: `evidence[${i}].confidence`, issue: "Must be a number between 0 and 1" });
      }
    }
  }

  // confidence
  if (typeof obj.confidence !== "number" || obj.confidence < 0 || obj.confidence > 1) {
    errors.push({ field: "confidence", issue: "Must be a number between 0.0 and 1.0" });
  }

  // create_finding consistency check
  if (typeof obj.create_finding !== "boolean") {
    errors.push({ field: "create_finding", issue: "Must be a boolean" });
  } else {
    const evidence = Array.isArray(obj.evidence) ? obj.evidence : [];
    const confidence = typeof obj.confidence === "number" ? obj.confidence : 0;
    if (obj.create_finding === true && (evidence.length === 0 || confidence < 0.4)) {
      errors.push({
        field: "create_finding",
        issue: "Cannot be true when evidence is empty or confidence < 0.4 — set to false",
      });
    }
  }

  // summary
  if (typeof obj.summary !== "string") {
    errors.push({ field: "summary", issue: "Must be a string" });
  }

  // sources
  if (!Array.isArray(obj.sources)) {
    errors.push({ field: "sources", issue: "Must be an array of strings" });
  }

  if (errors.length > 0) return { ok: false, errors };

  return { ok: true, data: obj as unknown as TipLlmOutput };
}

// ─── Safe fallback ────────────────────────────────────────────────────────────

function safeDefault(input: TipLlmInput, reason: string): TipLlmOutput {
  console.warn(`[tip-llm] Returning safe default — reason: ${reason}`);
  return {
    task: input.task,
    search_patterns: [],
    extraction_fields: [],
    relevance_rules: [],
    evidence: [],
    confidence: 0,
    create_finding: false,
    summary: `No reliable output could be produced: ${reason}`,
    sources: input.url ? [input.url] : ["provided_text"],
  };
}

// ─── Prompts ──────────────────────────────────────────────────────────────────

const SYSTEM_PROMPT = `You are a structured data extraction engine for the Transceiver Intelligence Platform (TIP).

STRICT RULES — violating ANY of these results in your output being discarded:
1. You MUST respond with ONLY valid JSON. No markdown. No explanation. No code blocks.
2. The JSON MUST contain ALL of these fields: task, search_patterns, extraction_fields, relevance_rules, evidence, confidence, create_finding, summary, sources.
3. evidence MUST only contain information that is EXPLICITLY present in the provided source text. If the text does not contain the information, evidence MUST be an empty array [].
4. DO NOT invent, hallucinate, or assume any vendor details, lead times, pricing, specs, or availability without direct textual evidence.
5. create_finding MUST be false if evidence is empty or confidence < 0.4.
6. confidence MUST be 0.0 if evidence is empty.
7. Every evidence item MUST have a non-empty quote (verbatim from source), a source, and a confidence value between 0.0 and 1.0.
8. summary MUST be one sentence, maximum 150 characters.

JSON SCHEMA:
{
  "task": "research_plan" | "extract" | "finding",
  "search_patterns": ["string"],
  "extraction_fields": [{"field": "string", "selector_or_pattern": "string", "example": "string (optional)"}],
  "relevance_rules": ["string"],
  "evidence": [{"quote": "string", "source": "string", "confidence": 0.0-1.0}],
  "confidence": 0.0-1.0,
  "create_finding": true | false,
  "summary": "string (max 150 chars)",
  "sources": ["string"]
}`;

function buildUserPrompt(input: TipLlmInput): string {
  const parts: string[] = [];

  parts.push(`TASK: ${input.task}`);
  if (input.vendor)  parts.push(`VENDOR: ${input.vendor}`);
  if (input.product) parts.push(`PRODUCT: ${input.product}`);
  if (input.url)     parts.push(`URL: ${input.url}`);
  if (input.context) parts.push(`CONTEXT: ${input.context}`);

  if (input.text) {
    // Truncate to 4000 chars to stay within tip-llm-v1 context window
    const truncated = input.text.length > 4000
      ? input.text.slice(0, 4000) + "\n[... truncated]"
      : input.text;
    parts.push(`\nSOURCE TEXT:\n${truncated}`);
  } else {
    parts.push("\nSOURCE TEXT: (none provided — generate search plan only)");
  }

  parts.push("\nRespond with ONLY the JSON object. No other text.");
  return parts.join("\n");
}

function buildRepairPrompt(input: TipLlmInput, previousOutput: string, errors: ValidationError[]): string {
  const errorList = errors.map((e) => `- ${e.field}: ${e.issue}`).join("\n");
  return `Your previous response had schema errors. Fix them and return ONLY corrected JSON.

ERRORS:
${errorList}

PREVIOUS RESPONSE (first 500 chars):
${previousOutput.slice(0, 500)}

${buildUserPrompt(input)}`;
}

// ─── Raw Ollama call (bypasses blog LLM routing, hits tip-llm-v1 directly) ────

async function callTipLlm(
  systemPrompt: string,
  userPrompt: string,
  attempt: number,
): Promise<string> {
  // Override model env vars temporarily by calling Ollama API directly
  // This ensures tip-llm-v1 is used, not fo-blog-v7
  const resp = await fetch(`${TIP_OLLAMA_URL}/api/generate`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: TIP_LLM_MODEL,
      prompt: userPrompt,
      system: systemPrompt,
      stream: false,
      options: {
        temperature: attempt === 0 ? 0.1 : 0.05,  // lower temp on retry
        num_predict: 1500,
        stop: ["\n\n\n"],  // prevent runaway output
      },
    }),
    signal: AbortSignal.timeout(TIP_LLM_TIMEOUT),
  });

  if (!resp.ok) {
    const errText = await resp.text();
    throw new Error(`tip-llm-v1 call failed: ${resp.status} ${errText.slice(0, 200)}`);
  }

  const data = await resp.json() as { response: string; model: string; total_duration: number };
  const durationMs = Math.round(data.total_duration / 1_000_000);
  console.log(`[tip-llm] ${data.model} attempt=${attempt} duration=${durationMs}ms chars=${data.response.length}`);
  return data.response;
}

// ─── JSON extraction ──────────────────────────────────────────────────────────

function extractJson(raw: string): unknown {
  // Strip markdown code blocks if model wrapped output despite instructions
  let text = raw.trim();
  const codeBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (codeBlock) text = codeBlock[1].trim();

  // Find the first complete JSON object
  const start = text.indexOf("{");
  const end   = text.lastIndexOf("}");
  if (start === -1 || end === -1) throw new Error("No JSON object found in response");

  return JSON.parse(text.slice(start, end + 1));
}

// ─── Main entry point ─────────────────────────────────────────────────────────

/**
 * Run tip-llm-v1 with strict guided inference.
 * Never throws — returns a safe default on persistent failure.
 */
export async function tipLlmInfer(input: TipLlmInput): Promise<TipLlmOutput> {
  const startTime = Date.now();
  let lastRaw = "";
  let lastErrors: ValidationError[] = [];

  for (let attempt = 0; attempt <= TIP_LLM_MAX_RETRY; attempt++) {
    try {
      const userPrompt = attempt === 0
        ? buildUserPrompt(input)
        : buildRepairPrompt(input, lastRaw, lastErrors);

      lastRaw = await callTipLlm(SYSTEM_PROMPT, userPrompt, attempt);

      let parsed: unknown;
      try {
        parsed = extractJson(lastRaw);
      } catch (parseErr) {
        lastErrors = [{ field: "root", issue: `JSON parse error: ${(parseErr as Error).message}` }];
        console.warn(`[tip-llm] attempt=${attempt} parse failed: ${(parseErr as Error).message}`);
        continue;
      }

      const result = validateOutput(parsed);
      if (result.ok) {
        const totalMs = Date.now() - startTime;
        console.log(`[tip-llm] SUCCESS attempt=${attempt} create_finding=${result.data.create_finding} evidence=${result.data.evidence.length} total=${totalMs}ms`);
        return result.data;
      }

      lastErrors = result.errors;
      console.warn(`[tip-llm] attempt=${attempt} validation failed (${result.errors.length} errors):`, result.errors);

    } catch (err) {
      const msg = (err as Error).message;
      console.error(`[tip-llm] attempt=${attempt} call error: ${msg}`);
      lastErrors = [{ field: "root", issue: msg }];
    }
  }

  // All retries exhausted — return safe default
  const errorSummary = lastErrors.map((e) => `${e.field}: ${e.issue}`).slice(0, 3).join("; ");
  return safeDefault(input, `schema validation failed after ${TIP_LLM_MAX_RETRY + 1} attempts: ${errorSummary}`);
}

// ─── Convenience wrappers ─────────────────────────────────────────────────────

/** Generate a search/crawl plan for a vendor+product without any source text */
export async function tipLlmResearchPlan(
  vendor: string,
  product: string,
  context?: string,
): Promise<TipLlmOutput> {
  return tipLlmInfer({ task: "research_plan", vendor, product, context });
}

/** Extract structured facts from scraped HTML/text */
export async function tipLlmExtract(
  text: string,
  url: string,
  vendor?: string,
  product?: string,
): Promise<TipLlmOutput> {
  return tipLlmInfer({ task: "extract", text, url, vendor, product });
}

/** Produce a finding from pre-gathered evidence text */
export async function tipLlmFinding(
  text: string,
  vendor: string,
  product: string,
  url?: string,
): Promise<TipLlmOutput> {
  return tipLlmInfer({ task: "finding", text, url, vendor, product });
}

/** Health check — verifies tip-llm-v1 is loaded in Ollama */
export async function checkTipLlmHealth(): Promise<{
  ok: boolean;
  model: string;
  error?: string;
}> {
  try {
    const resp = await fetch(`${TIP_OLLAMA_URL}/api/tags`, {
      signal: AbortSignal.timeout(5000),
    });
    if (!resp.ok) {
      return { ok: false, model: TIP_LLM_MODEL, error: `Ollama HTTP ${resp.status}` };
    }
    const data = await resp.json() as { models: Array<{ name: string }> };
    const hasModel = data.models.some((m) => m.name.startsWith(TIP_LLM_MODEL.split(":")[0]));
    return {
      ok: hasModel,
      model: TIP_LLM_MODEL,
      error: hasModel ? undefined : `Model "${TIP_LLM_MODEL}" not found in Ollama`,
    };
  } catch (err) {
    return { ok: false, model: TIP_LLM_MODEL, error: (err as Error).message };
  }
}
