import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LinkStatus } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AppException, ErrorCode } from '../../common/errors';
import {
  civilDateInZone,
  isValidIanaTimeZone,
} from '../../common/zoned-civil-time';
import type { VoiceConfig, VoiceProfile } from '../../configs/configurations';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { weekStartForLocale } from './due-date.resolver';
import {
  resolveVoiceIntent,
  type ResolvedExtraction,
} from './extraction.resolver';
import {
  OpenRouterAsrProvider,
  OpenRouterExtractionProvider,
} from './openrouter.provider';
import {
  VoiceProviderError,
  type AsrProvider,
  type ExtractionCatalog,
  type ExtractionProvider,
} from './voice.providers';
import type { ExtractVoiceDto } from './dto/voice.dto';

export type VoiceAvailability = {
  enabled: boolean;
  locales: string[];
  maxRecordingMs: number | null;
};

export type VoiceExtractionResponse = ResolvedExtraction & {
  transcript: string;
};

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly config: ConfigService,
    private readonly treatmentCatalog: TreatmentCatalogService,
    private readonly prosthesisCatalog: ProsthesisCatalogService,
  ) {}

  private get voiceConfig(): VoiceConfig {
    return this.config.get<VoiceConfig>('voice')!;
  }

  /**
   * What the frontend needs to decide whether to render the microphone. v1 is ungated beyond
   * a configured locale profile; the Plan.features design is deferred, not dropped.
   */
  getAvailability(): VoiceAvailability {
    const voice = this.voiceConfig;
    const hasKey = Boolean(voice.openRouter.apiKey);
    const locales = hasKey ? Object.keys(voice.profiles) : [];
    return {
      enabled: locales.length > 0,
      locales,
      maxRecordingMs: voice.maxRecordingMs,
    };
  }

  async extract(
    user: { id: string; organizationId?: string },
    dto: ExtractVoiceDto,
    locale: string,
    signal?: AbortSignal,
  ): Promise<VoiceExtractionResponse> {
    const startedAt = Date.now();
    const organizationId = this.assertOrganization(user);
    await this.assertCanEditTreatment(user.id, organizationId);

    const catalogLocale = normalizeCatalogLocale(locale);
    const profile = this.resolveProfile(catalogLocale);
    this.assertWithinCap(dto.durationMs);

    const timeZone = isValidIanaTimeZone(dto.timeZone) ? dto.timeZone : 'UTC';
    const todayIso = civilDateInZone(new Date(), timeZone);

    const { asr, extraction } = this.buildProviders(profile);

    // Stage 1 — audio never touches disk and is not retained beyond this call.
    let transcript: string;
    let asrCost: number | null = null;
    let asrSeconds: number | null = null;
    try {
      const result = await asr.transcribe(
        { data: dto.audio, format: dto.format },
        catalogLocale,
        signal,
      );
      transcript = result.text;
      asrCost = result.usage.costUsd;
      asrSeconds = result.usage.seconds;
    } catch (error) {
      throw this.toAppException(error, 'asr');
    }

    // durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
    // measurement — a client under-reporting to slip past the cap is caught here, after the
    // ASR spend but before the more expensive extraction call.
    if (asrSeconds != null) {
      this.assertWithinCap(asrSeconds * 1000);
    }

    if (!transcript.trim()) {
      throw new AppException(
        ErrorCode.VOICE_NOTHING_RECOGNIZED,
        HttpStatus.UNPROCESSABLE_ENTITY,
      );
    }

    // Stage 2 — structure it. On failure the transcript still goes back to the client so
    // the words the clinician already paid for are not lost (transcript salvage).
    let resolved: ResolvedExtraction;
    let llmCost: number | null = null;
    try {
      // Inside the try: the transcript is already paid for, so a catalog/DB failure here
      // must still salvage it rather than becoming a generic 500 that throws it away.
      const catalog = await this.buildCatalog(organizationId, catalogLocale);
      const result = await extraction.extract(
        transcript,
        catalog,
        catalogLocale,
        signal,
      );
      llmCost = result.costUsd;
      resolved = resolveVoiceIntent(result.intent, {
        todayIso,
        weekStartJs: weekStartForLocale(catalogLocale),
        treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
        prosthesisTypeCodes: new Set(
          catalog.prosthesisTypes.map((t) => t.code),
        ),
        linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
      });
    } catch (error) {
      throw this.toAppException(error, 'extraction', transcript);
    }

    this.logTelemetry({
      locale: catalogLocale,
      durationMs: dto.durationMs,
      elapsedMs: Date.now() - startedAt,
      asrCost,
      llmCost,
      resolved,
    });

    return { ...resolved, transcript };
  }

  private assertOrganization(user: { organizationId?: string }): string {
    if (!user?.organizationId) {
      throw new AppException(
        ErrorCode.AUTH_ORG_NOT_SELECTED,
        HttpStatus.BAD_REQUEST,
      );
    }
    return user.organizationId;
  }

  private async assertCanEditTreatment(userId: string, organizationId: string) {
    const membership = await this.prisma.membership.findFirst({
      where: {
        userId,
        organizationId,
        OR: [{ isOwner: true }, { isActive: true }],
      },
      include: {
        permissions: { include: { permission: true } },
        organization: { include: { type: true, plan: true } },
      },
    });
    if (!membership) {
      throw new AppException(
        ErrorCode.PERMISSION_NOT_MEMBER,
        HttpStatus.FORBIDDEN,
      );
    }
    if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
      throw new AppException(
        ErrorCode.PERMISSION_EDIT_TREATMENTS,
        HttpStatus.FORBIDDEN,
      );
    }
  }

  private resolveProfile(locale: string): VoiceProfile {
    const voice = this.voiceConfig;
    const profile = voice.profiles[locale];
    if (!profile || !voice.openRouter.apiKey) {
      throw new AppException(
        ErrorCode.VOICE_NOT_AVAILABLE,
        HttpStatus.FORBIDDEN,
      );
    }
    return profile;
  }

  /**
   * The client auto-stops at maxMs and only then measures, so a capped recording always
   * reports slightly over. Without this tolerance every auto-stopped recording — the exact
   * case the cap exists for — would be rejected as too long.
   */
  private static readonly CAP_TOLERANCE_MS = 2_000;

  private assertWithinCap(durationMs: number) {
    const max = this.voiceConfig.maxRecordingMs;
    if (max != null && durationMs > max + VoiceService.CAP_TOLERANCE_MS) {
      throw new AppException(
        ErrorCode.VOICE_CLIP_TOO_LONG,
        HttpStatus.PAYLOAD_TOO_LARGE,
      );
    }
  }

  private buildProviders(profile: VoiceProfile): {
    asr: AsrProvider;
    extraction: ExtractionProvider;
  } {
    const { apiKey, baseUrl } = this.voiceConfig.openRouter;
    const base = { apiKey: apiKey!, baseUrl };
    return {
      asr: new OpenRouterAsrProvider({ ...base, model: profile.asr.model }),
      extraction: new OpenRouterExtractionProvider({
        ...base,
        model: profile.llm.model,
      }),
    };
  }

  /** Codes with labels in the actor's locale, plus the clinic's linked labs. */
  private async buildCatalog(
    organizationId: string,
    locale: string,
  ): Promise<ExtractionCatalog> {
    const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
      this.treatmentCatalog.list(locale, null),
      this.prosthesisCatalog.list(locale),
      this.listLinkedLabs(organizationId),
    ]);

    return {
      treatmentTypes: treatmentTypes
        .filter((entry) => entry.availableInTreatment)
        .map((entry) => ({ code: entry.code, label: entry.label })),
      prosthesisTypes: prosthesisTypes.map((entry) => ({
        code: entry.code,
        label: entry.label,
      })),
      labs,
    };
  }

  private async listLinkedLabs(
    organizationId: string,
  ): Promise<{ id: string; name: string }[]> {
    const [linksA, linksB] = await Promise.all([
      this.prisma.organizationLink.findMany({
        where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
        include: { organizationB: { select: { id: true, name: true } } },
      }),
      this.prisma.organizationLink.findMany({
        where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
        include: { organizationA: { select: { id: true, name: true } } },
      }),
    ]);
    return [
      ...linksA.map((l) => ({
        id: l.organizationB.id,
        name: l.organizationB.name,
      })),
      ...linksB.map((l) => ({
        id: l.organizationA.id,
        name: l.organizationA.name,
      })),
    ];
  }

  private toAppException(
    error: unknown,
    stage: 'asr' | 'extraction',
    transcript?: string,
  ): AppException {
    if (error instanceof Error && error.name === 'AbortError') {
      // The clinician cancelled; not a failure worth a translated message.
      return new AppException(ErrorCode.BAD_REQUEST, HttpStatus.BAD_REQUEST);
    }
    if (error instanceof VoiceProviderError) {
      this.logger.warn(`voice ${stage} failed: ${error.message}`);
    } else {
      this.logger.error(`voice ${stage} failed unexpectedly`, error as Error);
    }
    const code =
      stage === 'asr'
        ? ErrorCode.VOICE_ASR_FAILED
        : ErrorCode.VOICE_EXTRACT_FAILED;
    return new AppException(
      code,
      HttpStatus.BAD_GATEWAY,
      transcript ? { transcript } : undefined,
    );
  }

  /** Structured and patient-free: never the transcript, never audio, never a patient id. */
  private logTelemetry(input: {
    locale: string;
    durationMs: number;
    elapsedMs: number;
    asrCost: number | null;
    llmCost: number | null;
    resolved: ResolvedExtraction;
  }) {
    const { resolved } = input;
    this.logger.log(
      JSON.stringify({
        event: 'voice.extract',
        locale: input.locale,
        clipMs: input.durationMs,
        elapsedMs: input.elapsedMs,
        costUsd: (input.asrCost ?? 0) + (input.llmCost ?? 0),
        resolvedFields: {
          treatmentType: resolved.treatmentType != null,
          teeth: resolved.teeth.length,
          comment: resolved.comment != null,
          prosthesisComplete: resolved.prosthesis?.complete ?? null,
          lab: resolved.labId != null,
          dueDate: resolved.dueDate != null,
        },
        unresolvedCount: resolved.unresolved.length,
      }),
    );
  }
}
