import type { AuthUser } from "@pi-chat/shared";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { AppDatabase } from "../db/database.js";
import { getAuthUser } from "../auth/index.js";
import { webhookRateLimiterFromEnv } from "../rate-limit.js";
import { createSessionRepository } from "../sessions/repository.js";
import { createWebhookAuditRepository } from "./audit.js";
import { canUseWebhook, loadWebhookDefinitions, toPublicWebhook } from "./config.js";
import { envNumber } from "../env.js";

const runWebhookBody = z.object({
  sessionId: z.string().min(1),
  confirmed: z.literal(true),
  lastUserMessage: z.string().max(envNumber(process.env.CHAT_MESSAGE_MAX_CHARS, 8_000)).default(""),
  payload: z.record(z.unknown()).default({}),
});

const webhookRunsQuery = z.object({
  sessionId: z.string().min(1),
  limit: z.coerce.number().int().min(1).max(50).default(20),
});

const renderTemplate = (template: unknown, context: Record<string, unknown>): unknown => {
  if (typeof template === "string") {
    return template.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (_match, path: string) => {
      const value = path.split(".").reduce<unknown>((current, key) => {
        if (current && typeof current === "object" && key in current) {
          return (current as Record<string, unknown>)[key];
        }
        return "";
      }, context);

      return value == null ? "" : String(value);
    });
  }

  if (Array.isArray(template)) {
    return template.map((item) => renderTemplate(item, context));
  }

  if (template && typeof template === "object") {
    return Object.fromEntries(
      Object.entries(template).map(([key, value]) => [key, renderTemplate(value, context)]),
    );
  }

  return template;
};

const buildPayload = (template: unknown, input: z.infer<typeof runWebhookBody>, user: AuthUser) => {
  const templated = renderTemplate(template ?? {}, {
    user,
    session: { id: input.sessionId },
    chat: { last_user_message: input.lastUserMessage },
  });

  return {
    ...(templated && typeof templated === "object" && !Array.isArray(templated) ? templated : {}),
    ...input.payload,
  };
};

const fetchWithTimeout = async (url: string, init: RequestInit, timeoutMs: number) => {
  const abortController = new AbortController();
  const timeout = setTimeout(() => abortController.abort(), timeoutMs);

  return fetch(url, { ...init, signal: abortController.signal }).finally(() => clearTimeout(timeout));
};

const sleep = (ms: number) =>
  new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });

const isRetryableStatus = (status: number) => status >= 500 || status === 429;

type RetryPolicy = {
  maxAttempts: number;
  initialBackoffMs: number;
  maxBackoffMs: number;
  timeoutMs: number;
};

const retryPolicyFromEnv = (): RetryPolicy => {
  const maxAttempts = Math.max(1, envNumber(process.env.WEBHOOK_RETRY_MAX_ATTEMPTS, 3));
  const initialBackoffMs = Math.max(0, envNumber(process.env.WEBHOOK_RETRY_INITIAL_BACKOFF_MS, 500));
  const maxBackoffMs = Math.max(initialBackoffMs, envNumber(process.env.WEBHOOK_RETRY_MAX_BACKOFF_MS, 5_000));
  const timeoutMs = Math.max(1, envNumber(process.env.WEBHOOK_TIMEOUT_MS, 15_000));

  return { maxAttempts, initialBackoffMs, maxBackoffMs, timeoutMs };
};

type RunOutcome = {
  response: Response | null;
  attempts: number;
  lastError: unknown;
};

const runWithRetry = async (url: string, init: RequestInit, policy: RetryPolicy): Promise<RunOutcome> => {
  let lastError: unknown = null;
  let response: Response | null = null;

  for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
    try {
      response = await fetchWithTimeout(url, init, policy.timeoutMs);
      if (response.ok) {
        return { response, attempts: attempt, lastError: null };
      }
      if (!isRetryableStatus(response.status)) {
        // 4xx (non-429): don't retry, surface as-is.
        return { response, attempts: attempt, lastError: null };
      }
    } catch (error) {
      lastError = error;
      response = null;
    }

    if (attempt < policy.maxAttempts) {
      const backoff = Math.min(
        policy.maxBackoffMs,
        policy.initialBackoffMs * 2 ** (attempt - 1),
      );
      await sleep(backoff);
    }
  }

  return { response, attempts: policy.maxAttempts, lastError };
};

export const registerWebhookRoutes = async (app: FastifyInstance, db: AppDatabase) => {
  const sessions = createSessionRepository(db);
  const audit = createWebhookAuditRepository(db);
  const webhookRateLimiter = webhookRateLimiterFromEnv();

  app.get("/api/webhooks", async (request) => {
    const user = await getAuthUser(request);
    const items = loadWebhookDefinitions()
      .filter((webhook) => canUseWebhook(user.roles, webhook))
      .map(toPublicWebhook);

    return { items };
  });

  app.get("/api/webhooks/usage", async (request) => {
    const user = await getAuthUser(request);
    const query = z
      .object({ days: z.coerce.number().int().min(1).max(365).default(7) })
      .parse(request.query ?? {});
    const since = new Date(Date.now() - query.days * 86_400_000).toISOString();
    const usage = audit.usageForUserSince(since, user.id);
    const items = Object.entries(usage).map(([webhook_id, stats]) => ({
      webhook_id,
      runs: stats.runs,
      successes: stats.successes,
      success_rate: stats.successRate,
      window_days: query.days,
    }));
    return { window_days: query.days, items };
  });

  app.get("/api/webhook-runs", async (request, reply) => {
    const user = await getAuthUser(request);
    const query = webhookRunsQuery.parse(request.query);
    const session = sessions.get(user.id, query.sessionId);

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

    const items = audit.listForSession(user.id, query.sessionId, query.limit).map((run) => ({
      id: run.id,
      webhook_id: run.webhook_id,
      session_id: run.session_id,
      status: run.status,
      response_status: run.response_status,
      attempts: run.attempts,
      created_at: run.created_at,
    }));

    return { items };
  });

  app.post("/api/webhooks/:id/run", async (request, reply) => {
    const user = await getAuthUser(request);
    const params = z.object({ id: z.string().min(1) }).parse(request.params);
    const body = runWebhookBody.parse(request.body);
    const webhook = loadWebhookDefinitions().find((item) => item.id === params.id);

    if (!webhook || !canUseWebhook(user.roles, webhook)) {
      return reply.code(404).send({ error: "webhook_not_found" });
    }

    // Per-webhook abuse detection: each webhook_id has its own bucket so a
    // runaway loop on one hook doesn't starve the rest. The bucket is shared
    // across all users on purpose — that's the abuse signal.
    const decision = webhookRateLimiter.consume(webhook.id);
    if (!decision.ok) {
      const retryAfterSec = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
      app.log.warn(
        { webhook: webhook.id, user: user.id, retryAfterSec },
        "webhook 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 session = sessions.get(user.id, body.sessionId);
    if (!session) {
      return reply.code(404).send({ error: "session_not_found" });
    }

    const requestPayload = buildPayload(webhook.payload_template, body, user);

    if (!webhook.url) {
      audit.create({
        webhookId: webhook.id,
        userId: user.id,
        sessionId: body.sessionId,
        status: "error",
        requestPayload,
        attempts: 0,
      });

      return reply.code(500).send({ error: "webhook_not_configured" });
    }

    const policy = retryPolicyFromEnv();
    const outcome = await runWithRetry(
      webhook.url,
      {
        method: webhook.method,
        headers: { "content-type": "application/json" },
        body: webhook.method === "GET" ? undefined : JSON.stringify(requestPayload),
      },
      policy,
    );

    if (outcome.lastError) {
      app.log.error({ err: outcome.lastError, webhook: webhook.id, attempts: outcome.attempts }, "webhook request failed after retries");
    } else if (outcome.attempts > 1 && outcome.response) {
      app.log.warn(
        { webhook: webhook.id, attempts: outcome.attempts, status: outcome.response.status },
        "webhook request retried",
      );
    }

    const response = outcome.response;
    const httpOk = response?.ok ?? false;
    const isTransportError = !response;

    const run = audit.create({
      webhookId: webhook.id,
      userId: user.id,
      sessionId: body.sessionId,
      status: httpOk ? "success" : "error",
      requestPayload,
      responseStatus: response?.status ?? null,
      attempts: outcome.attempts,
    });

    if (isTransportError) {
      return reply.code(502).send({
        id: run.id,
        webhook_id: run.webhook_id,
        status: run.status,
        response_status: run.response_status,
        attempts: run.attempts,
        error: "webhook_request_failed",
      });
    }

    return reply.code(httpOk ? 200 : 502).send({
      id: run.id,
      webhook_id: run.webhook_id,
      status: run.status,
      response_status: run.response_status,
      attempts: run.attempts,
    });
  });
};
