/**
 * Agent loop — runs one turn of the chat: stream the LLM, execute any
 * tool calls (via tool-dispatcher), feed results back, repeat until
 * the model returns text without tool calls (end_turn) or we hit the
 * iteration cap.
 *
 * Ported from the extension's runTurn. Differences vs the extension:
 *   - Persistence: messages persist to lib/chat/session-store (sqlite)
 *   - Tools: browser tools dispatch via lib/chat/tool-dispatcher → bridge
 *   - Memory: Temper integration deferred to Phase 2b
 *   - Narrowing / destructive confirm: deferred — agent is simpler v1
 */

import { streamLlm, type LlmTool } from './llm';
import { loadSettings } from '../settings';
import {
  appendMessage,
  getSession,
  listMessages,
} from './session-store';
import {
  dispatchTool,
  BUILTIN_TOOL_DEFS,
  type BuiltinHandler,
} from './tool-dispatcher';
import { renderMemoryContext } from './temper';
import { getMemoryStore } from './memory-store';
import { buildMemoryTools } from './memory-tools';
import {
  listInstalledConnectors,
  getConnector,
  getConnectorEntries,
} from '../connectors/registry';
import type {
  ContentBlock,
  Message,
  TextBlock,
  ToolResultBlock,
  ToolUseBlock,
} from './types';

const MAX_ITERATIONS = 6;
const MAX_TOKENS = 16000;

export interface AgentEvent {
  type:
    | 'text_delta'      // assistant text streaming
    | 'tool_use'        // model called a tool
    | 'tool_result'     // tool returned
    | 'message_saved'   // a full message persisted (assistant or tool-results carrier)
    | 'memory_status'   // pinned/blocks/hits snapshot from Temper for the UI strip
    | 'turn_done'       // loop finished
    | 'error';          // unrecoverable
  message_id?: string;
  data?: any;
}

export type AgentCallbacks = {
  onEvent: (event: AgentEvent) => void;
};

interface ProviderResolution {
  name: string;
  type: 'anthropic' | 'openai';
  apiKey: string;
  baseUrl: string;
  model: string;
}

/**
 * Map the user-facing provider string to a chat adapter protocol.
 * Anthropic's API is the only first-class non-OpenAI shape we ship; LiteLLM /
 * Azure / OpenAI proxies / Grok / Google-via-LiteLLM all speak OpenAI.
 */
function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
  const p = (provider || '').toLowerCase();
  if (p === 'anthropic' || p === 'claude') return 'anthropic';
  return 'openai';
}

/**
 * Provider → default public API URL. Saves the user from having to know
 * the right host for first-party providers; only LiteLLM (which is by
 * definition a self-hosted proxy) requires an explicit URL.
 *
 * Resolution priority: profile.baseUrl > env.*_BASE_URL > provider default.
 */
export function defaultBaseUrl(provider: string | undefined): string {
  switch ((provider || '').toLowerCase()) {
    case 'anthropic':
    case 'claude':
      return 'https://api.anthropic.com';
    case 'openai':
      return 'https://api.openai.com/v1';
    case 'grok':
    case 'xai':
      return 'https://api.x.ai/v1';
    case 'google':
    case 'gemini':
      // Google's Gemini exposes an OpenAI-compatible shim at this path.
      return 'https://generativelanguage.googleapis.com/v1beta/openai';
    case 'deepseek':
      return 'https://api.deepseek.com';
    default:
      return '';
  }
}

function pickBaseUrl(
  profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
  adapter: 'anthropic' | 'openai',
): string {
  if (profile.baseUrl) return profile.baseUrl;
  const env = profile.env || {};
  const envOverride = adapter === 'anthropic'
    ? env.ANTHROPIC_BASE_URL
    : (env.OPENAI_BASE_URL || env.OPENAI_API_BASE);
  if (envOverride) return envOverride;
  return defaultBaseUrl(profile.provider);
}

function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
  if (profile.apiKey) return profile.apiKey;
  const env = profile.env || {};
  if (adapter === 'anthropic') return env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || '';
  return env.OPENAI_API_KEY || '';
}

function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
  const settings = loadSettings();
  const agents = settings.agents || {};

  // Candidate API profiles: type=='api', enabled, with a usable apiKey (direct or via env).
  const candidates = Object.entries(agents).filter(([_, a]) => {
    if (!a || a.type !== 'api' || a.enabled === false) return false;
    const adapter = inferAdapter(a.provider);
    return pickApiKey(a, adapter).length > 0;
  });
  if (candidates.length === 0) {
    return { error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API) and pick it as the chat agent.' };
  }

  // Preferred: session.provider (an agent profile id), then settings.chatAgent, else first candidate.
  const preferredId = sessionProvider || settings.chatAgent || candidates[0]![0];
  const profile = agents[preferredId] && agents[preferredId].type === 'api'
    ? agents[preferredId]
    : candidates[0]![1];
  const name = agents[preferredId]?.type === 'api' ? preferredId : candidates[0]![0];

  const adapter = inferAdapter(profile.provider);
  const apiKey = pickApiKey(profile, adapter);
  if (!apiKey) return { error: `Agent profile "${name}" has no apiKey (direct or via env).` };

  const model = sessionModel || profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');

  return {
    name,
    type: adapter,
    apiKey,
    baseUrl: pickBaseUrl(profile, adapter),
    model,
  };
}

function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
  const now = new Date().toISOString();
  const lines: string[] = [
    "You are Forge, the user's personal AI assistant.",
    '',
    `Current time: ${now}`,
    '',
    'Tool usage — IMPORTANT:',
    '- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") — even casually like "在 teams 中..." / "from mantis" — you MUST attempt the matching connector tool FIRST.',
    '  Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
    '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
    '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
    '- Reply without tools ONLY when no system + no time question is involved.',
    '',
    'Other:',
    '- Call get_current_time when asked about "now" or "today".',
    'Keep replies short and direct.',
  ];

  if (connectorTools.length > 0) {
    lines.push('', 'Connector tools available:');
    for (const t of connectorTools) {
      lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
    }
  }
  if (builtinDefs.length > 0) {
    lines.push('', 'Builtin tools:');
    for (const t of builtinDefs) {
      lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
    }
  }

  if (sessionSystemPrompt) {
    lines.push('', sessionSystemPrompt);
  }

  return lines.join('\n');
}

/**
 * Detect connector names the user mentioned in their message so we can narrow
 * the tool list before handing it to the LLM. Three signal strengths:
 *
 *   1. STRONG — slash-prefixed: "/teams" / "/mantis" → user explicitly
 *      commanded that connector, narrow to it AND emit a directive line in
 *      the system prompt. Multiple slashes ok.
 *   2. MEDIUM — bare alias mention surrounded by non-alphanumeric ("从 teams
 *      读消息" / "in mantis"). Narrow the tool list to those plugins so the
 *      LLM can't accidentally wander to a different connector, but no extra
 *      directive.
 *   3. NONE — no mention → keep the full tool list.
 *
 * Strong signals subsume medium ones (only the strong set is kept if any
 * exist). Aliases include the plugin id and its name first-word (>=4 chars).
 */
function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

function detectMentionedConnectors(
  userText: string,
  pluginIds: { id: string; name?: string }[],
): { strong: Set<string>; medium: Set<string> } {
  const strong = new Set<string>();
  const medium = new Set<string>();
  const text = userText || '';
  if (!text) return { strong, medium };

  for (const p of pluginIds) {
    const aliases = new Set<string>();
    aliases.add(p.id.toLowerCase());
    if (p.name) aliases.add(p.name.toLowerCase());
    if (p.name?.includes(' ')) {
      for (const word of p.name.split(/\s+/)) {
        if (word.length >= 4) aliases.add(word.toLowerCase());
      }
    }
    for (const alias of aliases) {
      // STRONG — slash-prefix form like "/teams"
      if (new RegExp(`(^|\\s)/${escapeRegExp(alias)}\\b`, 'i').test(text)) {
        strong.add(p.id);
        break;
      }
      // MEDIUM — bare alias, non-alphanumeric boundaries (Chinese chars count
      // as non-alphanumeric, so "从 teams 读消息" matches "teams" but not
      // "teamspeak")
      if (new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegExp(alias)}([^a-zA-Z0-9_]|$)`, 'i').test(text)) {
        medium.add(p.id);
        break;
      }
    }
  }
  return { strong, medium };
}

function buildConnectorTools(): LlmTool[] {
  const out: LlmTool[] = [];
  for (const inst of listInstalledConnectors()) {
    if (!inst.enabled) continue;
    const def = inst.definition;
    for (const entry of getConnectorEntries(def)) {
      for (const [toolName, tool] of Object.entries(entry.tools || {})) {
        // Executable if it has a script (browser protocol) OR a non-browser
        // protocol that runs server-side (http / shell).
        const protocol = (tool as any).protocol;
        const isServerSide = protocol === 'http' || protocol === 'shell';
        if (!tool.script && !isServerSide) continue;
        const properties: Record<string, unknown> = {};
        const required: string[] = [];
        for (const [pname, pdef] of Object.entries(tool.parameters || {})) {
          properties[pname] = fieldToJsonSchema(pdef as any);
          if ((pdef as any)?.required) required.push(pname);
        }
        out.push({
          name: `${def.id}.${toolName}`,
          description: (tool.description || `${def.name} · ${toolName}`).trim(),
          input_schema: {
            type: 'object',
            properties,
            ...(required.length > 0 ? { required } : {}),
          },
        });
      }
    }
  }
  return out;
}

function fieldToJsonSchema(field: { type?: string; description?: string; label?: string; default?: unknown; options?: string[] }): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  switch (field?.type) {
    case 'number': out.type = 'number'; break;
    case 'boolean': out.type = 'boolean'; break;
    case 'select':
      out.type = 'string';
      if (field.options) out.enum = field.options;
      break;
    default: out.type = 'string';
  }
  if (field?.description) out.description = field.description;
  else if (field?.label) out.description = field.label;
  if (field?.default !== undefined) out.default = field.default;
  return out;
}

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

export interface RunTurnArgs {
  sessionId: string;
  userText: string;
  callbacks?: AgentCallbacks;
}

export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?: string }> {
  const cb = args.callbacks?.onEvent ?? (() => {});
  const session = getSession(args.sessionId);
  if (!session) {
    cb({ type: 'error', data: { error: `session not found: ${args.sessionId}` } });
    return { ok: false, error: 'session not found' };
  }

  const provider = resolveProvider(session.provider, session.model);
  if ('error' in provider) {
    cb({ type: 'error', data: { error: provider.error } });
    return { ok: false, error: provider.error };
  }

  // Persist user message
  const userMsg = appendMessage({
    session_id: args.sessionId,
    role: 'user',
    blocks: [{ type: 'text', text: args.userText } as TextBlock],
  });
  cb({ type: 'message_saved', message_id: userMsg.id, data: userMsg });

  // ── Memory (Temper or local SQLite fallback) ───────────────────
  // When Temper is configured we use it; otherwise LocalMemoryStore
  // gives the same block API backed by workflow.db. Either way we
  // pre-fetch pinned + all blocks + a search on the user's message
  // and inline them into the system prompt. memory_* tools are always
  // registered so the LLM can read/write on demand.
  const memStore = getMemoryStore();
  let memContext = '';
  const memTools = buildMemoryTools(memStore);
  const memHandlers: Record<string, BuiltinHandler> = {};
  for (const t of memTools) memHandlers[t.def.name] = t.handle;

  if (memStore.enabled) {
    const [bp, ba, sp] = await Promise.allSettled([
      memStore.listBlocks({ pinned: true, scope: 'both' }),
      memStore.listBlocks({ scope: 'both' }),
      memStore.search(args.userText, 8),
    ]);
    const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
    const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
    const searchHits = sp.status === 'fulfilled' ? sp.value : [];
    const firstErr = [bp, ba, sp].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
    const memError = firstErr ? (firstErr.reason instanceof Error ? firstErr.reason.message : String(firstErr.reason)) : undefined;

    memContext = renderMemoryContext(allBlocks, searchHits);

    cb({
      type: 'memory_status',
      data: {
        ts: Date.now(),
        backend: memStore.kind,
        pinnedCount: pinnedBlocks.length,
        blocksCount: allBlocks.length,
        hitsCount: searchHits.filter((h) => h.fact && h.invalid_at == null).length,
        pinnedKeys: pinnedBlocks.map((b) => b.key),
        blockKeys: allBlocks.map((b) => b.key),
        hits: searchHits
          .filter((h) => h.fact && h.invalid_at == null)
          .slice(0, 8)
          .map((h) => (h.fact ?? '').slice(0, 120)),
        error: memError,
      },
    });
  }

  let connectorTools = buildConnectorTools();

  // ── Narrowing: if the user named connectors (/teams, "in mantis", ...)
  // restrict the tool list so the LLM can't wander to unrelated connectors.
  // Strong (slash-prefix) signals also emit a directive in the system prompt
  // so the model treats it as a command, not a hint.
  const allConnectorIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
  const pluginCatalog = allConnectorIds.map((id) => {
    const def = getConnector(id);
    return { id, name: def?.name };
  });
  const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
  const narrowSet = mentioned.strong.size > 0 ? mentioned.strong
                  : mentioned.medium.size > 0 ? mentioned.medium
                  : null;
  let narrowDirective = '';
  if (narrowSet) {
    connectorTools = connectorTools.filter((t) => narrowSet.has(t.name.split('.')[0]!));
    const list = [...narrowSet].join(', ');
    if (mentioned.strong.size > 0) {
      narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. You MUST use these connector tools for this turn — do NOT answer without trying them first, and do NOT consider other connectors.`;
      console.log(`[chat] narrow STRONG → ${list}`);
    } else {
      console.log(`[chat] narrow MEDIUM → ${list}`);
    }
  }

  console.log(
    `[chat] tools=${connectorTools.length} → ` +
    connectorTools.map((t) => t.name).join(', ').slice(0, 600),
  );

  const builtinDefsAll = [
    ...BUILTIN_TOOL_DEFS,
    ...memTools.map((m) => m.def),
  ];
  const allTools: LlmTool[] = [
    ...builtinDefsAll.map((t) => ({
      name: t.name,
      description: t.description,
      input_schema: t.input_schema,
    })),
    ...connectorTools,
  ];

  let system = buildSystemPrompt(connectorTools, builtinDefsAll, session.system_prompt);
  if (narrowDirective) system += narrowDirective;
  if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
  if (memStore.enabled) {
    const searchHint = memStore.kind === 'local'
      ? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
      : '• memory_search is for third-party graph search — DO NOT use for first-person questions; those are already in the Known facts block.';
    system += `\n\nMEMORY (${memStore.kind === 'temper' ? 'Temper' : 'local SQLite'}):\n` +
      '• When the user states a new preference / identity / role about THEMSELVES → call memory_remember_block in the same turn. After saving, tell the user one short line: "Saved: <key>=<value>".\n' +
      '• For NAMED third-party facts ("Alice prefers Postgres") → call memory_remember_event (one fact per call, name the subject).\n' +
      searchHint;
  }

  let iter = 0;
  let lastStop = '';
  let assistantBlocksAccum: ContentBlock[] = [];

  try {
    while (iter < MAX_ITERATIONS) {
      iter += 1;

      const history = listMessages(args.sessionId);

      assistantBlocksAccum = [];
      let currentTextBuf = '';

      const result = await streamLlm(
        {
          provider: provider.type,
          apiKey: provider.apiKey,
          baseUrl: provider.baseUrl,
          model: provider.model,
          system,
          history,
          tools: allTools,
          maxTokens: MAX_TOKENS,
        },
        {
          onTextDelta: (delta) => {
            currentTextBuf += delta;
            cb({ type: 'text_delta', data: { delta } });
          },
          onToolUse: (block) => {
            cb({ type: 'tool_use', data: block });
          },
        },
      );

      lastStop = result.stopReason;
      assistantBlocksAccum = result.content;

      // Persist assistant message
      const assistantMsg = appendMessage({
        session_id: args.sessionId,
        role: 'assistant',
        blocks: assistantBlocksAccum,
      });
      cb({ type: 'message_saved', message_id: assistantMsg.id, data: assistantMsg });

      if (result.stopReason !== 'tool_use') break;

      // Execute tool calls
      const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
      const toolResults: ToolResultBlock[] = [];
      for (const t of toolUses) {
        const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, memHandlers);
        const block: ToolResultBlock = {
          type: 'tool_result',
          tool_use_id: t.id,
          content: r.content,
          is_error: r.is_error,
        };
        toolResults.push(block);
        cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: r } });
      }

      // Per Anthropic API: tool_result blocks live on a USER message
      const toolResultMsg = appendMessage({
        session_id: args.sessionId,
        role: 'user',
        blocks: toolResults,
      });
      cb({ type: 'message_saved', message_id: toolResultMsg.id, data: toolResultMsg });
    }

    cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop } });
    return { ok: true };
  } catch (err) {
    let msg = err instanceof Error ? err.message : String(err);
    // Node's fetch throws "fetch failed" with the real reason hidden in
    // err.cause. Unwrap it + add concrete remediation so the user doesn't
    // have to guess between DNS / proxy / corp SSL / wrong baseUrl / VPN.
    if (/^fetch failed$/i.test(msg) || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|UNABLE_TO_VERIFY|self.signed|EAI_AGAIN/i.test(msg)) {
      const cause: any = (err as any)?.cause;
      const causeCode = cause?.code || '';
      const causeMsg = cause?.message || '';
      const baseUrl = provider.baseUrl || `(adapter default for ${provider.type})`;
      const hints: string[] = [];
      if (/ENOTFOUND|EAI_AGAIN/i.test(causeCode + causeMsg))   hints.push('DNS — host unresolvable. Corp VPN connected? Hostname typo in baseUrl?');
      if (/ECONNREFUSED/i.test(causeCode + causeMsg))           hints.push('Connection refused — endpoint down, or wrong port.');
      if (/ETIMEDOUT/i.test(causeCode + causeMsg))              hints.push('Timed out — firewall / proxy blocked, or VPN required for this endpoint.');
      if (/UNABLE_TO_VERIFY|self.signed/i.test(causeCode + causeMsg))
        hints.push('TLS cert untrusted — corp SSL inspection (Zscaler / firewall). Set NODE_EXTRA_CA_CERTS=<path/to/corp-ca.pem> in the env when starting forge.');
      if (hints.length === 0) hints.push('Verify: agent baseUrl reachable from this machine + apiKey valid + (if corp network) VPN/proxy/SSL inspection.');
      msg = [
        `LLM fetch failed → ${baseUrl}`,
        causeCode || causeMsg ? `cause: ${causeCode}${causeCode && causeMsg ? ' — ' : ''}${causeMsg}` : '',
        ...hints.map(h => '• ' + h),
        'Quick test:  curl -v ' + baseUrl + '   (from a terminal in the same shell that started forge)',
      ].filter(Boolean).join('\n');
    }
    appendMessage({
      session_id: args.sessionId,
      role: 'assistant',
      blocks: [{ type: 'text', text: '⚠ ' + msg } as TextBlock],
      error: msg,
    });
    cb({ type: 'error', data: { error: msg } });
    return { ok: false, error: msg };
  }
}
