import { randomBytes } from "node:crypto";
import { BadRequestException, ForbiddenException, ServiceUnavailableException } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import type Redis from "ioredis";
import type {
  CreateZoomMeetingRequest,
  ExternalCalendarEvent,
  ConnectedAccountProvider,
  ConnectedAccountStatus,
  OAuthAccountProvider,
  RewriteMessageRequest,
  RewriteTone,
} from "@slackwsh/contracts";
import { schema, withUser } from "@slackwsh/data";
import { asEntityId, type EntityIdInput } from "./channels";
import { decryptSecret, encryptSecret } from "./secret-crypto";

const OAUTH_PROVIDERS = ["google_calendar", "outlook_calendar", "zoom"] as const;
const ALL_PROVIDERS: ConnectedAccountProvider[] = ["google_calendar", "outlook_calendar", "zoom", "openai"];

const OAUTH_STATE_TTL_SEC = 600;

type IntegrationRow = typeof schema.userIntegrations.$inferSelect;

interface OAuthAppConfig {
  clientId: string;
  clientSecret: string;
  authUrl: string;
  tokenUrl: string;
  scopes: string[];
  extraAuthParams?: Record<string, string>;
}

function publicApiUrl(): string {
  return (process.env.PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001").replace(/\/$/, "");
}

function publicWebUrl(): string {
  return (process.env.PUBLIC_WEB_URL || process.env.NEXT_PUBLIC_WEB_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
}

function oauthConfig(provider: OAuthAccountProvider): OAuthAppConfig | null {
  if (provider === "google_calendar") {
    const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID;
    const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
    if (!clientId || !clientSecret) return null;
    return {
      clientId,
      clientSecret,
      authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
      tokenUrl: "https://oauth2.googleapis.com/token",
      scopes: ["https://www.googleapis.com/auth/calendar.readonly", "openid", "email", "profile"],
      extraAuthParams: { access_type: "offline", prompt: "consent" },
    };
  }
  if (provider === "outlook_calendar") {
    const clientId = process.env.MICROSOFT_OAUTH_CLIENT_ID;
    const clientSecret = process.env.MICROSOFT_OAUTH_CLIENT_SECRET;
    if (!clientId || !clientSecret) return null;
    return {
      clientId,
      clientSecret,
      authUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
      tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
      scopes: ["offline_access", "User.Read", "Calendars.Read"],
    };
  }
  const clientId = process.env.ZOOM_OAUTH_CLIENT_ID;
  const clientSecret = process.env.ZOOM_OAUTH_CLIENT_SECRET;
  if (!clientId || !clientSecret) return null;
  return {
    clientId,
    clientSecret,
    authUrl: "https://zoom.us/oauth/authorize",
    tokenUrl: "https://zoom.us/oauth/token",
    scopes: [],
  };
}

function callbackUrl(provider: OAuthAccountProvider): string {
  return `${publicApiUrl()}/integrations/oauth/${provider}/callback`;
}

function isOAuthProvider(provider: string): provider is OAuthAccountProvider {
  return (OAUTH_PROVIDERS as readonly string[]).includes(provider);
}

function statusFromRow(provider: ConnectedAccountProvider, row: IntegrationRow | undefined): ConnectedAccountStatus {
  const configured = provider === "openai" ? true : oauthConfig(provider as OAuthAccountProvider) != null;
  return {
    provider,
    connected: Boolean(row?.accessTokenEnc),
    accountEmail: row?.accountEmail ?? null,
    accountLabel: row?.accountLabel ?? null,
    secretLast4: row?.secretLast4 ?? null,
    configured,
  };
}

export async function listConnectedAccountStatuses(userId: EntityIdInput): Promise<ConnectedAccountStatus[]> {
  const uid = asEntityId(userId);
  const rows = await withUser(uid, (tx) =>
    tx.select().from(schema.userIntegrations).where(eq(schema.userIntegrations.userId, uid)),
  );
  const byProvider = new Map(rows.map((row) => [row.provider, row]));
  return ALL_PROVIDERS.map((provider) => statusFromRow(provider, byProvider.get(provider)));
}

export async function saveOpenAiKey(userId: EntityIdInput, apiKey: string): Promise<ConnectedAccountStatus> {
  const uid = asEntityId(userId);
  const trimmed = apiKey.trim();
  if (!trimmed.startsWith("sk-")) {
    throw new BadRequestException("OpenAI API keys usually start with sk-");
  }
  const enc = encryptSecret(trimmed);
  const last4 = trimmed.slice(-4);
  await withUser(uid, async (tx) => {
    const [existing] = await tx
      .select()
      .from(schema.userIntegrations)
      .where(and(eq(schema.userIntegrations.userId, uid), eq(schema.userIntegrations.provider, "openai")))
      .limit(1);
    if (existing) {
      await tx
        .update(schema.userIntegrations)
        .set({
          accessTokenEnc: enc,
          secretLast4: last4,
          accountLabel: "OpenAI",
          updatedAt: new Date(),
        })
        .where(eq(schema.userIntegrations.id, existing.id));
    } else {
      await tx.insert(schema.userIntegrations).values({
        userId: uid,
        provider: "openai",
        accessTokenEnc: enc,
        secretLast4: last4,
        accountLabel: "OpenAI",
      });
    }
  });
  return {
    provider: "openai",
    connected: true,
    secretLast4: last4,
    accountLabel: "OpenAI",
    accountEmail: null,
    configured: true,
  };
}

export async function disconnectIntegration(userId: EntityIdInput, provider: ConnectedAccountProvider): Promise<void> {
  const uid = asEntityId(userId);
  await withUser(uid, (tx) =>
    tx
      .delete(schema.userIntegrations)
      .where(and(eq(schema.userIntegrations.userId, uid), eq(schema.userIntegrations.provider, provider))),
  );
}

async function resolveOpenAiKey(userId: number): Promise<string> {
  const row = await withUser(userId, async (tx) => {
    const [found] = await tx
      .select()
      .from(schema.userIntegrations)
      .where(and(eq(schema.userIntegrations.userId, userId), eq(schema.userIntegrations.provider, "openai")))
      .limit(1);
    return found ?? null;
  });
  if (row?.accessTokenEnc) return decryptSecret(row.accessTokenEnc);
  const envKey = process.env.OPENAI_API_KEY?.trim();
  if (envKey) return envKey;
  throw new ServiceUnavailableException(
    "No OpenAI API key configured. Add yours in Settings → Writing assistant, or set OPENAI_API_KEY on the server.",
  );
}

function toneInstruction(tone: RewriteTone, mode: "rewrite" | "proofread"): string {
  if (mode === "proofread") {
    return "Fix grammar, spelling, and clarity. Keep the author's voice and meaning. Do not add new ideas.";
  }
  const tones: Record<RewriteTone, string> = {
    professional: "Rewrite in a clear, professional workplace tone.",
    friendly: "Rewrite in a warm, friendly, approachable tone.",
    concise: "Rewrite to be shorter and more direct without losing meaning.",
    formal: "Rewrite in a formal, polished tone suitable for executives.",
    casual: "Rewrite in a casual, conversational tone.",
  };
  return tones[tone];
}

export async function rewriteMessage(userId: EntityIdInput, input: RewriteMessageRequest): Promise<{ text: string }> {
  const uid = asEntityId(userId);
  const text = input.text.trim();
  if (!text) throw new BadRequestException("Nothing to rewrite");
  const apiKey = await resolveOpenAiKey(uid);
  const system = [
    "You rewrite chat messages for a workplace messaging app.",
    toneInstruction(input.tone, input.mode),
    "Return ONLY the rewritten message text. No quotes, no preface, no markdown fences.",
    "Preserve @mentions and URLs exactly.",
  ].join(" ");

  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: process.env.OPENAI_REWRITE_MODEL || "gpt-4o-mini",
      // Omit temperature — gpt-5 / o-series models only allow the default (1).
      max_completion_tokens: 1200,
      messages: [
        { role: "system", content: system },
        { role: "user", content: text },
      ],
    }),
  });

  if (!response.ok) {
    const detail = await response.text().catch(() => "");
    throw new BadRequestException(`OpenAI request failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ""}`);
  }
  const payload = (await response.json()) as {
    choices?: Array<{ message?: { content?: string } }>;
  };
  const out = payload.choices?.[0]?.message?.content?.trim();
  if (!out) throw new BadRequestException("OpenAI returned an empty rewrite");
  return { text: out };
}

export async function startOAuth(
  redis: Redis,
  userId: EntityIdInput,
  provider: OAuthAccountProvider,
  returnTo?: string,
): Promise<{ url: string }> {
  const uid = asEntityId(userId);
  const config = oauthConfig(provider);
  if (!config) {
    throw new ServiceUnavailableException(
      `${provider} is not configured on this server. Set the OAuth client id/secret in the environment.`,
    );
  }
  const state = randomState();
  await redis.set(
    `oauth:state:${state}`,
    JSON.stringify({ userId: uid, provider, returnTo: sanitizeReturnTo(returnTo) }),
    "EX",
    OAUTH_STATE_TTL_SEC,
  );

  const url = new URL(config.authUrl);
  url.searchParams.set("client_id", config.clientId);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("redirect_uri", callbackUrl(provider));
  url.searchParams.set("state", state);
  if (config.scopes.length) url.searchParams.set("scope", config.scopes.join(" "));
  for (const [key, value] of Object.entries(config.extraAuthParams ?? {})) {
    url.searchParams.set(key, value);
  }
  return { url: url.toString() };
}

function randomState(): string {
  return randomBytes(24).toString("base64url");
}

function sanitizeReturnTo(returnTo: string | undefined): string {
  if (!returnTo) return "/settings";
  if (returnTo.startsWith("/") && !returnTo.startsWith("//")) return returnTo;
  return "/settings";
}

interface TokenResponse {
  access_token: string;
  refresh_token?: string;
  expires_in?: number;
  scope?: string;
  token_type?: string;
}

export async function completeOAuth(
  redis: Redis,
  provider: OAuthAccountProvider,
  code: string,
  state: string,
): Promise<{ returnTo: string }> {
  const raw = await redis.get(`oauth:state:${state}`);
  await redis.del(`oauth:state:${state}`);
  if (!raw) throw new BadRequestException("OAuth state expired or invalid — try connecting again");
  const pending = JSON.parse(raw) as { userId: number; provider: string; returnTo: string };
  if (pending.provider !== provider) throw new ForbiddenException("OAuth provider mismatch");

  const config = oauthConfig(provider);
  if (!config) throw new ServiceUnavailableException("OAuth provider is not configured");

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: callbackUrl(provider),
    client_id: config.clientId,
    client_secret: config.clientSecret,
  });

  const tokenRes = await fetch(config.tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
    body,
  });
  if (!tokenRes.ok) {
    const detail = await tokenRes.text().catch(() => "");
    throw new BadRequestException(`Token exchange failed (${tokenRes.status}): ${detail.slice(0, 200)}`);
  }
  const tokens = (await tokenRes.json()) as TokenResponse;
  if (!tokens.access_token) throw new BadRequestException("Token exchange returned no access token");

  const profile = await fetchAccountProfile(provider, tokens.access_token);
  const expiresAt =
    tokens.expires_in != null ? new Date(Date.now() + Math.max(0, tokens.expires_in - 60) * 1000) : null;

  await withUser(pending.userId, async (tx) => {
    const [existing] = await tx
      .select()
      .from(schema.userIntegrations)
      .where(and(eq(schema.userIntegrations.userId, pending.userId), eq(schema.userIntegrations.provider, provider)))
      .limit(1);
    const values = {
      accessTokenEnc: encryptSecret(tokens.access_token),
      refreshTokenEnc: tokens.refresh_token ? encryptSecret(tokens.refresh_token) : existing?.refreshTokenEnc ?? null,
      accessTokenExpiresAt: expiresAt,
      scopes: tokens.scope ?? config.scopes.join(" "),
      accountEmail: profile.email,
      accountLabel: profile.label,
      updatedAt: new Date(),
    };
    if (existing) {
      await tx.update(schema.userIntegrations).set(values).where(eq(schema.userIntegrations.id, existing.id));
    } else {
      await tx.insert(schema.userIntegrations).values({
        userId: pending.userId,
        provider,
        ...values,
      });
    }
  });

  return { returnTo: pending.returnTo || "/settings" };
}

async function fetchAccountProfile(
  provider: OAuthAccountProvider,
  accessToken: string,
): Promise<{ email: string | null; label: string | null }> {
  try {
    if (provider === "google_calendar") {
      const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
        headers: { Authorization: `Bearer ${accessToken}` },
      });
      if (!res.ok) return { email: null, label: "Google Calendar" };
      const data = (await res.json()) as { email?: string; name?: string };
      return { email: data.email ?? null, label: data.name ?? "Google Calendar" };
    }
    if (provider === "outlook_calendar") {
      const res = await fetch("https://graph.microsoft.com/v1.0/me", {
        headers: { Authorization: `Bearer ${accessToken}` },
      });
      if (!res.ok) return { email: null, label: "Outlook" };
      const data = (await res.json()) as { mail?: string; userPrincipalName?: string; displayName?: string };
      return {
        email: data.mail ?? data.userPrincipalName ?? null,
        label: data.displayName ?? "Outlook",
      };
    }
    const res = await fetch("https://api.zoom.us/v2/users/me", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    if (!res.ok) return { email: null, label: "Zoom" };
    const data = (await res.json()) as { email?: string; first_name?: string; last_name?: string };
    const name = [data.first_name, data.last_name].filter(Boolean).join(" ");
    return { email: data.email ?? null, label: name || "Zoom" };
  } catch {
    return { email: null, label: provider };
  }
}

async function getValidAccessToken(userId: number, provider: OAuthAccountProvider): Promise<string> {
  const row = await withUser(userId, async (tx) => {
    const [found] = await tx
      .select()
      .from(schema.userIntegrations)
      .where(and(eq(schema.userIntegrations.userId, userId), eq(schema.userIntegrations.provider, provider)))
      .limit(1);
    return found ?? null;
  });
  if (!row?.accessTokenEnc) {
    throw new BadRequestException(`${provider} is not connected`);
  }
  const stillValid =
    !row.accessTokenExpiresAt || row.accessTokenExpiresAt.getTime() > Date.now() + 60_000;
  if (stillValid) return decryptSecret(row.accessTokenEnc);

  if (!row.refreshTokenEnc) {
    throw new BadRequestException(`${provider} session expired — reconnect in Settings`);
  }
  const config = oauthConfig(provider);
  if (!config) throw new ServiceUnavailableException("OAuth provider is not configured");

  const body = new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: decryptSecret(row.refreshTokenEnc),
    client_id: config.clientId,
    client_secret: config.clientSecret,
  });
  const tokenRes = await fetch(config.tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
    body,
  });
  if (!tokenRes.ok) {
    throw new BadRequestException(`${provider} refresh failed — reconnect in Settings`);
  }
  const tokens = (await tokenRes.json()) as TokenResponse;
  const expiresAt =
    tokens.expires_in != null ? new Date(Date.now() + Math.max(0, tokens.expires_in - 60) * 1000) : null;
  await withUser(userId, (tx) =>
    tx
      .update(schema.userIntegrations)
      .set({
        accessTokenEnc: encryptSecret(tokens.access_token),
        refreshTokenEnc: tokens.refresh_token ? encryptSecret(tokens.refresh_token) : row.refreshTokenEnc,
        accessTokenExpiresAt: expiresAt,
        updatedAt: new Date(),
      })
      .where(eq(schema.userIntegrations.id, row.id)),
  );
  return tokens.access_token;
}

export async function listExternalCalendarEvents(
  userId: EntityIdInput,
  fromIso: string,
  toIso: string,
): Promise<ExternalCalendarEvent[]> {
  const uid = asEntityId(userId);
  const statuses = await listConnectedAccountStatuses(uid);
  const events: ExternalCalendarEvent[] = [];

  if (statuses.find((s) => s.provider === "google_calendar")?.connected) {
    try {
      events.push(...(await fetchGoogleEvents(uid, fromIso, toIso)));
    } catch {
      // Best-effort overlay — native calendar still works if Google fails.
    }
  }
  if (statuses.find((s) => s.provider === "outlook_calendar")?.connected) {
    try {
      events.push(...(await fetchOutlookEvents(uid, fromIso, toIso)));
    } catch {
      // ignore
    }
  }
  return events.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
}

async function fetchGoogleEvents(userId: number, fromIso: string, toIso: string): Promise<ExternalCalendarEvent[]> {
  const token = await getValidAccessToken(userId, "google_calendar");
  const url = new URL("https://www.googleapis.com/calendar/v3/calendars/primary/events");
  url.searchParams.set("timeMin", fromIso);
  url.searchParams.set("timeMax", toIso);
  url.searchParams.set("singleEvents", "true");
  url.searchParams.set("orderBy", "startTime");
  url.searchParams.set("maxResults", "250");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`google ${res.status}`);
  const data = (await res.json()) as {
    items?: Array<{
      id?: string;
      summary?: string;
      htmlLink?: string;
      location?: string;
      start?: { dateTime?: string; date?: string };
      end?: { dateTime?: string; date?: string };
    }>;
  };
  return (data.items ?? [])
    .filter((item) => item.id && (item.start?.dateTime || item.start?.date))
    .map((item) => {
      const allDay = Boolean(item.start?.date && !item.start?.dateTime);
      const startsAt = item.start?.dateTime ?? `${item.start!.date}T00:00:00.000Z`;
      const endsAt = item.end?.dateTime ?? `${item.end?.date ?? item.start!.date}T00:00:00.000Z`;
      return {
        id: `google:${item.id}`,
        provider: "google_calendar" as const,
        title: item.summary || "(No title)",
        startsAt: new Date(startsAt).toISOString(),
        endsAt: new Date(endsAt).toISOString(),
        allDay,
        location: item.location ?? null,
        htmlLink: item.htmlLink ?? null,
      };
    });
}

async function fetchOutlookEvents(userId: number, fromIso: string, toIso: string): Promise<ExternalCalendarEvent[]> {
  const token = await getValidAccessToken(userId, "outlook_calendar");
  const url = new URL("https://graph.microsoft.com/v1.0/me/calendarView");
  url.searchParams.set("startDateTime", fromIso);
  url.searchParams.set("endDateTime", toIso);
  url.searchParams.set("$top", "250");
  url.searchParams.set("$orderby", "start/dateTime");
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Prefer: 'outlook.timezone="UTC"',
    },
  });
  if (!res.ok) throw new Error(`outlook ${res.status}`);
  const data = (await res.json()) as {
    value?: Array<{
      id?: string;
      subject?: string;
      webLink?: string;
      location?: { displayName?: string };
      isAllDay?: boolean;
      start?: { dateTime?: string; timeZone?: string };
      end?: { dateTime?: string; timeZone?: string };
    }>;
  };
  return (data.value ?? [])
    .filter((item) => item.id && item.start?.dateTime)
    .map((item) => ({
      id: `outlook:${item.id}`,
      provider: "outlook_calendar" as const,
      title: item.subject || "(No title)",
      startsAt: new Date(/Z$/i.test(item.start!.dateTime!) ? item.start!.dateTime! : `${item.start!.dateTime}Z`).toISOString(),
      endsAt: new Date(/Z$/i.test(item.end!.dateTime!) ? item.end!.dateTime! : `${item.end!.dateTime}Z`).toISOString(),
      allDay: Boolean(item.isAllDay),
      location: item.location?.displayName ?? null,
      htmlLink: item.webLink ?? null,
    }));
}

export async function createZoomMeeting(
  userId: EntityIdInput,
  input: CreateZoomMeetingRequest,
): Promise<{ joinUrl: string; meetingId: string | number; startUrl?: string }> {
  const uid = asEntityId(userId);
  const token = await getValidAccessToken(uid, "zoom");
  const start = new Date(input.startsAt);
  const res = await fetch("https://api.zoom.us/v2/users/me/meetings", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      topic: input.topic,
      type: 2,
      start_time: start.toISOString().replace(/\.\d{3}Z$/, "Z"),
      duration: input.durationMinutes,
      timezone: input.timezone || "UTC",
      settings: {
        join_before_host: true,
        waiting_room: false,
      },
    }),
  });
  if (!res.ok) {
    const detail = await res.text().catch(() => "");
    throw new BadRequestException(`Zoom meeting create failed (${res.status}): ${detail.slice(0, 200)}`);
  }
  const data = (await res.json()) as { id?: number | string; join_url?: string; start_url?: string };
  if (!data.join_url) throw new BadRequestException("Zoom did not return a join URL");
  return { joinUrl: data.join_url, meetingId: data.id ?? "", startUrl: data.start_url };
}

export function oauthCallbackRedirect(returnTo: string, ok: boolean, message?: string): string {
  const target = new URL(sanitizeReturnTo(returnTo), publicWebUrl());
  target.searchParams.set("integration", ok ? "connected" : "error");
  if (message) target.searchParams.set("integrationMessage", message.slice(0, 180));
  return target.toString();
}

export { isOAuthProvider, publicWebUrl };
