import * as childProcess from 'child_process';
import { mkdtemp, writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { getLLMConfig } from './config.js';
import { recordUsage } from './usage.js';
import { line } from './log.js';

// 每個 LLM CLI 呼叫（角色分析、補行號等）都是一個獨立子行程。預設「不限制」併發（全部同時跑）；
// 若機器資源不足或撞到提供者限流，可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
// 0 / 未設定 / 非正整數 → 不限制。
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;

/**
 * 對 items 並行執行 async fn（保序回傳），加速多個獨立的 LLM 子行程呼叫。
 *
 * limit 為同時執行上限；`limit <= 0`、非數字或大於項目數時「不限制」（全部並行）。
 * fn 需自行處理例外（內部 try/catch）；本函式不會因單一項目 reject 而中斷其餘工作。
 * @template T, R
 * @param {T[]} items - 要處理的項目。
 * @param {number} limit - 同時執行的上限；<=0／非數字表示不限制。
 * @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
 * @returns {Promise<R[]>} 與 items 對應（同索引）的結果陣列。
 */
export async function mapWithConcurrency(items, limit, fn) {
  const list = Array.isArray(items) ? items : [];
  const results = new Array(list.length);
  if (list.length === 0) return results;
  const n = Number(limit);
  const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
  let cursor = 0;
  async function run() {
    while (cursor < list.length) {
      const i = cursor++;
      results[i] = await fn(list[i], i);
    }
  }
  await Promise.all(Array.from({ length: workers }, run));
  return results;
}

/**
 * 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
 */
function buildPrompt(systemPrompt, userContent) {
  return [
    '請依照以下系統指示處理使用者內容，並只輸出要求的最終結果。',
    '',
    '<system>',
    systemPrompt,
    '</system>',
    '',
    '<user>',
    userContent,
    '</user>',
  ].join('\n');
}

/**
 * 依不同 AI provider 產生 CLI 參數。
 *
 * @param {*} provider - AI provider 名稱。
 * @param {*} model - 模型名稱。
 * @param {*} promptFile - prompt 檔路徑，供 `opencode` 使用。
 * @param {*} prompt - 直接傳給 CLI 的 prompt 文字，供部分 provider 使用。
 * @remarks 適合把不同 CLI 的參數差異集中管理。
 * @remarks 目前支援的 provider 名稱是硬編碼的，新增 provider 時需人工確認是否同步更新所有呼叫端。
 */
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
  if (provider === 'codex') {
    return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
  }
  if (provider === 'claude') {
    return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
  }
  if (provider === 'antigravity') {
    return ['-p', prompt, '--model', model];
  }
  if (provider === 'opencode') {
    return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行，並只輸出要求的最終結果。'];
  }
  throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
}

/**
 * 從 CLI 輸出中抽出「真正有意義的錯誤」。
 *
 * 像 codex 這類 CLI 會先印出一大段 banner（workdir/model/...）與回顯的 prompt，
 * 真正的失敗原因（例如 401、token 失效、額度不足）通常落在**尾端**。直接取前段
 * 會被 banner／prompt 洗掉，因此改為：先抽出看起來像錯誤的行；抽不到再退取尾段。
 *
 * @param {string} raw - CLI 的原始輸出（stderr 或 stdout）。
 * @param {number} [limit=1000] - 回傳字串長度上限。
 * @returns {string} 最能說明失敗原因的片段。
 */
export function extractMeaningfulError(raw, limit = 1000) {
  const text = String(raw || '').trim();
  const errorLines = text
    .split('\n')
    .filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|rate.?limit|quota|insufficient/i.test(l));
  const picked = (errorLines.length ? errorLines.join('\n') : text).trim();
  return picked.length > limit ? picked.slice(-limit) : picked;
}

/**
 * 將 CLI 例外整理成較精簡的錯誤摘要。
 *
 * @param {*} e - 被拋出的錯誤物件，可能含 `stderr`、`stdout`、`message`。
 * @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
 * @remarks 若錯誤物件結構和預期不同，仍會退回字串化處理，屬保守容錯。
 */
function summarizeCliError(e) {
  const stderr = String(e.stderr || '').trim();
  const stdout = String(e.stdout || '').trim();
  return extractMeaningfulError(stderr || stdout || e.message || String(e));
}

/**
 * 執行 AI 助理 CLI 並回傳純文字結果。
 *
 * @param {*} provider - CLI provider 名稱。
 * @param {*} command - 實際可執行指令。
 * @param {*} model - 要使用的模型名稱。
 * @param {*} prompt - 送給 CLI 的完整 prompt 內容。
 * @remarks 適合用在需呼叫外部 AI CLI 的情境。
 * @remarks 逾時與輸出上限由環境變數控制，預設值是保守設定。
 * @remarks 若子行程回傳非 0，錯誤訊息會由上層摘要處理。
 */
async function runAssistantCLI({ provider, command, model }, prompt) {
  let tempDir = null;
  let promptFile = null;
  if (provider === 'opencode') {
    tempDir = await mkdtemp(join(tmpdir(), 'ai-review-prompt-'));
    promptFile = join(tempDir, 'prompt.md');
    await writeFile(promptFile, prompt);
  }
  const args = cliArgs({ provider, model, promptFile, prompt });
  const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
  const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
  try {
    return await new Promise((resolve, reject) => {
      const child = childProcess.spawn(command, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
      let stdout = '';
      let stderr = '';
      let settled = false;
      const timer = setTimeout(() => {
        settled = true;
        child.kill('SIGTERM');
        reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
      }, timeout);
      const append = (kind, chunk) => {
        if (kind === 'stdout') stdout += chunk;
        else stderr += chunk;
        if (stdout.length + stderr.length > maxBuffer) {
          settled = true;
          child.kill('SIGTERM');
          reject(new Error(`${provider} CLI 輸出超過 ${maxBuffer} bytes`));
        }
      };

      child.stdout.setEncoding('utf8');
      child.stderr.setEncoding('utf8');
      child.stdout.on('data', chunk => append('stdout', chunk));
      child.stderr.on('data', chunk => append('stderr', chunk));
      child.on('error', reject);
      child.on('close', (code, signal) => {
        clearTimeout(timer);
        if (settled) return;
        if (code === 0) resolve(stdout.trim());
        else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
      });
      child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt);
    });
  } finally {
    if (tempDir) await rm(tempDir, { recursive: true, force: true });
  }
}

/**
 * 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。
 *
 * 從設定取得 provider/command/model；未偵測到 CLI 時拋錯。成功時記錄一次
 * usage 呼叫（CLI 通常不回傳 token 明細，因此 token 可能為 0）並回傳內容。
 *
 * @param {string} systemPrompt - 系統提示詞。
 * @param {string} userContent - 使用者輸入內容。
 * @returns {Promise<string>} 模型回應的純文字內容。
 * @throws {Error} 當未偵測到可用 AI 助理 CLI，或 CLI 呼叫失敗時。
 */
export async function chat(systemPrompt, userContent) {
  const cfg = getLLMConfig();
  const { provider, command, model } = cfg;
  if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI，請安裝 codex、claude、antigravity 或 opencode');

  line(`[LLM] provider=${provider} command=${command} model=${model}`);

  try {
    const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent));
    recordUsage(null);
    return content;
  } catch (e) {
    const message = summarizeCliError(e);
    line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`);
    throw new Error(message);
  }
}

/**
 * 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。
 *
 * 先取得文字回應，經 {@link extractJSONText} 抽出 JSON 片段後解析。
 * 解析失敗時記錄錯誤並回傳空陣列，不向外拋錯（容錯設計）。
 *
 * @param {string} systemPrompt - 系統提示詞。
 * @param {string} userContent - 使用者輸入內容。
 * @returns {Promise<any>} 解析後的 JSON 值；解析失敗時回傳空陣列 `[]`。
 */
export async function chatJSON(systemPrompt, userContent) {
  const text = await chat(systemPrompt, userContent);
  try {
    return JSON.parse(extractJSONText(text));
  } catch (e) {
    line(`[LLM] JSON 解析失敗: ${e.message}`);
    return [];
  }
}

/**
 * 去除文字外層的 Markdown code fence（```），用於清理被 code block 包裹的輸出。
 *
 * 會 trim、移除開頭 fence（含可選語言標籤與換行）與結尾 fence，再 trim。
 * 對非字串輸入會先以 `String()` 轉換；無 fence 時回傳 trim 後原文。
 *
 * @param {*} text - 待清理的內容（會被轉為字串）。
 * @returns {string} 去除外層 fence 並 trim 後的字串。
 */
function stripOuterFence(text) {
  return String(text)
    .trim()
    .replace(/^```[a-zA-Z0-9_-]*\n?/, '')
    .replace(/```$/, '')
    .trim();
}

/**
 * 從指定索引起，以括號平衡方式擷取一段完整配對的 JSON 子字串。
 *
 * 依起始字元判定為物件（`{}`）或陣列（`[]`），逐字元計數巢狀深度，
 * 並正確略過字串字面值與其中的跳脫字元，深度歸零時回傳完整片段。
 *
 * @param {*} text - 來源內容（會被轉為字串）。
 * @param {number} startIndex - 起始掃描索引，應指向 `{` 或 `[`。
 * @returns {string|null} 配對完整的 JSON 子字串；找不到配對時回傳 `null`。
 */
export function extractBalancedJSON(text, startIndex) {
  const source = String(text);
  const open = source[startIndex];
  const close = open === '{' ? '}' : ']';
  let depth = 0;
  let inString = false;
  let escaped = false;

  for (let i = startIndex; i < source.length; i++) {
    const ch = source[i];
    if (inString) {
      if (escaped) {
        escaped = false;
      } else if (ch === '\\') {
        escaped = true;
      } else if (ch === '"') {
        inString = false;
      }
      continue;
    }
    if (ch === '"') {
      inString = true;
      continue;
    }
    if (ch === open) depth += 1;
    else if (ch === close) {
      depth -= 1;
      if (depth === 0) return source.slice(startIndex, i + 1);
    }
  }
  return null;
}

/**
 * 從可能夾雜雜訊或被 code fence 包裹的文字中，盡力抽出可被 JSON.parse 解析的片段。
 *
 * 先去除外層 fence；若整段即為合法 JSON 直接回傳；否則由左至右尋找每個
 * `{`/`[` 起點，以括號平衡擷取候選片段並試解析，回傳第一個成功者；
 * 全數失敗則回傳去 fence 後的原文（仍可能非合法 JSON，交由呼叫端再處理）。
 *
 * @param {*} text - 可能含有 JSON 的原始內容（會被轉為字串）。
 * @returns {string} 最可能為合法 JSON 的字串片段，或去 fence 後的原文。
 */
export function extractJSONText(text) {
  const stripped = stripOuterFence(text);
  try {
    JSON.parse(stripped);
    return stripped;
  } catch {}

  for (let i = 0; i < stripped.length; i++) {
    if (stripped[i] !== '[' && stripped[i] !== '{') continue;
    const candidate = extractBalancedJSON(stripped, i);
    if (!candidate) continue;
    try {
      JSON.parse(candidate);
      return candidate;
    } catch {}
  }
  return stripped;
}
