import { createOpenAICompatiblePiAdapter } from "@pi-chat/pi-adapter";
import type { InternalDocReference, RecommendedAction } from "@pi-chat/shared";
import type { FastifyInstance, FastifyReply } from "fastify";
import { z } from "zod";
import { getAuthUser } from "../auth/index.js";
import type { AppDatabase } from "../db/database.js";
import { createDocsRepository, type KnowledgeSearchResult } from "../docs/repository.js";
import { findModelDefinition, getDefaultModelId, resolveFallbackChain, resolveModelApiKey } from "../models/config.js";
import { envNumber } from "../env.js";
import { chatRateLimiterFromEnv } from "../rate-limit.js";
import { getEnabledSkillPrompts } from "../skills/config.js";
import { createMessageRepository, createSessionRepository } from "../sessions/repository.js";
import { createWebhookAuditRepository } from "../webhooks/audit.js";
import { canUseWebhook, loadWebhookDefinitions } from "../webhooks/config.js";

const chatStreamBody = z.object({
  sessionId: z.string().min(1),
  message: z.string().trim().min(1).max(envNumber(process.env.CHAT_MESSAGE_MAX_CHARS, 8_000)),
  model: z.string().trim().default(getDefaultModelId()),
});

const sendEvent = (reply: FastifyReply, event: unknown) => {
  reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
};

const sendAnswerTokens = (reply: FastifyReply, answer: string) => {
  for (const token of answer.match(/\S+\s*/g) ?? []) {
    sendEvent(reply, { type: "token", token });
  }
};

const toDocReference = (doc: KnowledgeSearchResult): InternalDocReference => ({
  id: doc.id,
  title: doc.title,
  source: doc.source,
  relevance: doc.relevance,
});

const shouldAutoTitle = (title: string | null) => !title || title === "New session";

const titleFromMessage = (message: string) => {
  const normalized = message.replace(/\s+/g, " ").trim();
  return normalized.length > 48 ? `${normalized.slice(0, 45)}...` : normalized;
};

// Match a webhook id against a chunk of text. Accepts the full id (with - or _)
// OR its first word (e.g. id "dns-flush" matches "dns" in "flush the dns cache").
// Both sides are normalized to lowercase and dashes/underscores → spaces.
const matchesId = (text: string, id: string): boolean => {
  const normalized = text.toLowerCase().replace(/[-_]+/g, " ");
  const normalizedId = id.toLowerCase().replace(/[-_]+/g, " ");
  if (normalized.includes(normalizedId)) return true;
  const firstWord = normalizedId.split(" ")[0] ?? "";
  if (firstWord.length >= 3 && new RegExp(`\\b${firstWord}\\b`, "i").test(normalized)) {
    return true;
  }
  return false;
};

const enrichActionsWithMentions = (
  userMessage: string,
  answer: string,
  recommended: RecommendedAction[],
  available: RecommendedAction[],
): RecommendedAction[] => {
  const seen = new Set(recommended.map((action) => action.id));
  const enriched = [...recommended];
  for (const candidate of available) {
    if (seen.has(candidate.id)) continue;
    if (matchesId(userMessage, candidate.id) || matchesId(answer, candidate.id)) {
      enriched.push({
        ...candidate,
        // Lower confidence than LLM-recommended ones so the UI can show the
        // difference if needed; still actionable.
        confidence: 0.4,
        reason: "Mentioned in the conversation",
      });
      seen.add(candidate.id);
    }
  }
  return enriched;
};

export const registerChatRoutes = async (app: FastifyInstance, db: AppDatabase) => {
  const sessions = createSessionRepository(db);
  const messages = createMessageRepository(db);
  const docs = createDocsRepository();
  const audit = createWebhookAuditRepository(db);
  const rateLimiter = chatRateLimiterFromEnv();

  app.post("/api/chat/stream", async (request, reply) => {
    const user = await getAuthUser(request);

    const decision = rateLimiter.consume(user.id);
    if (!decision.ok) {
      const retryAfterSec = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
      app.log.warn({ user: user.id, retryAfterSec }, "chat rate limit exceeded");
      return reply
        .code(429)
        .header("retry-after", String(retryAfterSec))
        .header("x-ratelimit-remaining", "0")
        .send({
          error: "rate_limited",
          retry_after_ms: decision.retryAfterMs,
        });
    }
    reply.header("x-ratelimit-remaining", String(decision.remaining));

    const body = chatStreamBody.parse(request.body);
    const session = sessions.get(user.id, body.sessionId);
    const selectedModel = findModelDefinition(body.model);

    if (!session) {
      return reply.code(404).send({ error: "session_not_found" });
    }

    if (!selectedModel) {
      return reply.code(400).send({ error: "model_not_found" });
    }

const resolvedKey = resolveModelApiKey(selectedModel);
    app.log.debug(
      {
        model: selectedModel.id,
        keyLen: resolvedKey.length,
        llmKeyLen: (process.env.LLM_API_KEY ?? "").length,
      },
      "llm api key resolved",
    );

    // Build the ordered fallback chain starting at the selected model. The
    // adapter is built fresh per model because base URL, key and provider
    // model name can differ across chain entries.
    const chain = resolveFallbackChain(selectedModel.id);
    const chainModels = chain
      .map((id) => findModelDefinition(id))
      .filter((m): m is NonNullable<ReturnType<typeof findModelDefinition>> => Boolean(m));

    const adapters = chainModels.map((model) => ({
      model,
      pi: createOpenAICompatiblePiAdapter({
        baseUrl: model.base_url || process.env.LLM_BASE_URL || "https://api.minimax.io/v1",
        apiKey: resolveModelApiKey(model),
        defaultModel: model.model,
        maxTokens: model.max_tokens,
      }),
    }));

    reply.raw.writeHead(200, {
      "content-type": "text/event-stream; charset=utf-8",
      "cache-control": "no-cache, no-transform",
      connection: "keep-alive",
    });

    const userMessage = messages.create({
      sessionId: body.sessionId,
      userId: user.id,
      role: "user",
      content: body.message,
      metadata: { model: selectedModel.id },
    });

    if (shouldAutoTitle(session.title)) {
      sessions.updateTitle(user.id, body.sessionId, titleFromMessage(body.message));
    }

    sessions.touch(user.id, body.sessionId);

    const docResults = await docs.search(body.message, 5);
    const internalDocs = docResults.map(toDocReference);

    // Soft usage signal: how often has this user run each webhook in the
    // recent past. Surfaced as `usageHint` so the LLM can prefer frequently
    // used webhooks when ambiguous, and so the UI can show a "Most used" tag.
    const usageSinceDays = Math.max(0, envNumber(process.env.WEBHOOK_USAGE_WINDOW_DAYS, 7));
    const usageSince = usageSinceDays > 0
      ? new Date(Date.now() - usageSinceDays * 86_400_000).toISOString()
      : new Date(0).toISOString();
    const usageMap = usageSinceDays > 0
      ? audit.usageForUserSince(usageSince, user.id)
      : {};

    const formatUsageHint = (webhookId: string): string | null => {
      const stats = usageMap[webhookId];
      if (!stats || stats.runs === 0) return null;
      const successPct = Math.round(stats.successRate * 100);
      return `${stats.runs} run${stats.runs === 1 ? "" : "s"} in last ${usageSinceDays}d, ${successPct}% success`;
    };

    const availableActions: RecommendedAction[] = loadWebhookDefinitions()
      .filter((webhook) => canUseWebhook(user.roles, webhook))
      .map((webhook) => {
        const usageHint = formatUsageHint(webhook.id);
        return {
          type: "webhook" as const,
          id: webhook.id,
          confidence: 0,
          reason: webhook.description ?? webhook.label,
          requires_confirmation: webhook.confirmation_required,
          ...(usageHint ? { usageHint } : {}),
        };
      });

    sendEvent(reply, { type: "docs", docs: docResults });

    try {
      const history = messages
        .listForSession(user.id, body.sessionId)
        .filter((message) => message.id !== userMessage.id)
        .slice(-12)
        .map((message) => ({ role: message.role, content: message.content }));

      const t0 = Date.now();
      // Walk the fallback chain. The first adapter that returns ok=true
      // wins. If a structured error comes back from any one model we move
      // to the next; an exception (network/5xx/timeout) also jumps chain.
      let chat: Awaited<ReturnType<typeof adapters[number]["pi"]["chat"]>> | null = null;
      let usedModelId = selectedModel.id;
      let fallbackAttempts = 0;
      const failures: Array<{ model: string; reason: string; kind?: string }> = [];

      for (const entry of adapters) {
        try {
          const result = await entry.pi.chat({
            message: body.message,
            model: entry.model.model,
            docs: internalDocs,
            availableActions,
            history,
            skillPrompts: getEnabledSkillPrompts(),
            systemPrompt: session.system_prompt,
          });
          if (result.ok) {
            chat = result;
            usedModelId = entry.model.id;
            break;
          }
          // Structured error (no_content / json_parse / schema). Try next.
          failures.push({
            model: entry.model.id,
            kind: result.error.kind,
            reason: result.error.kind === "no_content"
              ? result.error.message
              : result.error.reason,
          });
          fallbackAttempts += 1;
          chat = result; // keep last error for the controlled fallback path
          usedModelId = entry.model.id;
        } catch (error) {
          // Transport / timeout / 5xx — also fall through.
          failures.push({
            model: entry.model.id,
            reason: error instanceof Error ? error.message : String(error),
          });
          fallbackAttempts += 1;
          app.log.warn(
            { model: entry.model.id, err: error },
            "llm call failed, trying next model in fallback chain",
          );
        }
      }

      const durationMs = Date.now() - t0;
      if (!chat) {
        throw new Error("all fallback models failed");
      }

      // The adapter may return ok=true (well-formed JSON) or ok=false with
      // a structured error + safe fallback. In both cases the fallback
      // contains a usable `answer` and (possibly empty) actions; we never
      // throw on a parse/schema problem — those are operational signal, not
      // request failures.
      const result = chat.ok ? chat.result : chat.fallback;
      if (!chat.ok) {
        app.log.warn(
          {
            kind: chat.error.kind,
            reason: chat.error.kind === "no_content" ? chat.error.message : chat.error.reason,
            model: usedModelId,
          },
          "pi-adapter returned a parse/structured error; using safe fallback",
        );
      }

      // Deterministic safety net: if the LLM forgot to put a relevant webhook
      // in `recommended_actions` (common with short user prompts), scan both
      // the user's input and the model's answer for any role-allowed webhook
      // id (or its first word) and synthesize an action so the user can still
      // execute it from the right panel.
      const recommendedActions = enrichActionsWithMentions(
        body.message,
        result.answer,
        result.recommended_actions,
        availableActions,
      );

      sendAnswerTokens(reply, result.answer);
      sendEvent(reply, { type: "actions", actions: recommendedActions });

      const assistantMetadata: Record<string, unknown> = {
        model: usedModelId,
        docs: result.internal_docs,
        actions: result.recommended_actions,
        usage: { ...(chat.usage ?? {}), durationMs },
      };
      if (usedModelId !== selectedModel.id || fallbackAttempts > 0) {
        assistantMetadata.requested_model = selectedModel.id;
        assistantMetadata.fallback_attempts = fallbackAttempts;
        assistantMetadata.fallback_chain = chain;
        assistantMetadata.fallback_failures = failures;
      }
      if (!chat.ok) {
        assistantMetadata.error_kind = chat.error.kind;
        assistantMetadata.error_reason = chat.error.kind === "no_content"
          ? chat.error.message
          : chat.error.reason;
        assistantMetadata.fallback = true;
      }

      messages.create({
        sessionId: body.sessionId,
        userId: user.id,
        role: "assistant",
        content: result.answer,
        metadata: assistantMetadata,
      });
      sessions.touch(user.id, body.sessionId);

      sendEvent(reply, { type: "done" });
      reply.raw.end();
    } catch (error) {
      const message = "I could not complete the model response. The user message has been saved.";

      app.log.error(error);
      messages.create({
        sessionId: body.sessionId,
        userId: user.id,
        role: "assistant",
        content: message,
        metadata: {
          model: selectedModel.id,
          error: error instanceof Error ? error.message : "unknown_error",
        },
      });
      sessions.touch(user.id, body.sessionId);

      sendEvent(reply, { type: "token", token: message });
      sendEvent(reply, { type: "error", error: "llm_error" });
      sendEvent(reply, { type: "done" });
      reply.raw.end();
    }

    return reply;
  });
};
