import {
  Injectable,
  Logger,
  ServiceUnavailableException,
  UnauthorizedException,
} from "@nestjs/common";
import {
  AccessToken,
  EgressClient,
  EncodedFileOutput,
  EncodedFileType,
  RoomServiceClient,
  S3Upload,
  SipClient,
  WebhookReceiver,
} from "livekit-server-sdk";
import * as messaging from "@slackwsh/messaging";
import type { Call } from "@slackwsh/contracts";
import { ObjectStorageService } from "../messages/object-storage.service";

export interface LiveKitConfig {
  url: string;
  publicUrl: string;
  apiKey: string;
  apiSecret: string;
}

@Injectable()
export class LiveKitService {
  private readonly logger = new Logger(LiveKitService.name);

  constructor(private readonly storage: ObjectStorageService) {}

  config(): LiveKitConfig | null {
    const url = (process.env.LIVEKIT_URL ?? "").replace(/\/$/, "");
    const apiKey = process.env.LIVEKIT_API_KEY ?? "";
    const apiSecret = process.env.LIVEKIT_API_SECRET ?? "";
    if (!url || !apiKey || !apiSecret) return null;
    const publicUrl = (process.env.LIVEKIT_PUBLIC_URL ?? url.replace(/^http/, "ws")).replace(/\/$/, "");
    return { url, publicUrl, apiKey, apiSecret };
  }

  /** `--dev` is down when Docker isn't running; fail fast so the client meshes. */
  private async isReachable(cfg: LiveKitConfig): Promise<boolean> {
    try {
      const ac = new AbortController();
      const timer = setTimeout(() => ac.abort(), 1200);
      const res = await fetch(cfg.url, { method: "GET", signal: ac.signal });
      clearTimeout(timer);
      return res.status < 500;
    } catch {
      return false;
    }
  }

  async mintParticipantToken(call: Call, userId: number, name?: string): Promise<{
    configured: boolean;
    url: string | null;
    token: string | null;
    roomName: string | null;
    identity: string | null;
  }> {
    const cfg = this.config();
    if (!cfg) {
      return { configured: false, url: null, token: null, roomName: null, identity: null };
    }
    if (!(await this.isReachable(cfg))) {
      this.logger.warn(`LiveKit not reachable at ${cfg.url}; client will use mesh`);
      return { configured: false, url: null, token: null, roomName: null, identity: null };
    }
    const roomName = messaging.livekitRoomName(Number(call.workspaceId), Number(call.id));
    const identity = String(userId);
    const at = new AccessToken(cfg.apiKey, cfg.apiSecret, {
      identity,
      name: name || identity,
      ttl: "6h",
    });
    at.addGrant({
      roomJoin: true,
      room: roomName,
      canPublish: true,
      canSubscribe: true,
      canPublishData: true,
      canUpdateOwnMetadata: true,
      roomRecord: true,
    });
    const token = await at.toJwt();
    return { configured: true, url: cfg.publicUrl, token, roomName, identity };
  }

  async startRecording(call: Call, actorUserId: number): Promise<Call> {
    const cfg = this.config();
    if (!cfg) throw new ServiceUnavailableException("LiveKit is not configured");
    const roomName = messaging.livekitRoomName(Number(call.workspaceId), Number(call.id));
    const objectKey = `call-recordings/${call.workspaceId}/${call.id}.mp4`;
    const s3 = this.storage.s3Config();
    const egress = new EgressClient(cfg.url, cfg.apiKey, cfg.apiSecret);
    const file = new EncodedFileOutput({
      fileType: EncodedFileType.MP4,
      filepath: objectKey,
      disableManifest: true,
      ...(s3.accessKey && s3.secret
        ? {
            s3: new S3Upload({
              accessKey: s3.accessKey,
              secret: s3.secret,
              bucket: s3.bucket,
              region: s3.region,
              endpoint: s3.endpoint,
              forcePathStyle: Boolean(s3.endpoint),
            }),
          }
        : {}),
    });
    try {
      const info = await egress.startRoomCompositeEgress(roomName, { file });
      return messaging.setCallRecording(call.workspaceId, actorUserId, call.id, {
        recordingStatus: "recording",
        recordingEgressId: info.egressId,
        recordingObjectKey: objectKey,
      });
    } catch (err) {
      this.logger.warn(`egress start failed: ${String(err)}`);
      throw new ServiceUnavailableException("recording requires a running LiveKit egress worker");
    }
  }

  async stopRecording(call: Call, actorUserId: number): Promise<Call> {
    const cfg = this.config();
    if (!cfg) throw new ServiceUnavailableException("LiveKit is not configured");
    if (!call.recordingStatus || call.recordingStatus !== "recording") return call;
    const roomName = messaging.livekitRoomName(Number(call.workspaceId), Number(call.id));
    const egress = new EgressClient(cfg.url, cfg.apiKey, cfg.apiSecret);
    try {
      const active = await egress.listEgress({ roomName });
      for (const info of active) {
        if (Number(info.status) <= 1) await egress.stopEgress(info.egressId);
      }
    } catch (err) {
      this.logger.warn(`egress stop failed: ${String(err)}`);
    }
    return messaging.setCallRecording(call.workspaceId, actorUserId, call.id, { recordingStatus: "processing" });
  }

  async dialPstn(call: Call, actorUserId: number, e164: string): Promise<Call> {
    const cfg = this.config();
    const trunkId = process.env.LIVEKIT_SIP_TRUNK_ID ?? "";
    if (!cfg || !trunkId) throw new ServiceUnavailableException("PSTN is not configured (LIVEKIT_SIP_TRUNK_ID)");
    const roomName = messaging.livekitRoomName(Number(call.workspaceId), Number(call.id));
    const sip = new SipClient(cfg.url, cfg.apiKey, cfg.apiSecret);
    try {
      await sip.createSipParticipant(trunkId, e164, roomName, {
        participantIdentity: `sip:${e164}`,
        participantName: e164,
        playDialtone: true,
      });
    } catch (err) {
      this.logger.warn(`SIP dial failed: ${String(err)}`);
      throw new ServiceUnavailableException("could not place the PSTN call");
    }
    void actorUserId;
    return call;
  }

  async handleWebhook(rawBody: string, authorization: string | undefined): Promise<void> {
    const cfg = this.config();
    if (!cfg) return;
    const receiver = new WebhookReceiver(cfg.apiKey, cfg.apiSecret);
    let event: { event?: string; room?: { name?: string }; egressInfo?: { egressId?: string; fileResults?: Array<{ filename?: string; location?: string }> } };
    try {
      event = await receiver.receive(rawBody, authorization ?? "", false);
    } catch (err) {
      if (process.env.NODE_ENV === "production") {
        throw new UnauthorizedException("invalid LiveKit webhook");
      }
      this.logger.warn(`LiveKit webhook auth skipped in non-production: ${String(err)}`);
      event = JSON.parse(rawBody || "{}");
    }

    const roomName = event.room?.name ?? (event.egressInfo as { roomName?: string } | undefined)?.roomName ?? "";
    const parsed = parseLivekitRoom(roomName);
    if (!parsed) return;
    const actorId = parsed.workspaceId;

    if (event.event === "egress_ended") {
      const objectKey = event.egressInfo?.fileResults?.[0]?.filename ?? `call-recordings/${parsed.workspaceId}/${parsed.callId}.mp4`;
      const call = await messaging.setCallRecording(parsed.workspaceId, actorId, parsed.callId, {
        recordingStatus: "ready",
        recordingObjectKey: objectKey,
      });
      void this.transcribe(call, actorId).catch((err) => this.logger.warn(`transcription failed: ${String(err)}`));
    }
  }

  private async transcribe(call: Call, actorUserId: number): Promise<void> {
    const apiKey = process.env.OPENAI_API_KEY;
    const objectKey = call.recordingObjectKey;
    if (!apiKey || !objectKey) return;
    await messaging.setCallTranscript(call.workspaceId, actorUserId, call.id, { transcriptStatus: "processing" });
    try {
      const bytes = await this.storage.getObjectBytes(objectKey);
      const file = new File([bytes], "recording.mp4", { type: "video/mp4" });
      const body = new FormData();
      body.set("model", "whisper-1");
      body.set("file", file);
      const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
        method: "POST",
        headers: { Authorization: `Bearer ${apiKey}` },
        body,
      });
      if (!res.ok) throw new Error(`whisper ${res.status}`);
      const json = (await res.json()) as { text?: string };
      const transcript = json.text?.trim() || "";
      let summary: string | null = null;
      if (transcript) {
        summary = await this.summarise(apiKey, transcript).catch(() => null);
      }
      await messaging.setCallTranscript(call.workspaceId, actorUserId, call.id, {
        transcriptStatus: "ready",
        transcript,
        summary,
      });
    } catch (err) {
      await messaging.setCallTranscript(call.workspaceId, actorUserId, call.id, { transcriptStatus: "failed" });
      throw err;
    }
  }

  private async summarise(apiKey: string, transcript: string): Promise<string | null> {
    const res = 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",
        messages: [
          { role: "system", content: "Summarise this call transcript in 5 short bullet points. No preamble." },
          { role: "user", content: transcript.slice(0, 12_000) },
        ],
        temperature: 0.2,
      }),
    });
    if (!res.ok) return null;
    const json = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
    return json.choices?.[0]?.message?.content?.trim() || null;
  }

  async deleteRoom(roomName: string): Promise<void> {
    const cfg = this.config();
    if (!cfg) return;
    try {
      const rooms = new RoomServiceClient(cfg.url, cfg.apiKey, cfg.apiSecret);
      await rooms.deleteRoom(roomName);
    } catch {
      // Room already gone is the success case.
    }
  }
}

export function parseLivekitRoom(name: string): { workspaceId: number; callId: number } | null {
  const match = /^call-(\d+)-(\d+)$/.exec(name);
  if (!match) return null;
  const workspaceId = Number(match[1]);
  const callId = Number(match[2]);
  if (!Number.isInteger(workspaceId) || !Number.isInteger(callId)) return null;
  return { workspaceId, callId };
}
