import { BadRequestException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ReportBuilderService } from './report-builder.service';

/**
 * AI drafting for the audit report. The auditor stays in control:
 *  - it only DRAFTS text into editable fields (the auditor reviews / edits before submitting)
 *  - it never decides C / NC / OFI: rows the auditor marked NC or OFI are skipped (a real finding must be written by the auditor)
 *  - it is told not to invent record numbers, dates or names
 *
 * Providers (set in the backend .env; several can be listed, they are tried in order):
 *   ollama     your own server:   AUDIT_REPORT_AI_PROVIDER=ollama,gemini
 *                                  AUDIT_REPORT_OLLAMA_MODEL=gemma4:12b   (OLLAMA_URL default http://127.0.0.1:11434)
 *   gemini     free tier:         GEMINI_API_KEY=...   (AUDIT_REPORT_GEMINI_MODEL, default gemini-2.5-flash)
 *   openai     Groq / OpenRouter / Mistral:  AUDIT_REPORT_AI_BASE_URL, AUDIT_REPORT_AI_API_KEY, AUDIT_REPORT_AI_MODEL
 *   anthropic  paid:              ANTHROPIC_API_KEY=...
 * Tuning (optional): AUDIT_REPORT_AI_CONCURRENCY, AUDIT_REPORT_AI_BATCH, AUDIT_REPORT_AI_TIMEOUT_MS,
 *                    AUDIT_REPORT_OLLAMA_NUM_CTX (default 8192), AUDIT_REPORT_OLLAMA_KEEP_ALIVE (default 10m).
 */
@Injectable()
export class ReportAiService {
  private readonly logger = new Logger(ReportAiService.name);

  constructor(private readonly builder: ReportBuilderService) {}

  // ───────────────────────── provider selection ─────────────────────────
  /**
   * Providers are tried in this order; the next one is used when one fails (server down, free limit reached...).
   *   AUDIT_REPORT_AI_PROVIDER=ollama,gemini        <- your own GPU first, Gemini as backup
   * If the variable is not set, it is chosen from the keys that are present.
   */
  private providers(): ('ollama' | 'gemini' | 'openai' | 'anthropic')[] {
    const valid = ['ollama', 'gemini', 'openai', 'anthropic'];
    const forced = (process.env.AUDIT_REPORT_AI_PROVIDER || '')
      .toLowerCase().split(',').map((x) => x.trim()).filter((x) => valid.includes(x)) as any[];
    if (forced.length) return forced;
    const auto: any[] = [];
    if (process.env.OLLAMA_URL || process.env.AUDIT_REPORT_OLLAMA_MODEL) auto.push('ollama');
    if (process.env.GEMINI_API_KEY) auto.push('gemini');
    if (process.env.AUDIT_REPORT_AI_API_KEY && process.env.AUDIT_REPORT_AI_BASE_URL) auto.push('openai');
    if (process.env.ANTHROPIC_API_KEY) auto.push('anthropic');
    if (!auto.length) {
      throw new ServiceUnavailableException(
        'AI is not configured. Set AUDIT_REPORT_AI_PROVIDER=ollama (your own server) or add GEMINI_API_KEY in the backend .env and restart.',
      );
    }
    return auto;
  }

  /** A local GPU handles one request at a time best; cloud providers can take two in parallel. */
  private concurrency(): number {
    const env = Number(process.env.AUDIT_REPORT_AI_CONCURRENCY);
    if (env > 0) return env;
    return this.providers()[0] === 'ollama' ? 1 : 2;
  }

  private batchSize(): number {
    const env = Number(process.env.AUDIT_REPORT_AI_BATCH);
    if (env > 0) return env;
    return this.providers()[0] === 'ollama' ? 8 : 12;
  }

  private sleep(ms: number) {
    return new Promise((r) => setTimeout(r, ms));
  }

  /** One request with automatic retry when a free tier says "too many requests" (429) or the server is busy (503). */
  private async post(url: string, init: RequestInit, label: string): Promise<any> {
    const timeout = Number(process.env.AUDIT_REPORT_AI_TIMEOUT_MS) || 600000;
    for (let attempt = 0; attempt < 4; attempt++) {
      let res: Response;
      try {
        res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
      } catch (e: any) {
        this.logger.error(`[REPORT-AI] ${label} unreachable: ${e?.message}`);
        throw new ServiceUnavailableException(`AI server (${label}) is not reachable or too slow. Check that it is running.`);
      }
      if (res.ok) return res.json();
      if ((res.status === 429 || res.status === 503) && attempt < 3) {
        const wait = Number(res.headers.get('retry-after')) * 1000 || 8000 * (attempt + 1);
        this.logger.warn(`[REPORT-AI] ${label} ${res.status}, retrying in ${Math.round(wait / 1000)}s`);
        await this.sleep(wait);
        continue;
      }
      const t = await res.text().catch(() => '');
      this.logger.error(`[REPORT-AI] ${label} ${res.status}: ${t.slice(0, 300)}`);
      const hint =
        res.status === 429 ? 'The free limit was reached. Wait a minute and try again, or draft fewer rows at a time.'
        : res.status === 401 || res.status === 403 ? 'The API key was rejected. Check the key in .env.'
        : res.status === 404 ? 'The model name was not found. Check the model name in .env (for Ollama: run "ollama list").'
        : 'Check the key, the model name and the account balance.';
      const err: any = new ServiceUnavailableException(`AI request failed (${label} ${res.status}). ${hint}`);
      err.httpStatus = res.status;
      throw err;
    }
    throw new ServiceUnavailableException('AI request failed.');
  }

  // ───────────────────────── model call ─────────────────────────
  /** Tries each configured provider in order; returns the first answer. */
  private async ask(system: string, user: string, maxTokens = 4000): Promise<string> {
    const list = this.providers();
    let last: any;
    for (const provider of list) {
      try {
        return await this.askOne(provider, system, user, maxTokens);
      } catch (e: any) {
        last = e;
        if (!(e instanceof ServiceUnavailableException)) throw e;
        this.logger.warn(`[REPORT-AI] ${provider} failed (${e.message})${list.length > 1 ? ', trying next provider' : ''}`);
      }
    }
    throw last;
  }

  private async askOne(provider: 'ollama' | 'gemini' | 'openai' | 'anthropic', system: string, user: string, maxTokens: number): Promise<string> {
    if (provider === 'ollama') {
      const base = (process.env.OLLAMA_URL || 'http://127.0.0.1:11434').replace(/\/$/, '');
      const model = process.env.AUDIT_REPORT_OLLAMA_MODEL || process.env.AUDIT_REPORT_AI_MODEL;
      if (!model) throw new ServiceUnavailableException('Set AUDIT_REPORT_OLLAMA_MODEL in the backend .env (see "ollama list").');
      const body = (withThink: boolean) => JSON.stringify({
        model,
        stream: false,
        format: 'json',                       // the model must answer with valid JSON
        ...(withThink ? { think: false } : {}), // no long "thinking" text, much faster
        keep_alive: process.env.AUDIT_REPORT_OLLAMA_KEEP_ALIVE || '10m',
        messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
        options: { temperature: 0.4, num_ctx: Number(process.env.AUDIT_REPORT_OLLAMA_NUM_CTX) || 8192, num_predict: maxTokens },
      });
      const headers = { 'content-type': 'application/json' };
      let j: any;
      try {
        j = await this.post(`${base}/api/chat`, { method: 'POST', headers, body: body(true) }, 'ollama');
      } catch (e: any) {
        if (e?.httpStatus !== 400) throw e;
        j = await this.post(`${base}/api/chat`, { method: 'POST', headers, body: body(false) }, 'ollama');
      }
      return j.message?.content ?? '';
    }

    if (provider === 'gemini') {
      const key = process.env.GEMINI_API_KEY;
      if (!key) throw new ServiceUnavailableException('GEMINI_API_KEY is missing in the backend .env.');
      const model = process.env.AUDIT_REPORT_GEMINI_MODEL || (/^gemini/.test(process.env.AUDIT_REPORT_AI_MODEL || '') ? process.env.AUDIT_REPORT_AI_MODEL! : 'gemini-2.5-flash');
      const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
      const body = (withThinking: boolean) => JSON.stringify({
        systemInstruction: { parts: [{ text: system }] },
        contents: [{ role: 'user', parts: [{ text: user }] }],
        generationConfig: {
          maxOutputTokens: maxTokens,
          temperature: 0.4,
          responseMimeType: 'application/json',
          ...(withThinking ? { thinkingConfig: { thinkingBudget: 0 } } : {}),
        },
      });
      const headers = { 'content-type': 'application/json', 'x-goog-api-key': key };
      let j: any;
      try {
        j = await this.post(url, { method: 'POST', headers, body: body(true) }, 'gemini');
      } catch (e: any) {
        if (e?.httpStatus !== 400) throw e;
        j = await this.post(url, { method: 'POST', headers, body: body(false) }, 'gemini');
      }
      return (j.candidates?.[0]?.content?.parts ?? []).map((x: any) => x.text ?? '').join('\n');
    }

    if (provider === 'openai') {
      const base = (process.env.AUDIT_REPORT_AI_BASE_URL || '').replace(/\/$/, '');
      const key = process.env.AUDIT_REPORT_AI_API_KEY || '';
      const model = process.env.AUDIT_REPORT_AI_MODEL;
      if (!base || !model) throw new ServiceUnavailableException('Set AUDIT_REPORT_AI_BASE_URL and AUDIT_REPORT_AI_MODEL in the backend .env.');
      const j = await this.post(`${base}/chat/completions`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
        body: JSON.stringify({
          model, max_tokens: maxTokens, temperature: 0.4,
          messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
        }),
      }, 'openai-compatible');
      return j.choices?.[0]?.message?.content ?? '';
    }

    // anthropic (paid)
    const key = process.env.ANTHROPIC_API_KEY;
    if (!key) throw new ServiceUnavailableException('ANTHROPIC_API_KEY is missing in the backend .env.');
    const j = await this.post('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
      body: JSON.stringify({
        model: process.env.AUDIT_REPORT_AI_MODEL || 'claude-sonnet-4-6',
        max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }],
      }),
    }, 'anthropic');
    return (j.content ?? []).filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n');
  }

  private parseJson<T = any>(text: string): T {
    const clean = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/```json|```/g, '').trim();
    const start = clean.indexOf('{');
    const end = clean.lastIndexOf('}');
    if (start < 0 || end < 0) throw new Error('AI did not return JSON');
    return JSON.parse(clean.slice(start, end + 1));
  }

  private async pool<T, R>(items: T[], size: number, fn: (x: T) => Promise<R>): Promise<R[]> {
    const out: R[] = [];
    let i = 0;
    const workers = Array.from({ length: Math.min(size, items.length) }, async () => {
      while (i < items.length) {
        const idx = i++;
        out[idx] = await fn(items[idx]);
      }
    });
    await Promise.all(workers);
    return out;
  }

  private context(model: any) {
    const f = model.fields ?? {};
    return {
      organization: f.name,
      scope: f.scope,
      employees: f.employees,
      address: f.address,
      standard: model.report.standard_label || f.standard_name || 'ISO 9001:2015',
      stage: model.report.stage_label,
      audit_date: f.audit_date,
      lead_auditor: f.lead_auditor,
    };
  }

  // ───────────────────────── objective evidence per clause ─────────────────────────
  async generateEvidence(
    reportId: number,
    userId: number,
    opts: { clause_ids?: number[]; overwrite?: boolean; mark_conforming?: boolean } = {},
  ) {
    const model: any = await this.builder.getEditorModel(reportId, userId);
    if (!model.permissions.can_edit) throw new BadRequestException('This report can no longer be edited.');

    const wanted = new Set((opts.clause_ids ?? []).map(Number));
    const rows: any[] = model.clauses.filter((c: any) => {
      if (wanted.size && !wanted.has(c.id)) return false;
      if (c.status === 'NC' || c.status === 'OFI') return false; // the auditor writes real findings
      if (!opts.overwrite && String(c.evidence_text ?? '').trim()) return false;
      return true;
    });
    const skippedFindings = model.clauses.filter((c: any) => (c.status === 'NC' || c.status === 'OFI') && !String(c.evidence_text ?? '').trim()).length;
    if (!rows.length) return { generated: 0, skipped_findings: skippedFindings, items: [] };

    const docNames: string[] = (model.client_documents ?? []).map((d: any) => d.file_name);
    const ctx = this.context(model);

    const system = [
      'You are a lead auditor of a third-party certification body writing the "Objective Evidence / Audit Comments" column of an ISO audit report.',
      'Style: formal, past tense, third person ("During the audit, the organization demonstrated...", "It was verified that..."). 2 to 4 sentences, one paragraph, specific to the clause requirement and to the organization\'s scope.',
      'STRICT RULES:',
      '- Describe conforming evidence only. Never raise or imply a non-conformity.',
      '- Do NOT invent document numbers, record IDs, dates, names of people, or measurements. Refer to records generically (for example "the documented information", "training records", "the internal audit programme").',
      '- You may list documents ONLY from the provided "available_documents" list, and only if clearly relevant to that clause; otherwise return an empty list.',
      '- Output JSON only, no commentary: {"items":[{"id":<number>,"evidence":"<text>","docs":["<file name from available_documents>"]}]}',
    ].join('\n');

    const batches: any[][] = [];
    const size = this.batchSize();
    for (let i = 0; i < rows.length; i += size) batches.push(rows.slice(i, i + size));

    const results = await this.pool(batches, this.concurrency(), async (batch) => {
      const payload = {
        organization: ctx,
        available_documents: docNames,
        clauses: batch.map((c) => ({ id: c.id, clause: c.code, heading: c.heading, requirement: c.requirement || c.title })),
      };
      try {
        const raw = await this.ask(system, JSON.stringify(payload), 3500);
        const parsed = this.parseJson<{ items: { id: number; evidence: string; docs?: string[] }[] }>(raw);
        return parsed.items ?? [];
      } catch (e: any) {
        if (e instanceof ServiceUnavailableException) throw e;
        this.logger.warn(`[REPORT-AI] batch failed: ${e.message}`);
        return [];
      }
    });

    const byId = new Map<number, { evidence: string; docs: string[] }>();
    results.flat().forEach((it) => {
      if (it && it.id && String(it.evidence ?? '').trim()) {
        byId.set(Number(it.id), {
          evidence: String(it.evidence).trim(),
          docs: Array.isArray(it.docs) ? it.docs.filter((d) => docNames.includes(d)) : [],
        });
      }
    });

    const markC = opts.mark_conforming !== false;
    const save = rows
      .filter((c) => byId.has(c.id))
      .map((c) => {
        const g = byId.get(c.id)!;
        const item: any = { id: c.id, evidence_text: g.evidence };
        if (g.docs.length && !(Array.isArray(c.docs_reviewed) && c.docs_reviewed.length)) item.docs_reviewed = g.docs;
        if (markC && !c.status) item.status = 'C';
        return item;
      });
    if (save.length) await this.builder.bulkClauses(reportId, { items: save } as any, userId);

    return { generated: save.length, requested: rows.length, skipped_findings: skippedFindings, items: save };
  }

  // ───────────────────────── finding wording (NC / observation) ─────────────────────────
  /** The auditor decides there is a finding; the AI only turns their short note into a formal statement. */
  async generateFinding(reportId: number, userId: number, body: { clause_id: number; note?: string; nc_type?: string }) {
    const model: any = await this.builder.getEditorModel(reportId, userId);
    if (!model.permissions.can_edit) throw new BadRequestException('This report can no longer be edited.');
    const c = model.clauses.find((x: any) => x.id === Number(body.clause_id));
    if (!c) throw new BadRequestException('Clause row not found.');
    const note = String(body.note ?? c.evidence_text ?? '').trim();
    if (!note) throw new BadRequestException('Write a short note about what you found first.');

    const system = [
      'You are a lead auditor of a certification body writing the statement of a finding in an ISO audit report.',
      'Style: formal, past tense, third person, 1 to 3 sentences, starting like "During the audit, it was noted that ...".',
      'Use ONLY the facts in the auditor\'s note. Do not add records, numbers, dates or names. Do not change the severity.',
      'Mention the requirement that was not met only in general terms (from the clause text).',
      'Output JSON only: {"statement":"<text>"}',
    ].join('\n');
    const raw = await this.ask(system, JSON.stringify({
      organization: this.context(model),
      clause: c.code, heading: c.heading, requirement: c.requirement || c.title,
      finding_type: body.nc_type || 'Minor', auditor_note: note,
    }), 1200);
    let statement = '';
    try { statement = String(this.parseJson<{ statement: string }>(raw).statement ?? '').trim(); } catch { /* ignore */ }
    if (!statement) throw new BadRequestException('AI returned an unreadable answer. Try again.');
    return { statement };
  }

  // ───────────────────────── narrative sections ─────────────────────────
  async generateSections(reportId: number, userId: number, opts: { keys?: string[]; overwrite?: boolean } = {}) {
    const model: any = await this.builder.getEditorModel(reportId, userId);
    if (!model.permissions.can_edit) throw new BadRequestException('This report can no longer be edited.');

    const AI_KEYS = ['org_brief', 'audit_brief', 'objective', 'criteria', 'effectiveness', 'improvement_comments'];
    const available = new Map<string, any>(model.sections.map((s: any) => [s.key, s]));
    const keys = (opts.keys?.length ? opts.keys : AI_KEYS).filter((k) => available.has(k) && AI_KEYS.includes(k));
    const todo = keys.filter((k) => opts.overwrite || !String(available.get(k).body ?? '').trim());
    if (!todo.length) return { generated: 0, items: [] };

    const counts = { C: 0, NC: 0, OFI: 0, NA: 0 } as Record<string, number>;
    model.clauses.forEach((c: any) => { if (c.status) counts[c.status] = (counts[c.status] ?? 0) + 1; });
    const ncs = (model.ncs ?? []).map((n: any) => ({ type: n.nc_type, clause: n.criteria_clause, statement: n.ncr_statement }));

    const guide: Record<string, string> = {
      org_brief: '2 short paragraphs: what the organization does (based on the scope), its size/location if known, and its management-system context. Do not invent facts beyond the scope and details given.',
      audit_brief: '2 short paragraphs: how the audit was conducted (document review, interviews, observation of processes, sampling of records) and that it covered the scope against the standard.',
      objective: '1 paragraph: the audit objective (verify implementation, maintenance and effectiveness of the management system against the standard; statutory and customer requirements; continual improvement).',
      criteria: '1 paragraph listing the audit criteria: the standard, the organization\'s documented management system, and applicable statutory/regulatory and customer requirements.',
      effectiveness: '2 short paragraphs: overall conclusion on the effectiveness of the management system, consistent with the finding counts and any NCs given (if there are none, say none were raised).',
      improvement_comments: '1 short paragraph: audit team comments on continual improvement, consistent with the findings.',
    };

    const system = [
      'You are a lead auditor of a third-party certification body writing narrative sections of an ISO audit report.',
      'Formal, past tense, third person, plain text. No headings, no bullet points, no markdown. Separate paragraphs with a blank line.',
      'Do NOT invent names, dates, document numbers, locations or numbers that are not in the provided data.',
      'Output JSON only: {"sections":{"<key>":"<text>"}} with exactly the requested keys.',
    ].join('\n');

    const payload = {
      organization: this.context(model),
      finding_counts: counts,
      non_conformities: ncs,
      requested: todo.map((k) => ({ key: k, what_to_write: guide[k] })),
    };
    const raw = await this.ask(system, JSON.stringify(payload), 3500);
    let parsed: { sections?: Record<string, string> };
    try { parsed = this.parseJson(raw); } catch { throw new BadRequestException('AI returned an unreadable answer. Try again.'); }

    const items: { key: string; body: string }[] = [];
    for (const k of todo) {
      const body = String(parsed.sections?.[k] ?? '').trim();
      if (!body) continue;
      await this.builder.upsertSection(reportId, k, body, userId);
      items.push({ key: k, body });
    }
    return { generated: items.length, items };
  }
}
