import crypto from 'node:crypto';

import type { AppserviceConfig } from './types.js';

export interface SendMessageOptions {
  roomId: string;
  /** Agent slug, e.g. "pi0-web1" -> @agent-pi0-web1:domain */
  agent: string;
  body: string;
  /** Request event id to thread off (m.thread, spec v1.4). */
  threadRoot?: string;
  msgtype?: string;
  /** Extra content keys merged into the message content (e.g. org.uscllc.agent). */
  extraContent?: Record<string, unknown>;
}

export class MatrixApiError extends Error {
  constructor(
    readonly status: number,
    readonly errcode: string | undefined,
    message: string,
  ) {
    super(message);
    this.name = 'MatrixApiError';
  }
}

type FetchLike = typeof fetch;

/**
 * Acts on the homeserver as appservice-namespaced virtual users
 * (Application Service API: as_token auth + user_id impersonation).
 */
export class AppserviceIntent {
  private readonly registered = new Set<string>();
  private readonly joined = new Set<string>();
  private readonly fetchImpl: FetchLike;

  constructor(
    private readonly cfg: AppserviceConfig,
    fetchImpl?: FetchLike,
  ) {
    this.fetchImpl = fetchImpl ?? fetch;
  }

  get userPrefix(): string {
    return this.cfg.userPrefix ?? 'agent-';
  }

  get senderUserId(): string {
    return `@${this.cfg.senderLocalpart ?? 'mosaic-as'}:${this.cfg.domain}`;
  }

  agentLocalpart(agent: string): string {
    const slug = agent.toLowerCase();
    if (!/^[a-z0-9][a-z0-9_.-]*$/.test(slug)) {
      throw new Error(`invalid agent slug: ${agent}`);
    }
    return `${this.userPrefix}${slug}`;
  }

  agentUserId(agent: string): string {
    return `@${this.agentLocalpart(agent)}:${this.cfg.domain}`;
  }

  private async request(
    method: string,
    path: string,
    options: { userId?: string; body?: unknown } = {},
  ): Promise<Record<string, unknown>> {
    const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
    if (options.userId) {
      url.searchParams.set('user_id', options.userId);
    }
    const res = await this.fetchImpl(url, {
      method,
      headers: {
        Authorization: `Bearer ${this.cfg.asToken}`,
        'Content-Type': 'application/json',
      },
      body: options.body === undefined ? undefined : JSON.stringify(options.body),
    });
    const text = await res.text();
    const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
    if (!res.ok) {
      throw new MatrixApiError(
        res.status,
        typeof data.errcode === 'string' ? data.errcode : undefined,
        `${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
      );
    }
    return data;
  }

  /** Register the virtual user if it does not exist yet. Idempotent. */
  async ensureRegistered(agent: string): Promise<string> {
    const localpart = this.agentLocalpart(agent);
    const userId = this.agentUserId(agent);
    if (this.registered.has(userId)) return userId;
    try {
      await this.request('POST', '/_matrix/client/v3/register', {
        body: { type: 'm.login.application_service', username: localpart },
      });
    } catch (err) {
      if (!(err instanceof MatrixApiError && err.errcode === 'M_USER_IN_USE')) {
        throw err;
      }
    }
    this.registered.add(userId);
    return userId;
  }

  /** Join the agent to a room; on invite-only rooms the AS sender invites first. */
  async ensureJoined(roomId: string, agent: string): Promise<void> {
    const userId = await this.ensureRegistered(agent);
    const key = `${userId} ${roomId}`;
    if (this.joined.has(key)) return;
    const room = encodeURIComponent(roomId);
    try {
      await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} });
    } catch (err) {
      if (!(err instanceof MatrixApiError && err.errcode === 'M_FORBIDDEN')) throw err;
      await this.request('POST', `/_matrix/client/v3/rooms/${room}/invite`, {
        userId: this.senderUserId,
        body: { user_id: userId },
      });
      await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} });
    }
    this.joined.add(key);
  }

  /** Send a message AS the agent's virtual user. */
  async sendAsAgent(options: SendMessageOptions): Promise<string | undefined> {
    const userId = this.agentUserId(options.agent);
    await this.ensureJoined(options.roomId, options.agent);
    const content: Record<string, unknown> = {
      msgtype: options.msgtype ?? 'm.text',
      body: options.body,
      ...options.extraContent,
    };
    if (options.threadRoot) {
      content['m.relates_to'] = {
        rel_type: 'm.thread',
        event_id: options.threadRoot,
        is_falling_back: true,
        'm.in_reply_to': { event_id: options.threadRoot },
      };
    }
    const txn = `mosaic-as-${crypto.randomUUID()}`;
    const room = encodeURIComponent(options.roomId);
    const res = await this.request(
      'PUT',
      `/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`,
      { userId, body: content },
    );
    return typeof res.event_id === 'string' ? res.event_id : undefined;
  }

  /** Set the agent's typing indicator in a room. */
  async setTyping(
    roomId: string,
    agent: string,
    typing: boolean,
    timeoutMs = 30000,
  ): Promise<void> {
    const userId = await this.ensureRegistered(agent);
    const room = encodeURIComponent(roomId);
    const user = encodeURIComponent(userId);
    await this.request('PUT', `/_matrix/client/v3/rooms/${room}/typing/${user}`, {
      userId,
      body: typing ? { typing: true, timeout: timeoutMs } : { typing: false },
    });
  }

  /** Create a room as the AS sender: agents get PL 50 by namespace via the
   * sender (PL 100); humans invited at default PL. Optionally link into a
   * space (m.space.child + m.space.parent). Returns the room id. */
  async createRoom(options: {
    name: string;
    alias?: string;
    topic?: string;
    invite?: string[];
    spaceId?: string;
  }): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> {
    const body: Record<string, unknown> = {
      name: options.name,
      preset: 'private_chat',
      invite: options.invite ?? [],
      power_level_content_override: {
        users: { [this.senderUserId]: 100 },
        // state_default 50 stays; the AS sender can grant agents as needed.
      },
    };
    if (options.alias) body.room_alias_name = options.alias;
    if (options.topic) body.topic = options.topic;
    const res = await this.request('POST', '/_matrix/client/v3/createRoom', {
      userId: this.senderUserId,
      body,
    });
    const roomId = res.room_id;
    if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id');
    if (!options.spaceId) {
      return { roomId, spaceLinked: false };
    }
    // Space-link failures must NOT throw: the room already exists, and an
    // exception would hide the room_id (orphaned room, no recovery path).
    const encodedSpaceId = encodeURIComponent(options.spaceId);
    const encodedRoomId = encodeURIComponent(roomId);
    try {
      await this.request(
        'PUT',
        `/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`,
        { userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } },
      );
      await this.request(
        'PUT',
        `/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`,
        { userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } },
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return { roomId, spaceLinked: false, spaceError: message };
    }
    return { roomId, spaceLinked: true };
  }

  /** Set display name for an agent's virtual user. */
  async setDisplayName(agent: string, displayName: string): Promise<void> {
    const userId = await this.ensureRegistered(agent);
    const user = encodeURIComponent(userId);
    await this.request('PUT', `/_matrix/client/v3/profile/${user}/displayname`, {
      userId,
      body: { displayname: displayName },
    });
  }

  /** Read an account_data object on the AS sender user. Returns null when the
   * key has never been written (M_NOT_FOUND), so callers can treat that as an
   * empty store; any other error propagates. */
  async getSenderAccountData(type: string): Promise<Record<string, unknown> | null> {
    const user = encodeURIComponent(this.senderUserId);
    const key = encodeURIComponent(type);
    try {
      return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
        userId: this.senderUserId,
      });
    } catch (err) {
      if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null;
      throw err;
    }
  }

  /** Write an account_data object on the AS sender user. */
  async setSenderAccountData(type: string, content: Record<string, unknown>): Promise<void> {
    const user = encodeURIComponent(this.senderUserId);
    const key = encodeURIComponent(type);
    await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
      userId: this.senderUserId,
      body: content,
    });
  }
}
