import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash, randomInt } from 'crypto';
import { IntegrationConfigService } from '../integrations/integration-config.service';
import { OtpDeliveryProvider } from './otp.types';

type OtpChallenge = {
  hash: string;
  expiresAt: number;
  attempts: number;
};

type RuntimeOtpConfig = {
  provider?: OtpDeliveryProvider;
  enabledProviders?: OtpDeliveryProvider[];
  providerPriority?: OtpDeliveryProvider[];
  codeLength?: number;
  ttlSeconds?: number;
  maxAttempts?: number;
  exposeDevCode?: boolean;
  termiiBaseUrl?: string;
  termiiApiKey?: string;
  termiiSenderId?: string;
  termiiChannel?: string;
  africasTalkingBaseUrl?: string;
  africasTalkingUsername?: string;
  africasTalkingApiKey?: string;
  africasTalkingSenderId?: string;
  customHttpUrl?: string;
  customHttpMethod?: string;
  customHttpHeadersJson?: string;
  customHttpBodyTemplate?: string;
};

export type UpdateOtpRuntimeConfig = RuntimeOtpConfig;

@Injectable()
export class OtpService {
  private readonly challenges = new Map<string, OtpChallenge>();
  private readonly runtime: RuntimeOtpConfig = {};

  constructor(
    private readonly config: ConfigService,
    private readonly integrations: IntegrationConfigService
  ) {}

  async sendOtp(phone: string, name?: string) {
    const providers = this.providerOrder();
    if (!providers.length) {
      throw new BadRequestException('At least one OTP provider must be enabled.');
    }

    const code = providers[0] === OtpDeliveryProvider.DEV ? this.devCode() : this.generateCode();
    const ttlSeconds = this.ttlSeconds();
    const message = this.messageFor(code, name);
    const failures: string[] = [];

    for (const provider of providers) {
      try {
        await this.sendWithProvider(provider, phone, code, message);
        this.challenges.set(phone, {
          hash: this.hash(phone, code),
          expiresAt: Date.now() + ttlSeconds * 1000,
          attempts: 0
        });

        return {
          message: 'OTP sent',
          provider,
          providerPriority: providers,
          expiresInSeconds: ttlSeconds,
          ...(this.shouldExposeDevCode(provider) ? { devOtp: code } : {})
        };
      } catch (error) {
        failures.push(`${provider}: ${this.errorMessage(error)}`);
      }
    }

    throw new BadGatewayException(`All enabled OTP providers failed: ${failures.join('; ')}`);
  }

  verifyOtp(phone: string, otp: string) {
    const normalizedOtp = otp.trim();
    const challenge = this.challenges.get(phone);

    if (challenge) {
      if (Date.now() > challenge.expiresAt) {
        this.challenges.delete(phone);
        return false;
      }

      challenge.attempts += 1;
      if (challenge.attempts > this.maxAttempts()) {
        this.challenges.delete(phone);
        return false;
      }

      const valid = challenge.hash === this.hash(phone, normalizedOtp);
      if (valid) {
        this.challenges.delete(phone);
      }
      return valid;
    }

    return this.enabledProviders().includes(OtpDeliveryProvider.DEV) && normalizedOtp === this.devCode();
  }

  getRuntimeConfig() {
    const provider = this.provider();
    const enabledProviders = this.enabledProviders();
    const providerPriority = this.providerOrder();
    return {
      provider,
      enabledProviders,
      providerPriority,
      availableProviders: Object.values(OtpDeliveryProvider),
      providers: Object.values(OtpDeliveryProvider).map((item) => ({
        provider: item,
        enabled: enabledProviders.includes(item),
        priority: providerPriority.includes(item) ? providerPriority.indexOf(item) + 1 : null,
        configured: this.providerConfigured(item),
        activePrimary: item === provider
      })),
      codeLength: this.codeLength(),
      expiresInSeconds: this.ttlSeconds(),
      maxAttempts: this.maxAttempts(),
      devOtpExposed: this.shouldExposeDevCode(provider),
      termii: {
        configured: this.hasValue(this.termiiApiKey()),
        baseUrl: this.termiiBaseUrl(),
        senderId: this.termiiSenderId(),
        channel: this.termiiChannel()
      },
      africasTalking: {
        configured: this.hasValue(this.africasTalkingApiKey()),
        baseUrl: this.africasTalkingBaseUrl(),
        username: this.africasTalkingUsername(),
        senderId: this.africasTalkingSenderId()
      },
      customHttp: {
        configured: this.hasValue(this.customHttpUrl()),
        url: this.maskUrl(this.customHttpUrl()),
        method: this.customHttpMethod(),
        hasHeaders: this.hasValue(this.customHttpHeadersJson()),
        hasBodyTemplate: this.hasValue(this.customHttpBodyTemplate())
      },
      note: 'Runtime changes are in-memory. Mirror production values into .env before deployment or restart.'
    };
  }

  updateRuntimeConfig(dto: UpdateOtpRuntimeConfig) {
    this.assertProviders(dto.enabledProviders, 'enabledProviders');
    this.assertProviders(dto.providerPriority, 'providerPriority');
    if (dto.enabledProviders && dto.enabledProviders.length === 0) {
      throw new BadRequestException('At least one OTP provider must be enabled.');
    }

    const previousEnabled = this.enabledProviders();
    const previousPriority = this.providerPriority();
    Object.entries(dto).forEach(([key, value]) => {
      if (value !== undefined && value !== null && value !== '') {
        this.runtime[key as keyof RuntimeOtpConfig] = value as never;
      }
    });

    if (dto.provider && !dto.enabledProviders) {
      this.runtime.enabledProviders = Array.from(new Set([dto.provider, ...previousEnabled]));
    }

    if (dto.provider && !dto.providerPriority) {
      this.runtime.providerPriority = [dto.provider, ...previousPriority.filter((item) => item !== dto.provider)];
    }

    return this.getRuntimeConfig();
  }

  private provider() {
    return this.providerOrder()[0] ?? this.configuredPrimaryProvider();
  }

  private configuredPrimaryProvider() {
    const value = this.runtime.provider ?? this.config.get<string>('OTP_DELIVERY_PROVIDER', OtpDeliveryProvider.DEV);
    return this.toProvider(value) ?? OtpDeliveryProvider.DEV;
  }

  private enabledProviders() {
    const providers =
      this.runtime.enabledProviders ??
      this.parseProviders(this.config.get<string>('OTP_ENABLED_PROVIDERS', '')) ??
      [this.configuredPrimaryProvider()];
    return this.uniqueProviders(providers);
  }

  private providerPriority() {
    const providers =
      this.runtime.providerPriority ??
      this.parseProviders(this.config.get<string>('OTP_PROVIDER_PRIORITY', '')) ??
      Object.values(OtpDeliveryProvider);
    return this.uniqueProviders(providers);
  }

  private providerOrder() {
    const enabledProviders = this.enabledProviders();
    const enabled = new Set(enabledProviders);
    const priority = this.providerPriority().filter((item) => enabled.has(item));
    return [...priority, ...enabledProviders.filter((item) => !priority.includes(item))];
  }

  private parseProviders(value?: string) {
    if (!value) {
      return undefined;
    }
    const parsed = value
      .split(',')
      .map((item) => this.toProvider(item.trim()))
      .filter((item): item is OtpDeliveryProvider => Boolean(item));
    return parsed.length ? parsed : undefined;
  }

  private uniqueProviders(providers: OtpDeliveryProvider[]) {
    return Array.from(new Set(providers));
  }

  private toProvider(value?: string) {
    if (Object.values(OtpDeliveryProvider).includes(value as OtpDeliveryProvider)) {
      return value as OtpDeliveryProvider;
    }
    return undefined;
  }

  private assertProviders(providers: OtpDeliveryProvider[] | undefined, field: string) {
    if (!providers) {
      return;
    }
    const invalid = providers.filter((item) => !this.toProvider(item));
    if (invalid.length) {
      throw new BadRequestException(`${field} contains unsupported OTP providers: ${invalid.join(', ')}`);
    }
  }

  private ttlSeconds() {
    return this.positiveInt(this.runtime.ttlSeconds, this.config.get<number>('OTP_CODE_TTL_SECONDS', 300), 300);
  }

  private codeLength() {
    const length = this.positiveInt(this.runtime.codeLength, this.config.get<number>('OTP_CODE_LENGTH', 6), 6);
    return Math.min(Math.max(length, 4), 8);
  }

  private maxAttempts() {
    return this.positiveInt(this.runtime.maxAttempts, this.config.get<number>('OTP_MAX_ATTEMPTS', 5), 5);
  }

  private positiveInt(runtimeValue: number | undefined, configValue: number | string | undefined, fallback: number) {
    const parsed = Number(runtimeValue ?? configValue ?? fallback);
    return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
  }

  private devCode() {
    return this.config.get<string>('OTP_DEV_CODE', '123456');
  }

  private shouldExposeDevCode(provider: OtpDeliveryProvider) {
    const value = this.runtime.exposeDevCode ?? this.config.get<string>('OTP_EXPOSE_DEV_CODE', 'true');
    return provider === OtpDeliveryProvider.DEV || value === true || String(value).toLowerCase() === 'true';
  }

  private generateCode() {
    const length = this.codeLength();
    const min = 10 ** (length - 1);
    const max = 10 ** length - 1;
    return String(randomInt(min, max + 1));
  }

  private hash(phone: string, code: string) {
    const secret = this.config.get<string>('JWT_ACCESS_SECRET', 'otp-secret');
    return createHash('sha256').update(`${phone}:${code}:${secret}`).digest('hex');
  }

  private messageFor(code: string, name?: string) {
    const prefix = name ? `Hi ${name}, ` : '';
    return `${prefix}your Smart Estate login code is ${code}. It expires in ${Math.ceil(this.ttlSeconds() / 60)} minutes.`;
  }

  private async sendWithProvider(provider: OtpDeliveryProvider, phone: string, code: string, message: string) {
    if (provider === OtpDeliveryProvider.DEV) {
      return;
    }
    if (provider === OtpDeliveryProvider.TERMII) {
      return this.sendViaTermii(phone, message);
    }
    if (provider === OtpDeliveryProvider.AFRICAS_TALKING) {
      return this.sendViaAfricasTalking(phone, message);
    }
    return this.sendViaCustomHttp(phone, code, message);
  }

  private async sendViaTermii(phone: string, message: string) {
    const apiKey = this.termiiApiKey();
    const senderId = this.termiiSenderId();
    if (!apiKey || !senderId) {
      throw new BadRequestException('Termii OTP is not configured. Set TERMII_API_KEY and TERMII_SENDER_ID.');
    }

    await this.postJson(`${this.termiiBaseUrl().replace(/\/$/, '')}/api/sms/send`, {
      api_key: apiKey,
      to: phone.replace(/[^\d]/g, ''),
      from: senderId,
      sms: message,
      type: 'plain',
      channel: this.termiiChannel()
    });
  }

  private async sendViaAfricasTalking(phone: string, message: string) {
    const username = this.africasTalkingUsername();
    const apiKey = this.africasTalkingApiKey();
    if (!username || !apiKey) {
      throw new BadRequestException(
        "Africa's Talking OTP is not configured. Set AFRICASTALKING_USERNAME and AFRICASTALKING_API_KEY."
      );
    }

    const body = new URLSearchParams({
      username,
      to: phone,
      message
    });
    const senderId = this.africasTalkingSenderId();
    if (senderId) {
      body.set('from', senderId);
    }

    await this.fetchOrThrow(this.africasTalkingBaseUrl(), {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
        apiKey
      },
      body
    });
  }

  private async sendViaCustomHttp(phone: string, code: string, message: string) {
    const url = this.customHttpUrl();
    if (!url) {
      throw new BadRequestException('Custom OTP HTTP provider is not configured. Set CUSTOM_OTP_URL.');
    }

    const headers = {
      'Content-Type': 'application/json',
      ...this.parseHeaders(this.customHttpHeadersJson())
    };
    const body = this.renderCustomBody(phone, code, message);
    await this.fetchOrThrow(url, {
      method: this.customHttpMethod(),
      headers,
      body: JSON.stringify(body)
    });
  }

  private async postJson(url: string, body: Record<string, unknown>) {
    await this.fetchOrThrow(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify(body)
    });
  }

  private async fetchOrThrow(url: string, init: RequestInit) {
    try {
      const response = await fetch(url, init);
      if (!response.ok) {
        const text = await response.text();
        throw new BadGatewayException(`OTP provider rejected the request: ${response.status} ${text.slice(0, 250)}`);
      }
    } catch (error) {
      if (error instanceof BadGatewayException) {
        throw error;
      }
      throw new BadGatewayException(`OTP provider request failed: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  private renderCustomBody(phone: string, code: string, message: string) {
    const template = this.customHttpBodyTemplate();
    if (!template) {
      return { to: phone, code, message };
    }

    const rendered = this.renderTemplate(template, phone, code, message);
    try {
      return JSON.parse(rendered) as Record<string, unknown>;
    } catch {
      throw new BadRequestException('CUSTOM_OTP_BODY_TEMPLATE must render to valid JSON.');
    }
  }

  private parseHeaders(value: string) {
    if (!value) {
      return {};
    }
    try {
      const parsed = JSON.parse(value) as Record<string, string>;
      return Object.fromEntries(Object.entries(parsed).map(([key, headerValue]) => [key, String(headerValue)]));
    } catch {
      throw new BadRequestException('CUSTOM_OTP_HEADERS_JSON must be valid JSON.');
    }
  }

  private renderTemplate(template: string, phone: string, code: string, message: string) {
    return template
      .replaceAll('{{phone}}', phone)
      .replaceAll('{{otp}}', code)
      .replaceAll('{{code}}', code)
      .replaceAll('{{message}}', message);
  }

  private termiiBaseUrl() {
    return this.runtime.termiiBaseUrl ?? this.integrations.getValue('TERMII', 'baseUrl', 'TERMII_BASE_URL', 'https://api.ng.termii.com');
  }

  private termiiApiKey() {
    return this.runtime.termiiApiKey ?? this.integrations.getValue('TERMII', 'apiKey', 'TERMII_API_KEY', '');
  }

  private termiiSenderId() {
    return this.runtime.termiiSenderId ?? this.integrations.getValue('TERMII', 'senderId', 'TERMII_SENDER_ID', '');
  }

  private termiiChannel() {
    return this.runtime.termiiChannel ?? this.integrations.getValue('TERMII', 'channel', 'TERMII_CHANNEL', 'dnd');
  }

  private africasTalkingBaseUrl() {
    return (
      this.runtime.africasTalkingBaseUrl ??
      this.integrations.getValue('AFRICAS_TALKING', 'baseUrl', 'AFRICASTALKING_BASE_URL', 'https://api.africastalking.com/version1/messaging')
    );
  }

  private africasTalkingUsername() {
    return this.runtime.africasTalkingUsername ?? this.integrations.getValue('AFRICAS_TALKING', 'username', 'AFRICASTALKING_USERNAME', '');
  }

  private africasTalkingApiKey() {
    return this.runtime.africasTalkingApiKey ?? this.integrations.getValue('AFRICAS_TALKING', 'apiKey', 'AFRICASTALKING_API_KEY', '');
  }

  private africasTalkingSenderId() {
    return this.runtime.africasTalkingSenderId ?? this.integrations.getValue('AFRICAS_TALKING', 'senderId', 'AFRICASTALKING_SENDER_ID', '');
  }

  private customHttpUrl() {
    return this.runtime.customHttpUrl ?? this.integrations.getValue('CUSTOM_HTTP', 'url', 'CUSTOM_OTP_URL', '');
  }

  private customHttpMethod() {
    return (this.runtime.customHttpMethod ?? this.integrations.getValue('CUSTOM_HTTP', 'method', 'CUSTOM_OTP_METHOD', 'POST')).toUpperCase();
  }

  private customHttpHeadersJson() {
    return this.runtime.customHttpHeadersJson ?? this.integrations.getValue('CUSTOM_HTTP', 'headersJson', 'CUSTOM_OTP_HEADERS_JSON', '');
  }

  private customHttpBodyTemplate() {
    return this.runtime.customHttpBodyTemplate ?? this.integrations.getValue('CUSTOM_HTTP', 'bodyTemplate', 'CUSTOM_OTP_BODY_TEMPLATE', '');
  }

  private hasValue(value: string) {
    return Boolean(value && !value.includes('REPLACE_ME'));
  }

  private providerConfigured(provider: OtpDeliveryProvider) {
    if (provider === OtpDeliveryProvider.DEV) {
      return true;
    }
    if (provider === OtpDeliveryProvider.TERMII) {
      return this.hasValue(this.termiiApiKey()) && this.hasValue(this.termiiSenderId());
    }
    if (provider === OtpDeliveryProvider.AFRICAS_TALKING) {
      return this.hasValue(this.africasTalkingUsername()) && this.hasValue(this.africasTalkingApiKey());
    }
    return this.hasValue(this.customHttpUrl());
  }

  private errorMessage(error: unknown) {
    return error instanceof Error ? error.message : String(error);
  }

  private maskUrl(value: string) {
    if (!value) {
      return '';
    }
    try {
      const url = new URL(value);
      return `${url.origin}${url.pathname}`;
    } catch {
      return value;
    }
  }
}
