import { randomUUID } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths";
import { loadOutboundMediaFromUrl } from "../runtime-api.js";
import { normalizeZaloReactionIcon } from "./reaction.js";
import type {
  ZaloAuthStatus,
  ZaloEventMessage,
  ZaloGroupContext,
  ZaloGroup,
  ZaloGroupMember,
  ZaloInboundMessage,
  ZaloSendOptions,
  ZaloSendResult,
  ZcaFriend,
  ZcaUserInfo,
} from "./types.js";
import {
  TextStyle,
  type API,
  type Credentials,
  type GroupInfo,
  type LoginQRCallbackEvent,
  type Message,
  type User,
  Zalo,
} from "./zca-client.js";
import { LoginQRCallbackEventType, ThreadType } from "./zca-constants.js";

const API_LOGIN_TIMEOUT_MS = 20_000;
const QR_LOGIN_TTL_MS = 3 * 60_000;
const DEFAULT_QR_START_TIMEOUT_MS = 30_000;
const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000;
const GROUP_INFO_CHUNK_SIZE = 80;
const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000;
const GROUP_CONTEXT_CACHE_MAX_ENTRIES = 500;
const LISTENER_WATCHDOG_INTERVAL_MS = 30_000;
const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000;

const apiByProfile = new Map<string, API>();
const apiInitByProfile = new Map<string, Promise<API>>();

type ActiveZaloQrLogin = {
  id: string;
  profile: string;
  startedAt: number;
  qrDataUrl?: string;
  connected: boolean;
  error?: string;
  abort?: () => void;
  waitPromise: Promise<void>;
};

const activeQrLogins = new Map<string, ActiveZaloQrLogin>();

type ActiveZaloListener = {
  profile: string;
  accountId: string;
  stop: () => void;
};

const activeListeners = new Map<string, ActiveZaloListener>();
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();

type AccountInfoResponse = Awaited<ReturnType<API["fetchAccountInfo"]>>;

type ApiTypingCapability = {
  sendTypingEvent: (
    threadId: string,
    type?: (typeof ThreadType)[keyof typeof ThreadType],
  ) => Promise<unknown>;
};

type StoredZaloCredentials = {
  imei: string;
  cookie: Credentials["cookie"];
  userAgent: string;
  language?: string;
  createdAt: string;
  lastUsedAt?: string;
};

function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
  return resolvePluginStateDir(env, os.homedir);
}

function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
  return path.join(resolveStateDir(env), "credentials", "zalouser");
}

function credentialsFilename(profile: string): string {
  const trimmed = profile.trim().toLowerCase();
  if (!trimmed || trimmed === "default") {
    return "credentials.json";
  }
  return `credentials-${encodeURIComponent(trimmed)}.json`;
}

function resolveCredentialsPath(profile: string, env: NodeJS.ProcessEnv = process.env): string {
  return path.join(resolveCredentialsDir(env), credentialsFilename(profile));
}

function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(label));
    }, timeoutMs);
    void promise
      .then((result) => {
        clearTimeout(timer);
        resolve(result);
      })
      .catch((err) => {
        clearTimeout(timer);
        reject(err);
      });
  });
}

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function normalizeProfile(profile?: string | null): string {
  const trimmed = profile?.trim();
  return trimmed && trimmed.length > 0 ? trimmed : "default";
}

function toErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

function clampTextStyles(
  text: string,
  styles?: ZaloSendOptions["textStyles"],
): ZaloSendOptions["textStyles"] {
  if (!styles || styles.length === 0) {
    return undefined;
  }
  const maxLength = text.length;
  const clamped = styles
    .map((style) => {
      const start = Math.max(0, Math.min(style.start, maxLength));
      const end = Math.min(style.start + style.len, maxLength);
      if (end <= start) {
        return null;
      }
      if (style.st === TextStyle.Indent) {
        return {
          start,
          len: end - start,
          st: style.st,
          indentSize: style.indentSize,
        };
      }
      return {
        start,
        len: end - start,
        st: style.st,
      };
    })
    .filter((style): style is NonNullable<typeof style> => style !== null);
  return clamped.length > 0 ? clamped : undefined;
}

function toNumberId(value: unknown): string {
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(Math.trunc(value));
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (trimmed.length > 0) {
      return trimmed.replace(/_\d+$/, "");
    }
  }
  return "";
}

function toStringValue(value: unknown): string {
  if (typeof value === "string") {
    return value.trim();
  }
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(Math.trunc(value));
  }
  return "";
}

function normalizeAccountInfoUser(info: AccountInfoResponse): User | null {
  if (!info || typeof info !== "object") {
    return null;
  }
  if ("profile" in info) {
    const profile = (info as { profile?: unknown }).profile;
    if (profile && typeof profile === "object") {
      return profile as User;
    }
    return null;
  }
  return info as User;
}

function toInteger(value: unknown, fallback = 0): number {
  if (typeof value === "number" && Number.isFinite(value)) {
    return Math.trunc(value);
  }
  const parsed = Number.parseInt(String(value ?? ""), 10);
  if (!Number.isFinite(parsed)) {
    return fallback;
  }
  return Math.trunc(parsed);
}

function normalizeMessageContent(content: unknown): string {
  if (typeof content === "string") {
    return content;
  }
  if (!content || typeof content !== "object") {
    return "";
  }
  const record = content as Record<string, unknown>;
  const title = typeof record.title === "string" ? record.title.trim() : "";
  const description = typeof record.description === "string" ? record.description.trim() : "";
  const href = typeof record.href === "string" ? record.href.trim() : "";
  const combined = [title, description, href].filter(Boolean).join("\n").trim();
  if (combined) {
    return combined;
  }
  try {
    return JSON.stringify(content);
  } catch {
    return "";
  }
}

function resolveInboundTimestamp(rawTs: unknown): number {
  if (typeof rawTs === "number" && Number.isFinite(rawTs)) {
    return rawTs > 1_000_000_000_000 ? rawTs : rawTs * 1000;
  }
  const parsed = Number.parseInt(String(rawTs ?? ""), 10);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    return Date.now();
  }
  return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
}

function extractMentionIds(rawMentions: unknown): string[] {
  if (!Array.isArray(rawMentions)) {
    return [];
  }
  const sink = new Set<string>();
  for (const entry of rawMentions) {
    if (!entry || typeof entry !== "object") {
      continue;
    }
    const record = entry as { uid?: unknown };
    const id = toNumberId(record.uid);
    if (id) {
      sink.add(id);
    }
  }
  return Array.from(sink);
}

type MentionSpan = {
  start: number;
  end: number;
};

function toNonNegativeInteger(value: unknown): number | null {
  if (typeof value === "number" && Number.isFinite(value)) {
    const normalized = Math.trunc(value);
    return normalized >= 0 ? normalized : null;
  }
  if (typeof value === "string" && value.trim().length > 0) {
    const parsed = Number.parseInt(value.trim(), 10);
    if (Number.isFinite(parsed)) {
      return parsed >= 0 ? parsed : null;
    }
  }
  return null;
}

function extractOwnMentionSpans(
  rawMentions: unknown,
  ownUserId: string,
  contentLength: number,
): MentionSpan[] {
  if (!Array.isArray(rawMentions) || !ownUserId || contentLength <= 0) {
    return [];
  }
  const spans: MentionSpan[] = [];
  for (const entry of rawMentions) {
    if (!entry || typeof entry !== "object") {
      continue;
    }
    const record = entry as {
      uid?: unknown;
      pos?: unknown;
      start?: unknown;
      offset?: unknown;
      len?: unknown;
      length?: unknown;
    };
    const uid = toNumberId(record.uid);
    if (!uid || uid !== ownUserId) {
      continue;
    }
    const startRaw = toNonNegativeInteger(record.pos ?? record.start ?? record.offset);
    const lengthRaw = toNonNegativeInteger(record.len ?? record.length);
    if (startRaw === null || lengthRaw === null || lengthRaw <= 0) {
      continue;
    }
    const start = Math.min(startRaw, contentLength);
    const end = Math.min(start + lengthRaw, contentLength);
    if (end <= start) {
      continue;
    }
    spans.push({ start, end });
  }
  if (spans.length <= 1) {
    return spans;
  }
  spans.sort((a, b) => a.start - b.start);
  const merged: MentionSpan[] = [];
  for (const span of spans) {
    const last = merged[merged.length - 1];
    if (!last || span.start > last.end) {
      merged.push({ ...span });
      continue;
    }
    last.end = Math.max(last.end, span.end);
  }
  return merged;
}

function stripOwnMentionsForCommandBody(
  content: string,
  rawMentions: unknown,
  ownUserId: string,
): string {
  if (!content || !ownUserId) {
    return content;
  }
  const spans = extractOwnMentionSpans(rawMentions, ownUserId, content.length);
  if (spans.length === 0) {
    return stripLeadingAtMentionForCommand(content);
  }
  let cursor = 0;
  let output = "";
  for (const span of spans) {
    if (span.start > cursor) {
      output += content.slice(cursor, span.start);
    }
    cursor = Math.max(cursor, span.end);
  }
  if (cursor < content.length) {
    output += content.slice(cursor);
  }
  return output.replace(/\s+/g, " ").trim();
}

function stripLeadingAtMentionForCommand(content: string): string {
  const fallbackMatch = content.match(/^\s*@[^\s]+(?:\s+|[:,-]\s*)([/!][\s\S]*)$/);
  if (!fallbackMatch) {
    return content;
  }
  return fallbackMatch[1].trim();
}

function resolveGroupNameFromMessageData(data: Record<string, unknown>): string | undefined {
  const candidates = [data.groupName, data.gName, data.idToName, data.threadName, data.roomName];
  for (const candidate of candidates) {
    const value = toStringValue(candidate);
    if (value) {
      return value;
    }
  }
  return undefined;
}

function buildEventMessage(data: Record<string, unknown>): ZaloEventMessage | undefined {
  const msgId = toStringValue(data.msgId);
  const cliMsgId = toStringValue(data.cliMsgId);
  const uidFrom = toStringValue(data.uidFrom);
  const idTo = toStringValue(data.idTo);
  if (!msgId || !cliMsgId || !uidFrom || !idTo) {
    return undefined;
  }
  return {
    msgId,
    cliMsgId,
    uidFrom,
    idTo,
    msgType: toStringValue(data.msgType) || "webchat",
    st: toInteger(data.st, 0),
    at: toInteger(data.at, 0),
    cmd: toInteger(data.cmd, 0),
    ts: toStringValue(data.ts) || Date.now(),
  };
}

function extractSendMessageId(result: unknown): string | undefined {
  if (!result || typeof result !== "object") {
    return undefined;
  }
  const payload = result as {
    msgId?: string | number;
    message?: { msgId?: string | number } | null;
    attachment?: Array<{ msgId?: string | number }>;
  };
  const direct = payload.msgId;
  if (direct !== undefined && direct !== null) {
    return String(direct);
  }
  const primary = payload.message?.msgId;
  if (primary !== undefined && primary !== null) {
    return String(primary);
  }
  const attachmentId = payload.attachment?.[0]?.msgId;
  if (attachmentId !== undefined && attachmentId !== null) {
    return String(attachmentId);
  }
  return undefined;
}

function resolveMediaFileName(params: {
  mediaUrl: string;
  fileName?: string;
  contentType?: string;
  kind?: string;
}): string {
  const explicit = params.fileName?.trim();
  if (explicit) {
    return explicit;
  }

  try {
    const parsed = new URL(params.mediaUrl);
    const fromPath = path.basename(parsed.pathname).trim();
    if (fromPath) {
      return fromPath;
    }
  } catch {
    // ignore URL parse failures
  }

  const ext =
    params.contentType === "image/png"
      ? "png"
      : params.contentType === "image/webp"
        ? "webp"
        : params.contentType === "image/jpeg"
          ? "jpg"
          : params.contentType === "video/mp4"
            ? "mp4"
            : params.contentType === "audio/mpeg"
              ? "mp3"
              : params.contentType === "audio/ogg"
                ? "ogg"
                : params.contentType === "audio/wav"
                  ? "wav"
                  : params.kind === "video"
                    ? "mp4"
                    : params.kind === "audio"
                      ? "mp3"
                      : params.kind === "image"
                        ? "jpg"
                        : "bin";

  return `upload.${ext}`;
}

function resolveUploadedVoiceAsset(
  uploaded: Array<{
    fileType?: string;
    fileUrl?: string;
    fileName?: string;
  }>,
): { fileUrl: string; fileName?: string } | undefined {
  for (const item of uploaded) {
    if (!item || typeof item !== "object") {
      continue;
    }
    const fileType = item.fileType?.toLowerCase();
    const fileUrl = item.fileUrl?.trim();
    if (!fileUrl) {
      continue;
    }
    if (fileType === "others" || fileType === "video") {
      return { fileUrl, fileName: item.fileName?.trim() || undefined };
    }
  }
  return undefined;
}

function buildZaloVoicePlaybackUrl(asset: { fileUrl: string; fileName?: string }): string {
  // zca-js uses uploadAttachment(...).fileUrl directly for sendVoice.
  // Appending filename can produce URLs that play only in the local session.
  return asset.fileUrl.trim();
}

function mapFriend(friend: User): ZcaFriend {
  return {
    userId: String(friend.userId),
    displayName: friend.displayName || friend.zaloName || friend.username || String(friend.userId),
    avatar: friend.avatar || undefined,
  };
}

function mapGroup(groupId: string, group: GroupInfo & Record<string, unknown>): ZaloGroup {
  const totalMember =
    typeof group.totalMember === "number" && Number.isFinite(group.totalMember)
      ? group.totalMember
      : undefined;
  return {
    groupId: String(groupId),
    name: group.name?.trim() || String(groupId),
    memberCount: totalMember,
  };
}

function readCredentials(profile: string): StoredZaloCredentials | null {
  const filePath = resolveCredentialsPath(profile);
  try {
    if (!fs.existsSync(filePath)) {
      return null;
    }
    const raw = fs.readFileSync(filePath, "utf-8");
    const parsed = JSON.parse(raw) as Partial<StoredZaloCredentials>;
    if (
      typeof parsed.imei !== "string" ||
      !parsed.imei ||
      !parsed.cookie ||
      typeof parsed.userAgent !== "string" ||
      !parsed.userAgent
    ) {
      return null;
    }
    return {
      imei: parsed.imei,
      cookie: parsed.cookie as Credentials["cookie"],
      userAgent: parsed.userAgent,
      language: typeof parsed.language === "string" ? parsed.language : undefined,
      createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
      lastUsedAt: typeof parsed.lastUsedAt === "string" ? parsed.lastUsedAt : undefined,
    };
  } catch {
    return null;
  }
}

function touchCredentials(profile: string): void {
  const existing = readCredentials(profile);
  if (!existing) {
    return;
  }
  const next: StoredZaloCredentials = {
    ...existing,
    lastUsedAt: new Date().toISOString(),
  };
  const dir = resolveCredentialsDir();
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
}

function writeCredentials(
  profile: string,
  credentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
): void {
  const dir = resolveCredentialsDir();
  fs.mkdirSync(dir, { recursive: true });
  const existing = readCredentials(profile);
  const now = new Date().toISOString();
  const next: StoredZaloCredentials = {
    ...credentials,
    createdAt: existing?.createdAt ?? now,
    lastUsedAt: now,
  };
  fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
}

function clearCredentials(profile: string): boolean {
  const filePath = resolveCredentialsPath(profile);
  try {
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
      return true;
    }
  } catch {
    // ignore
  }
  return false;
}

async function ensureApi(
  profileInput?: string | null,
  timeoutMs = API_LOGIN_TIMEOUT_MS,
): Promise<API> {
  const profile = normalizeProfile(profileInput);
  const cached = apiByProfile.get(profile);
  if (cached) {
    return cached;
  }

  const pending = apiInitByProfile.get(profile);
  if (pending) {
    return await pending;
  }

  const initPromise = (async () => {
    const stored = readCredentials(profile);
    if (!stored) {
      throw new Error(`No saved Zalo session for profile \"${profile}\"`);
    }
    const zalo = new Zalo({
      logging: false,
      selfListen: false,
    });
    const api = await withTimeout(
      zalo.login({
        imei: stored.imei,
        cookie: stored.cookie,
        userAgent: stored.userAgent,
        language: stored.language,
      }),
      timeoutMs,
      `Timed out restoring Zalo session for profile \"${profile}\"`,
    );
    apiByProfile.set(profile, api);
    touchCredentials(profile);
    return api;
  })();

  apiInitByProfile.set(profile, initPromise);
  try {
    return await initPromise;
  } catch (error) {
    apiByProfile.delete(profile);
    throw error;
  } finally {
    apiInitByProfile.delete(profile);
  }
}

function invalidateApi(profileInput?: string | null): void {
  const profile = normalizeProfile(profileInput);
  const api = apiByProfile.get(profile);
  if (api) {
    try {
      api.listener.stop();
    } catch {
      // ignore
    }
  }
  apiByProfile.delete(profile);
  apiInitByProfile.delete(profile);
}

function isQrLoginFresh(login: ActiveZaloQrLogin): boolean {
  return Date.now() - login.startedAt < QR_LOGIN_TTL_MS;
}

function resetQrLogin(profileInput?: string | null): void {
  const profile = normalizeProfile(profileInput);
  const active = activeQrLogins.get(profile);
  if (!active) {
    return;
  }
  try {
    active.abort?.();
  } catch {
    // ignore
  }
  activeQrLogins.delete(profile);
}

async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, GroupInfo>> {
  const result = new Map<string, GroupInfo>();
  for (let index = 0; index < ids.length; index += GROUP_INFO_CHUNK_SIZE) {
    const chunk = ids.slice(index, index + GROUP_INFO_CHUNK_SIZE);
    if (chunk.length === 0) {
      continue;
    }
    const response = await api.getGroupInfo(chunk);
    const map = response.gridInfoMap ?? {};
    for (const [groupId, info] of Object.entries(map)) {
      result.set(groupId, info);
    }
  }
  return result;
}

function makeGroupContextCacheKey(profile: string, groupId: string): string {
  return `${profile}:${groupId}`;
}

function readCachedGroupContext(profile: string, groupId: string): ZaloGroupContext | null {
  const key = makeGroupContextCacheKey(profile, groupId);
  const cached = groupContextCache.get(key);
  if (!cached) {
    return null;
  }
  if (cached.expiresAt <= Date.now()) {
    groupContextCache.delete(key);
    return null;
  }
  // Bump recency so hot groups stay in cache when enforcing max entries.
  groupContextCache.delete(key);
  groupContextCache.set(key, cached);
  return cached.value;
}

function trimGroupContextCache(now: number): void {
  for (const [key, value] of groupContextCache) {
    if (value.expiresAt > now) {
      continue;
    }
    groupContextCache.delete(key);
  }
  while (groupContextCache.size > GROUP_CONTEXT_CACHE_MAX_ENTRIES) {
    const oldestKey = groupContextCache.keys().next().value;
    if (!oldestKey) {
      break;
    }
    groupContextCache.delete(oldestKey);
  }
}

function writeCachedGroupContext(profile: string, context: ZaloGroupContext): void {
  const now = Date.now();
  const key = makeGroupContextCacheKey(profile, context.groupId);
  if (groupContextCache.has(key)) {
    groupContextCache.delete(key);
  }
  groupContextCache.set(key, {
    value: context,
    expiresAt: now + GROUP_CONTEXT_CACHE_TTL_MS,
  });
  trimGroupContextCache(now);
}

function clearCachedGroupContext(profile: string): void {
  for (const key of groupContextCache.keys()) {
    if (key.startsWith(`${profile}:`)) {
      groupContextCache.delete(key);
    }
  }
}

function extractGroupMembersFromInfo(
  groupInfo: (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] }) | undefined,
): string[] | undefined {
  if (!groupInfo || !Array.isArray(groupInfo.currentMems)) {
    return undefined;
  }
  const members = groupInfo.currentMems
    .map((member) => {
      if (!member || typeof member !== "object") {
        return "";
      }
      const record = member as { dName?: unknown; zaloName?: unknown };
      return toStringValue(record.dName) || toStringValue(record.zaloName);
    })
    .filter(Boolean);
  if (members.length === 0) {
    return undefined;
  }
  return members;
}

function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
  const data = message.data as Record<string, unknown>;
  const isGroup = message.type === ThreadType.Group;
  const senderId = toNumberId(data.uidFrom);
  const threadId = isGroup
    ? toNumberId(data.idTo)
    : toNumberId(data.uidFrom) || toNumberId(data.idTo);
  if (!threadId || !senderId) {
    return null;
  }
  const content = normalizeMessageContent(data.content);
  const normalizedOwnUserId = toNumberId(ownUserId);
  const mentionIds = extractMentionIds(data.mentions);
  const quoteOwnerId =
    data.quote && typeof data.quote === "object"
      ? toNumberId((data.quote as { ownerId?: unknown }).ownerId)
      : "";
  const hasAnyMention = mentionIds.length > 0;
  const canResolveExplicitMention = Boolean(normalizedOwnUserId);
  const wasExplicitlyMentioned = Boolean(
    normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
  );
  const commandContent = wasExplicitlyMentioned
    ? stripOwnMentionsForCommandBody(content, data.mentions, normalizedOwnUserId)
    : hasAnyMention && !canResolveExplicitMention
      ? stripLeadingAtMentionForCommand(content)
      : content;
  const implicitMention = Boolean(
    normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
  );
  const eventMessage = buildEventMessage(data);
  return {
    threadId,
    isGroup,
    senderId,
    senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined,
    groupName: isGroup ? resolveGroupNameFromMessageData(data) : undefined,
    content,
    commandContent,
    timestampMs: resolveInboundTimestamp(data.ts),
    msgId: typeof data.msgId === "string" ? data.msgId : undefined,
    cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
    hasAnyMention,
    canResolveExplicitMention,
    wasExplicitlyMentioned,
    implicitMention,
    eventMessage,
    raw: message,
  };
}

export function zalouserSessionExists(profileInput?: string | null): boolean {
  const profile = normalizeProfile(profileInput);
  return readCredentials(profile) !== null;
}

export async function checkZaloAuthenticated(profileInput?: string | null): Promise<boolean> {
  const profile = normalizeProfile(profileInput);
  if (!zalouserSessionExists(profile)) {
    return false;
  }
  try {
    const api = await ensureApi(profile, 12_000);
    await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session");
    return true;
  } catch {
    invalidateApi(profile);
    return false;
  }
}

export async function getZaloUserInfo(profileInput?: string | null): Promise<ZcaUserInfo | null> {
  const profile = normalizeProfile(profileInput);
  const api = await ensureApi(profile);
  const info = await api.fetchAccountInfo();
  const user = normalizeAccountInfoUser(info);
  if (!user?.userId) {
    return null;
  }
  return {
    userId: String(user.userId),
    displayName: user.displayName || user.zaloName || String(user.userId),
    avatar: user.avatar || undefined,
  };
}

export async function listZaloFriends(profileInput?: string | null): Promise<ZcaFriend[]> {
  const profile = normalizeProfile(profileInput);
  const api = await ensureApi(profile);
  const friends = await api.getAllFriends();
  return friends.map(mapFriend);
}

export async function listZaloFriendsMatching(
  profileInput: string | null | undefined,
  query?: string | null,
): Promise<ZcaFriend[]> {
  const friends = await listZaloFriends(profileInput);
  const q = query?.trim().toLowerCase();
  if (!q) {
    return friends;
  }
  const scored = friends
    .map((friend) => {
      const id = friend.userId.toLowerCase();
      const name = friend.displayName.toLowerCase();
      const exact = id === q || name === q;
      const includes = id.includes(q) || name.includes(q);
      return { friend, exact, includes };
    })
    .filter((entry) => entry.includes)
    .sort((a, b) => Number(b.exact) - Number(a.exact));
  return scored.map((entry) => entry.friend);
}

export async function listZaloGroups(profileInput?: string | null): Promise<ZaloGroup[]> {
  const profile = normalizeProfile(profileInput);
  const api = await ensureApi(profile);
  const allGroups = await api.getAllGroups();
  const ids = Object.keys(allGroups.gridVerMap ?? {});
  if (ids.length === 0) {
    return [];
  }
  const details = await fetchGroupsByIds(api, ids);
  const rows: ZaloGroup[] = [];
  for (const id of ids) {
    const info = details.get(id);
    if (!info) {
      rows.push({ groupId: id, name: id });
      continue;
    }
    rows.push(mapGroup(id, info as GroupInfo & Record<string, unknown>));
  }
  return rows;
}

export async function listZaloGroupsMatching(
  profileInput: string | null | undefined,
  query?: string | null,
): Promise<ZaloGroup[]> {
  const groups = await listZaloGroups(profileInput);
  const q = query?.trim().toLowerCase();
  if (!q) {
    return groups;
  }
  return groups.filter((group) => {
    const id = group.groupId.toLowerCase();
    const name = group.name.toLowerCase();
    return id.includes(q) || name.includes(q);
  });
}

export async function listZaloGroupMembers(
  profileInput: string | null | undefined,
  groupId: string,
): Promise<ZaloGroupMember[]> {
  const profile = normalizeProfile(profileInput);
  const api = await ensureApi(profile);

  const infoResponse = await api.getGroupInfo(groupId);
  const groupInfo = infoResponse.gridInfoMap?.[groupId] as
    | (GroupInfo & { memVerList?: unknown })
    | undefined;
  if (!groupInfo) {
    return [];
  }

  const memberIds = Array.isArray(groupInfo.memberIds)
    ? groupInfo.memberIds.map((id: unknown) => toNumberId(id)).filter(Boolean)
    : [];
  const memVerIds = Array.isArray(groupInfo.memVerList)
    ? groupInfo.memVerList.map((id: unknown) => toNumberId(id)).filter(Boolean)
    : [];
  const currentMembers = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : [];

  const currentById = new Map<string, { displayName?: string; avatar?: string }>();
  for (const member of currentMembers) {
    const id = toNumberId(member?.id);
    if (!id) {
      continue;
    }
    currentById.set(id, {
      displayName: member.dName?.trim() || member.zaloName?.trim() || undefined,
      avatar: member.avatar || undefined,
    });
  }

  const uniqueIds = Array.from(
    new Set<string>([...memberIds, ...memVerIds, ...currentById.keys()]),
  );

  const profileMap = new Map<string, { displayName?: string; avatar?: string }>();
  if (uniqueIds.length > 0) {
    const profiles = await api.getGroupMembersInfo(uniqueIds);
    const profileEntries = profiles.profiles as Record<
      string,
      {
        id?: string;
        displayName?: string;
        zaloName?: string;
        avatar?: string;
      }
    >;
    for (const [rawId, profileValue] of Object.entries(profileEntries)) {
      const id = toNumberId(rawId) || toNumberId((profileValue as { id?: unknown })?.id);
      if (!id || !profileValue) {
        continue;
      }
      profileMap.set(id, {
        displayName: profileValue.displayName?.trim() || profileValue.zaloName?.trim() || undefined,
        avatar: profileValue.avatar || undefined,
      });
    }
  }

  return uniqueIds.map((id) => ({
    userId: id,
    displayName: profileMap.get(id)?.displayName || currentById.get(id)?.displayName || id,
    avatar: profileMap.get(id)?.avatar || currentById.get(id)?.avatar,
  }));
}

export async function resolveZaloGroupContext(
  profileInput: string | null | undefined,
  groupId: string,
): Promise<ZaloGroupContext> {
  const profile = normalizeProfile(profileInput);
  const normalizedGroupId = toNumberId(groupId) || groupId.trim();
  if (!normalizedGroupId) {
    throw new Error("groupId is required");
  }
  const cached = readCachedGroupContext(profile, normalizedGroupId);
  if (cached) {
    return cached;
  }

  const api = await ensureApi(profile);
  const response = await api.getGroupInfo(normalizedGroupId);
  const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
    | (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
    | undefined;
  const context: ZaloGroupContext = {
    groupId: normalizedGroupId,
    name: groupInfo?.name?.trim() || undefined,
    members: extractGroupMembersFromInfo(groupInfo),
  };
  writeCachedGroupContext(profile, context);
  return context;
}

export async function sendZaloTextMessage(
  threadId: string,
  text: string,
  options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
  const profile = normalizeProfile(options.profile);
  const trimmedThreadId = threadId.trim();
  if (!trimmedThreadId) {
    return { ok: false, error: "No threadId provided" };
  }

  const api = await ensureApi(profile);
  const type = options.isGroup ? ThreadType.Group : ThreadType.User;

  try {
    if (options.mediaUrl?.trim()) {
      const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), {
        mediaLocalRoots: options.mediaLocalRoots,
      });
      const fileName = resolveMediaFileName({
        mediaUrl: options.mediaUrl,
        fileName: media.fileName,
        contentType: media.contentType,
        kind: media.kind,
      });
      const payloadText = (text || options.caption || "").slice(0, 2000);
      const textStyles = clampTextStyles(payloadText, options.textStyles);

      if (media.kind === "audio") {
        let textMessageId: string | undefined;
        if (payloadText) {
          const textResponse = await api.sendMessage(
            textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
            trimmedThreadId,
            type,
          );
          textMessageId = extractSendMessageId(textResponse);
        }

        const attachmentFileName = fileName.includes(".") ? fileName : `${fileName}.bin`;
        const uploaded = await api.uploadAttachment(
          [
            {
              data: media.buffer,
              filename: attachmentFileName as `${string}.${string}`,
              metadata: {
                totalSize: media.buffer.length,
              },
            },
          ],
          trimmedThreadId,
          type,
        );
        const voiceAsset = resolveUploadedVoiceAsset(uploaded);
        if (!voiceAsset) {
          throw new Error("Failed to resolve uploaded audio URL for voice message");
        }
        const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset);
        const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type);
        return {
          ok: true,
          messageId: extractSendMessageId(response) ?? textMessageId,
        };
      }

      const response = await api.sendMessage(
        {
          msg: payloadText,
          ...(textStyles ? { styles: textStyles } : {}),
          attachments: [
            {
              data: media.buffer,
              filename: fileName.includes(".") ? fileName : `${fileName}.bin`,
              metadata: {
                totalSize: media.buffer.length,
              },
            },
          ],
        },
        trimmedThreadId,
        type,
      );
      return { ok: true, messageId: extractSendMessageId(response) };
    }

    const payloadText = text.slice(0, 2000);
    const textStyles = clampTextStyles(payloadText, options.textStyles);
    const response = await api.sendMessage(
      textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
      trimmedThreadId,
      type,
    );
    return { ok: true, messageId: extractSendMessageId(response) };
  } catch (error) {
    return { ok: false, error: toErrorMessage(error) };
  }
}

export async function sendZaloTypingEvent(
  threadId: string,
  options: Pick<ZaloSendOptions, "profile" | "isGroup"> = {},
): Promise<void> {
  const profile = normalizeProfile(options.profile);
  const trimmedThreadId = threadId.trim();
  if (!trimmedThreadId) {
    throw new Error("No threadId provided");
  }
  const api = await ensureApi(profile);
  const type = options.isGroup ? ThreadType.Group : ThreadType.User;
  if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
    await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
    return;
  }
  throw new Error("Zalo typing indicator is not supported by current API session");
}

async function resolveOwnUserId(api: API): Promise<string> {
  try {
    const info = await api.fetchAccountInfo();
    const resolved = toNumberId(normalizeAccountInfoUser(info)?.userId);
    if (resolved) {
      return resolved;
    }
  } catch {
    // Fall back to getOwnId when account info shape changes.
  }

  try {
    const ownId = toNumberId(api.getOwnId());
    if (ownId) {
      return ownId;
    }
  } catch {
    // Ignore fallback probe failures and keep mention detection conservative.
  }

  return "";
}

export async function sendZaloReaction(params: {
  profile?: string | null;
  threadId: string;
  isGroup?: boolean;
  msgId: string;
  cliMsgId: string;
  emoji: string;
  remove?: boolean;
}): Promise<{ ok: boolean; error?: string }> {
  const profile = normalizeProfile(params.profile);
  const threadId = params.threadId.trim();
  const msgId = toStringValue(params.msgId);
  const cliMsgId = toStringValue(params.cliMsgId);
  if (!threadId || !msgId || !cliMsgId) {
    return { ok: false, error: "threadId, msgId, and cliMsgId are required" };
  }
  try {
    const api = await ensureApi(profile);
    const type = params.isGroup ? ThreadType.Group : ThreadType.User;
    const icon = params.remove
      ? { rType: -1, source: 6, icon: "" }
      : normalizeZaloReactionIcon(params.emoji);
    await api.addReaction(icon, {
      data: { msgId, cliMsgId },
      threadId,
      type,
    });
    return { ok: true };
  } catch (error) {
    return { ok: false, error: toErrorMessage(error) };
  }
}

export async function sendZaloDeliveredEvent(params: {
  profile?: string | null;
  isGroup?: boolean;
  message: ZaloEventMessage;
  isSeen?: boolean;
}): Promise<void> {
  const profile = normalizeProfile(params.profile);
  const api = await ensureApi(profile);
  const type = params.isGroup ? ThreadType.Group : ThreadType.User;
  await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
}

export async function sendZaloSeenEvent(params: {
  profile?: string | null;
  isGroup?: boolean;
  message: ZaloEventMessage;
}): Promise<void> {
  const profile = normalizeProfile(params.profile);
  const api = await ensureApi(profile);
  const type = params.isGroup ? ThreadType.Group : ThreadType.User;
  await api.sendSeenEvent(params.message, type);
}

export async function sendZaloLink(
  threadId: string,
  url: string,
  options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
  const profile = normalizeProfile(options.profile);
  const trimmedThreadId = threadId.trim();
  const trimmedUrl = url.trim();
  if (!trimmedThreadId) {
    return { ok: false, error: "No threadId provided" };
  }
  if (!trimmedUrl) {
    return { ok: false, error: "No URL provided" };
  }

  try {
    const api = await ensureApi(profile);
    const type = options.isGroup ? ThreadType.Group : ThreadType.User;
    const response = await api.sendLink(
      { link: trimmedUrl, msg: options.caption },
      trimmedThreadId,
      type,
    );
    return { ok: true, messageId: String(response.msgId) };
  } catch (error) {
    return { ok: false, error: toErrorMessage(error) };
  }
}

export async function startZaloQrLogin(params: {
  profile?: string | null;
  force?: boolean;
  timeoutMs?: number;
}): Promise<{ qrDataUrl?: string; message: string }> {
  const profile = normalizeProfile(params.profile);

  if (!params.force && (await checkZaloAuthenticated(profile))) {
    const info = await getZaloUserInfo(profile).catch(() => null);
    const name = info?.displayName ? ` (${info.displayName})` : "";
    return {
      message: `Zalo is already linked${name}.`,
    };
  }

  if (params.force) {
    await logoutZaloProfile(profile);
  }

  const existing = activeQrLogins.get(profile);
  if (existing && isQrLoginFresh(existing)) {
    if (existing.qrDataUrl) {
      return {
        qrDataUrl: existing.qrDataUrl,
        message: "QR already active. Scan it with the Zalo app.",
      };
    }
  } else if (existing) {
    resetQrLogin(profile);
  }

  if (!activeQrLogins.has(profile)) {
    const login: ActiveZaloQrLogin = {
      id: randomUUID(),
      profile,
      startedAt: Date.now(),
      connected: false,
      waitPromise: Promise.resolve(),
    };

    login.waitPromise = (async () => {
      let capturedCredentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt"> | null =
        null;
      try {
        const zalo = new Zalo({ logging: false, selfListen: false });
        const api = await zalo.loginQR(undefined, (event: LoginQRCallbackEvent) => {
          const current = activeQrLogins.get(profile);
          if (!current || current.id !== login.id) {
            return;
          }

          if (event.actions?.abort) {
            current.abort = () => {
              try {
                event.actions?.abort?.();
              } catch {
                // ignore
              }
            };
          }

          switch (event.type) {
            case LoginQRCallbackEventType.QRCodeGenerated: {
              const image = event.data.image.replace(/^data:image\/png;base64,/, "");
              current.qrDataUrl = image.startsWith("data:image")
                ? image
                : `data:image/png;base64,${image}`;
              break;
            }
            case LoginQRCallbackEventType.QRCodeExpired: {
              try {
                event.actions.retry();
              } catch {
                current.error = "QR expired before confirmation. Start login again.";
              }
              break;
            }
            case LoginQRCallbackEventType.QRCodeDeclined: {
              current.error = "QR login was declined on the phone.";
              break;
            }
            case LoginQRCallbackEventType.GotLoginInfo: {
              capturedCredentials = {
                imei: event.data.imei,
                cookie: event.data.cookie,
                userAgent: event.data.userAgent,
              };
              break;
            }
            default:
              break;
          }
        });

        const current = activeQrLogins.get(profile);
        if (!current || current.id !== login.id) {
          return;
        }

        if (!capturedCredentials) {
          const ctx = api.getContext();
          const cookieJar = api.getCookie();
          const cookieJson = cookieJar.toJSON();
          capturedCredentials = {
            imei: ctx.imei,
            cookie: cookieJson?.cookies ?? [],
            userAgent: ctx.userAgent,
            language: ctx.language,
          };
        }

        writeCredentials(profile, capturedCredentials);
        invalidateApi(profile);
        apiByProfile.set(profile, api);
        current.connected = true;
      } catch (error) {
        const current = activeQrLogins.get(profile);
        if (current && current.id === login.id) {
          current.error = toErrorMessage(error);
        }
      }
    })();

    activeQrLogins.set(profile, login);
  }

  const active = activeQrLogins.get(profile);
  if (!active) {
    return { message: "Failed to initialize Zalo QR login." };
  }

  const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_START_TIMEOUT_MS, 3000);
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    if (active.error) {
      resetQrLogin(profile);
      return {
        message: `Failed to start QR login: ${active.error}`,
      };
    }
    if (active.connected) {
      resetQrLogin(profile);
      return {
        message: "Zalo already connected.",
      };
    }
    if (active.qrDataUrl) {
      return {
        qrDataUrl: active.qrDataUrl,
        message: "Scan this QR with the Zalo app.",
      };
    }
    await delay(150);
  }

  return {
    message: "Still preparing QR. Call wait to continue checking login status.",
  };
}

export async function waitForZaloQrLogin(params: {
  profile?: string | null;
  timeoutMs?: number;
}): Promise<ZaloAuthStatus> {
  const profile = normalizeProfile(params.profile);
  const active = activeQrLogins.get(profile);

  if (!active) {
    const connected = await checkZaloAuthenticated(profile);
    return {
      connected,
      message: connected ? "Zalo session is ready." : "No active Zalo QR login in progress.",
    };
  }

  if (!isQrLoginFresh(active)) {
    resetQrLogin(profile);
    return {
      connected: false,
      message: "QR login expired. Start again to generate a fresh QR code.",
    };
  }

  const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_WAIT_TIMEOUT_MS, 1000);
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    if (active.error) {
      const message = `Zalo login failed: ${active.error}`;
      resetQrLogin(profile);
      return {
        connected: false,
        message,
      };
    }
    if (active.connected) {
      resetQrLogin(profile);
      return {
        connected: true,
        message: "Login successful.",
      };
    }
    await Promise.race([active.waitPromise, delay(400)]);
  }

  return {
    connected: false,
    message: "Still waiting for QR scan confirmation.",
  };
}

export async function logoutZaloProfile(profileInput?: string | null): Promise<{
  cleared: boolean;
  loggedOut: boolean;
  message: string;
}> {
  const profile = normalizeProfile(profileInput);
  resetQrLogin(profile);
  clearCachedGroupContext(profile);

  const listener = activeListeners.get(profile);
  if (listener) {
    try {
      listener.stop();
    } catch {
      // ignore
    }
    activeListeners.delete(profile);
  }

  invalidateApi(profile);
  const cleared = clearCredentials(profile);

  return {
    cleared,
    loggedOut: true,
    message: cleared ? "Logged out and cleared local session." : "No local session to clear.",
  };
}

export async function startZaloListener(params: {
  accountId: string;
  profile?: string | null;
  abortSignal: AbortSignal;
  onMessage: (message: ZaloInboundMessage) => void;
  onError: (error: Error) => void;
}): Promise<{ stop: () => void }> {
  const profile = normalizeProfile(params.profile);

  const existing = activeListeners.get(profile);
  if (existing) {
    throw new Error(
      `Zalo listener already running for profile \"${profile}\" (account \"${existing.accountId}\")`,
    );
  }

  const api = await ensureApi(profile);
  const ownUserId = await resolveOwnUserId(api);
  let stopped = false;
  let watchdogTimer: ReturnType<typeof setInterval> | null = null;
  let lastWatchdogTickAt = Date.now();

  const cleanup = () => {
    if (stopped) {
      return;
    }
    stopped = true;
    if (watchdogTimer) {
      clearInterval(watchdogTimer);
      watchdogTimer = null;
    }
    try {
      api.listener.off("message", onMessage);
      api.listener.off("error", onError);
      api.listener.off("closed", onClosed);
    } catch {
      // ignore listener detachment errors
    }
    try {
      api.listener.stop();
    } catch {
      // ignore
    }
    activeListeners.delete(profile);
  };

  const onMessage = (incoming: Message) => {
    if (incoming.isSelf) {
      return;
    }
    const normalized = toInboundMessage(incoming, ownUserId);
    if (!normalized) {
      return;
    }
    params.onMessage(normalized);
  };

  const failListener = (error: Error) => {
    if (stopped || params.abortSignal.aborted) {
      return;
    }
    cleanup();
    invalidateApi(profile);
    params.onError(error);
  };

  const onError = (error: unknown) => {
    const wrapped = error instanceof Error ? error : new Error(String(error));
    failListener(wrapped);
  };

  const onClosed = (code: number, reason: string) => {
    failListener(new Error(`Zalo listener closed (${code}): ${reason || "no reason"}`));
  };

  api.listener.on("message", onMessage);
  api.listener.on("error", onError);
  api.listener.on("closed", onClosed);

  try {
    api.listener.start({ retryOnClose: false });
  } catch (error) {
    cleanup();
    throw error;
  }

  watchdogTimer = setInterval(() => {
    if (stopped || params.abortSignal.aborted) {
      return;
    }
    const now = Date.now();
    const gapMs = now - lastWatchdogTickAt;
    lastWatchdogTickAt = now;
    if (gapMs <= LISTENER_WATCHDOG_MAX_GAP_MS) {
      return;
    }
    failListener(
      new Error(
        `Zalo listener watchdog gap detected (${Math.round(gapMs / 1000)}s): forcing reconnect`,
      ),
    );
  }, LISTENER_WATCHDOG_INTERVAL_MS);
  watchdogTimer.unref?.();

  params.abortSignal.addEventListener(
    "abort",
    () => {
      cleanup();
    },
    { once: true },
  );

  activeListeners.set(profile, {
    profile,
    accountId: params.accountId,
    stop: cleanup,
  });

  return { stop: cleanup };
}

export async function resolveZaloGroupsByEntries(params: {
  profile?: string | null;
  entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
  const groups = await listZaloGroups(params.profile);
  const byName = new Map<string, ZaloGroup[]>();
  for (const group of groups) {
    const key = group.name.trim().toLowerCase();
    if (!key) {
      continue;
    }
    const list = byName.get(key) ?? [];
    list.push(group);
    byName.set(key, list);
  }

  return params.entries.map((input) => {
    const trimmed = input.trim();
    if (!trimmed) {
      return { input, resolved: false };
    }
    if (/^\d+$/.test(trimmed)) {
      return { input, resolved: true, id: trimmed };
    }
    const candidates = byName.get(trimmed.toLowerCase()) ?? [];
    const match = candidates[0];
    return match ? { input, resolved: true, id: match.groupId } : { input, resolved: false };
  });
}

export async function resolveZaloAllowFromEntries(params: {
  profile?: string | null;
  entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string; note?: string }>> {
  const friends = await listZaloFriends(params.profile);
  const byName = new Map<string, ZcaFriend[]>();
  for (const friend of friends) {
    const key = friend.displayName.trim().toLowerCase();
    if (!key) {
      continue;
    }
    const list = byName.get(key) ?? [];
    list.push(friend);
    byName.set(key, list);
  }

  return params.entries.map((input) => {
    const trimmed = input.trim();
    if (!trimmed) {
      return { input, resolved: false };
    }
    if (/^\d+$/.test(trimmed)) {
      return { input, resolved: true, id: trimmed };
    }
    const matches = byName.get(trimmed.toLowerCase()) ?? [];
    const match = matches[0];
    if (!match) {
      return { input, resolved: false };
    }
    return {
      input,
      resolved: true,
      id: match.userId,
      note: matches.length > 1 ? "multiple matches; chose first" : undefined,
    };
  });
}

export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise<void> {
  const profile = normalizeProfile(profileInput);
  resetQrLogin(profile);
  clearCachedGroupContext(profile);
  const listener = activeListeners.get(profile);
  if (listener) {
    listener.stop();
    activeListeners.delete(profile);
  }
  invalidateApi(profile);
  await fsp.mkdir(resolveCredentialsDir(), { recursive: true }).catch(() => undefined);
}
