import { execFile } from 'child_process';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

import type {
  InboundMessage,
  OutboundMessage,
  ChannelAdapter,
  ChannelStatus,
} from '../types.js';
import {
  allowTrustedOrPairedSender,
  buildResetInboundMessage,
  isAllowedByAllowlist,
  normalizeResetCommandText,
  shouldIgnoreForMissingMention,
  splitMessage,
} from '../utils.js';
import type { PairingManager } from '../pairing.js';

export interface SignalAdapterConfig {
  /** Primary Signal account identifier used by signal-cli (-u). */
  account: string;
  /** Path to signal-cli binary. */
  signalCliPath?: string;
  /** Allowed direct-message sender numbers. Empty/undefined = allow all DMs. */
  allowedNumbers?: string[];
  /** Allowed group IDs. Empty/undefined = no groups allowed. */
  allowedGroupIds?: string[];
  /** Require mention in group chats (default: true). */
  requireMention?: boolean;
  /** Mention token used for group mention detection (default: flynn). */
  mentionName?: string;
  /** Poll interval for receive loop (default: 5000ms). */
  pollIntervalMs?: number;
  /** Timeout for send/receive CLI calls (default: 15000ms). */
  sendTimeoutMs?: number;
  /** Optional pairing manager for DM pairing codes. */
  pairingManager?: PairingManager;
}

interface SignalEnvelope {
  envelope?: {
    source?: string;
    sourceName?: string;
    timestamp?: number;
    dataMessage?: {
      message?: string;
      body?: string;
      groupInfo?: { groupId?: string };
      groupId?: string;
    };
  };
}

const MAX_MESSAGE_LENGTH = 3500;
const DEFAULT_POLL_INTERVAL_MS = 5000;
const DEFAULT_TIMEOUT_MS = 15000;

export class SignalAdapter implements ChannelAdapter {
  readonly name = 'signal';

  private _status: ChannelStatus = 'disconnected';
  private messageHandler?: (msg: InboundMessage) => void;
  private readonly config: SignalAdapterConfig;
  private pollTimer: NodeJS.Timeout | null = null;
  private polling = false;
  private attachmentTempCounter = 0;

  get status(): ChannelStatus {
    return this._status;
  }

  constructor(config: SignalAdapterConfig) {
    this.config = config;
  }

  onMessage(handler: (msg: InboundMessage) => void): void {
    this.messageHandler = handler;
  }

  async connect(): Promise<void> {
    this._status = 'connecting';
    try {
      await this.execSignal(['--version']);
      this._status = 'connected';
      const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
      this.pollTimer = setInterval(() => {
        void this.pollOnce();
      }, interval);
      void this.pollOnce();
      console.log(`Signal adapter connected (${this.config.account})`);
    } catch (error) {
      this._status = 'error';
      throw error;
    }
  }

  async disconnect(): Promise<void> {
    if (this.pollTimer) {
      clearInterval(this.pollTimer);
      this.pollTimer = null;
    }
    this._status = 'disconnected';
  }

  async send(peerId: string, message: OutboundMessage): Promise<void> {
    if (this._status !== 'connected') {
      throw new Error('Signal adapter not connected');
    }

    const text = message.text.trim();
    if (text.length > 0) {
      const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text];
      for (const chunk of chunks) {
        await this.sendText(peerId, chunk);
      }
    }

    if (message.attachments && message.attachments.length > 0) {
      for (const a of message.attachments) {
        if (a.url) {
          const line = a.filename ? `${a.filename}: ${a.url}` : a.url;
          await this.sendText(peerId, line);
        } else if (a.data) {
          await this.sendBinaryAttachment(peerId, a.data, a.filename, a.mimeType);
        }
      }
    }
  }

  private async sendText(peerId: string, text: string): Promise<void> {
    if (!text.trim()) {
      return;
    }
    const args = ['-u', this.config.account, 'send', '-m', text];
    const groupId = this.extractGroupId(peerId);
    if (groupId) {
      args.push('-g', groupId);
    } else {
      args.push(peerId);
    }
    await this.execSignal(args);
  }

  private async sendBinaryAttachment(
    peerId: string,
    base64Data: string,
    filename?: string,
    mimeType?: string,
  ): Promise<void> {
    const tempDir = await mkdtemp(join(tmpdir(), 'flynn-signal-'));
    const ext = extensionFromMimeType(mimeType);
    const safeName = sanitizeFilename(filename) || `attachment${ext}`;
    const tempPath = join(tempDir, `${this.attachmentTempCounter++}-${safeName}`);
    try {
      await writeFile(tempPath, Buffer.from(base64Data, 'base64'));
      const args = ['-u', this.config.account, 'send', '--attachment', tempPath];
      const groupId = this.extractGroupId(peerId);
      if (groupId) {
        args.push('-g', groupId);
      } else {
        args.push(peerId);
      }
      await this.execSignal(args);
    } finally {
      await rm(tempDir, { recursive: true, force: true });
    }
  }

  private async pollOnce(): Promise<void> {
    if (this.polling || !this.messageHandler || this._status !== 'connected') {
      return;
    }

    this.polling = true;
    try {
      const output = await this.execSignal([
        '-u',
        this.config.account,
        '-o',
        'json',
        'receive',
        '--timeout',
        '1',
      ]);
      await this.processReceiveOutput(output);
    } catch (error) {
      if (this._status === 'connected') {
        const msg = error instanceof Error ? error.message : String(error);
        console.warn(`Signal receive failed: ${msg}`);
      }
    } finally {
      this.polling = false;
    }
  }

  private async processReceiveOutput(output: string): Promise<void> {
    if (!this.messageHandler) {
      return;
    }
    const trimmed = output.trim();
    if (!trimmed) {
      return;
    }

    const payloads: unknown[] = [];
    if (trimmed.startsWith('[')) {
      try {
        const parsed = JSON.parse(trimmed);
        if (Array.isArray(parsed)) {
          payloads.push(...parsed);
        }
      } catch {
        // Fall through to line-based parsing.
      }
    }

    if (payloads.length === 0) {
      for (const line of trimmed.split('\n')) {
        const text = line.trim();
        if (!text) {
          continue;
        }
        try {
          payloads.push(JSON.parse(text));
        } catch {
          // Ignore non-JSON lines from signal-cli output.
        }
      }
    }

    for (const payload of payloads) {
      const inbound = await this.toInboundMessage(payload);
      if (inbound) {
        this.messageHandler(inbound);
      }
    }
  }

  private async toInboundMessage(payload: unknown): Promise<InboundMessage | null> {
    const data = payload as SignalEnvelope & Record<string, unknown>;
    const envelope = (data.envelope ?? data) as Record<string, unknown>;
    const dataMessage = (envelope.dataMessage ?? data.dataMessage) as Record<string, unknown> | undefined;

    const rawText = String(dataMessage?.message ?? dataMessage?.body ?? '').trim();
    if (!rawText) {
      return null;
    }

    const source = this.normalizeNumber(String(envelope.source ?? ''));
    const sourceName = typeof envelope.sourceName === 'string' ? envelope.sourceName : undefined;
    const groupIdRaw = dataMessage?.groupInfo && typeof dataMessage.groupInfo === 'object'
      ? String((dataMessage.groupInfo as { groupId?: unknown }).groupId ?? '')
      : String(dataMessage?.groupId ?? '');
    const groupId = groupIdRaw.trim();
    const isGroup = groupId.length > 0;

    let text = rawText;
    let senderId = source;

    if (isGroup) {
      if (!this.config.allowedGroupIds || this.config.allowedGroupIds.length === 0) {
        return null;
      }
      if (!this.config.allowedGroupIds.includes(groupId)) {
        return null;
      }

      const mentionName = (this.config.mentionName ?? 'flynn').trim();
      const mentionPattern = mentionName.length > 0
        ? new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i')
        : null;
      const mentionsBot = mentionPattern ? mentionPattern.test(text) : false;
      if (shouldIgnoreForMissingMention({
        requireMention: this.config.requireMention,
        defaultRequireMention: true,
        mentionsBot,
      })) {
        return null;
      }
      if (mentionPattern) {
        text = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim();
      }
      senderId = `group:${groupId}`;
    } else {
      if (!source) {
        return null;
      }
      const trusted = isAllowedByAllowlist(source, this.config.allowedNumbers);
      const allowed = await allowTrustedOrPairedSender({
        pairingManager: this.config.pairingManager,
        channel: 'signal',
        senderId: source,
        text,
        isTrusted: trusted,
      });
      if (!allowed) {
        return null;
      }
    }

    const normalizedText = normalizeResetCommandText(text);
    const timestamp = typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now();
    const id = `${senderId}:${timestamp}`;
    if (normalizedText === '!reset') {
      return buildResetInboundMessage({
        id,
        channel: 'signal',
        senderId,
        senderName: sourceName,
        timestamp,
      });
    }

    return {
      id,
      channel: 'signal',
      senderId,
      senderName: sourceName,
      text: normalizedText,
      timestamp,
      metadata: {
        source,
        groupId: groupId || undefined,
      },
    };
  }

  private execSignal(args: string[]): Promise<string> {
    const command = this.config.signalCliPath ?? 'signal-cli';
    const timeout = this.config.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS;
    return new Promise((resolve, reject) => {
      execFile(command, args, { timeout }, (error, stdout, stderr) => {
        if (error) {
          reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`));
          return;
        }
        resolve(stdout.trim());
      });
    });
  }

  private extractGroupId(peerId: string): string | null {
    if (!peerId.startsWith('group:')) {
      return null;
    }
    const groupId = peerId.slice('group:'.length).trim();
    return groupId.length > 0 ? groupId : null;
  }

  private normalizeNumber(value: string): string {
    return value.trim().replace(/[^\d+]/g, '');
  }
}

function extensionFromMimeType(mimeType?: string): string {
  if (!mimeType) {
    return '.bin';
  }
  if (mimeType.startsWith('image/jpeg')) {
    return '.jpg';
  }
  if (mimeType.startsWith('image/png')) {
    return '.png';
  }
  if (mimeType.startsWith('image/gif')) {
    return '.gif';
  }
  if (mimeType.startsWith('application/pdf')) {
    return '.pdf';
  }
  const [, subtype] = mimeType.split('/');
  if (!subtype) {
    return '.bin';
  }
  return `.${subtype.split('+')[0]}`;
}

function sanitizeFilename(filename?: string): string {
  if (!filename) {
    return '';
  }
  return filename.replace(/[^a-zA-Z0-9._-]/g, '_');
}

function escapeRegex(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
