import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import lockfile from "proper-lockfile";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
const PAIRING_PENDING_MAX = 3;
const PAIRING_STORE_LOCK_OPTIONS = {
    retries: {
        retries: 10,
        factor: 2,
        minTimeout: 100,
        maxTimeout: 10_000,
        randomize: true,
    },
    stale: 30_000,
};
function resolveCredentialsDir(env = process.env) {
    const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir));
    return resolveOAuthDir(env, stateDir);
}
/** Sanitize channel ID for use in filenames (prevent path traversal). */
function safeChannelKey(channel) {
    const raw = String(channel).trim().toLowerCase();
    if (!raw)
        throw new Error("invalid pairing channel");
    const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
    if (!safe || safe === "_")
        throw new Error("invalid pairing channel");
    return safe;
}
function resolvePairingPath(channel, env = process.env) {
    return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`);
}
function safeAccountKey(accountId) {
    const raw = String(accountId).trim().toLowerCase();
    if (!raw) {
        throw new Error("invalid pairing account id");
    }
    const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
    if (!safe || safe === "_") {
        throw new Error("invalid pairing account id");
    }
    return safe;
}
function resolveAllowFromPath(channel, env = process.env, accountId) {
    const base = safeChannelKey(channel);
    const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
    if (!normalizedAccountId) {
        return path.join(resolveCredentialsDir(env), `${base}-allowFrom.json`);
    }
    return path.join(resolveCredentialsDir(env), `${base}-${safeAccountKey(normalizedAccountId)}-allowFrom.json`);
}
function safeParseJson(raw) {
    try {
        return JSON.parse(raw);
    }
    catch {
        return null;
    }
}
async function readJsonFile(filePath, fallback) {
    try {
        const raw = await fs.promises.readFile(filePath, "utf-8");
        const parsed = safeParseJson(raw);
        if (parsed == null)
            return { value: fallback, exists: true };
        return { value: parsed, exists: true };
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT")
            return { value: fallback, exists: false };
        return { value: fallback, exists: false };
    }
}
async function writeJsonFile(filePath, value) {
    const dir = path.dirname(filePath);
    await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
    const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
    await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
        encoding: "utf-8",
    });
    await fs.promises.chmod(tmp, 0o600);
    await fs.promises.rename(tmp, filePath);
}
async function ensureJsonFile(filePath, fallback) {
    try {
        await fs.promises.access(filePath);
    }
    catch {
        await writeJsonFile(filePath, fallback);
    }
}
async function withFileLock(filePath, fallback, fn) {
    await ensureJsonFile(filePath, fallback);
    let release;
    try {
        release = await lockfile.lock(filePath, PAIRING_STORE_LOCK_OPTIONS);
        return await fn();
    }
    finally {
        if (release) {
            try {
                await release();
            }
            catch {
                // ignore unlock errors
            }
        }
    }
}
function parseTimestamp(value) {
    if (!value)
        return null;
    const parsed = Date.parse(value);
    if (!Number.isFinite(parsed))
        return null;
    return parsed;
}
function isExpired(entry, nowMs) {
    const createdAt = parseTimestamp(entry.createdAt);
    if (!createdAt)
        return true;
    return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
}
function pruneExpiredRequests(reqs, nowMs) {
    const kept = [];
    let removed = false;
    for (const req of reqs) {
        if (isExpired(req, nowMs)) {
            removed = true;
            continue;
        }
        kept.push(req);
    }
    return { requests: kept, removed };
}
function resolveLastSeenAt(entry) {
    return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0;
}
function pruneExcessRequests(reqs, maxPending) {
    if (maxPending <= 0 || reqs.length <= maxPending) {
        return { requests: reqs, removed: false };
    }
    const sorted = reqs.slice().sort((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
    return { requests: sorted.slice(-maxPending), removed: true };
}
function randomCode() {
    // Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
    let out = "";
    for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
        const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length);
        out += PAIRING_CODE_ALPHABET[idx];
    }
    return out;
}
function generateUniqueCode(existing) {
    for (let attempt = 0; attempt < 500; attempt += 1) {
        const code = randomCode();
        if (!existing.has(code))
            return code;
    }
    throw new Error("failed to generate unique pairing code");
}
function normalizeId(value) {
    return String(value).trim();
}
function normalizeAllowEntry(channel, entry) {
    const trimmed = entry.trim();
    if (!trimmed)
        return "";
    if (trimmed === "*")
        return "";
    const adapter = getPairingAdapter(channel);
    const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
    return String(normalized).trim();
}
async function writeAllowFromState(filePath, allowFrom) {
    await writeJsonFile(filePath, {
        version: 1,
        allowFrom,
    });
}
async function updateAllowFromStoreEntry(params) {
    const env = params.env ?? process.env;
    const filePath = resolveAllowFromPath(params.channel, env, params.accountId);
    return await withFileLock(filePath, { version: 1, allowFrom: [] }, async () => {
        const { value } = await readJsonFile(filePath, {
            version: 1,
            allowFrom: [],
        });
        const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
            .map((v) => normalizeAllowEntry(params.channel, String(v)))
            .filter(Boolean);
        const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
        if (!normalized)
            return { changed: false, allowFrom: current };
        const next = params.apply(current, normalized);
        if (next === null)
            return { changed: false, allowFrom: current };
        await writeAllowFromState(filePath, next);
        return { changed: true, allowFrom: next };
    });
}
export async function readChannelAllowFromStore(channel, env = process.env, accountId) {
    const filePath = resolveAllowFromPath(channel, env, accountId);
    const { value } = await readJsonFile(filePath, {
        version: 1,
        allowFrom: [],
    });
    const list = Array.isArray(value.allowFrom) ? value.allowFrom : [];
    const entries = list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean);
    // When reading the channel-level store (no accountId), also merge the "default"
    // account-scoped store so that `pairing approve` (which writes to the account-scoped
    // file because the pairing request carries meta.accountId="default") is always visible.
    if (!accountId) {
        const defaultFilePath = resolveAllowFromPath(channel, env, "default");
        if (defaultFilePath !== filePath) {
            const { value: defaultValue } = await readJsonFile(defaultFilePath, {
                version: 1,
                allowFrom: [],
            });
            const defaultList = Array.isArray(defaultValue.allowFrom) ? defaultValue.allowFrom : [];
            const defaultEntries = defaultList
                .map((v) => normalizeAllowEntry(channel, String(v)))
                .filter(Boolean);
            return Array.from(new Set([...entries, ...defaultEntries]));
        }
    }
    return entries;
}
export async function addChannelAllowFromStoreEntry(params) {
    return await updateAllowFromStoreEntry({
        channel: params.channel,
        entry: params.entry,
        accountId: params.accountId,
        env: params.env,
        apply: (current, normalized) => {
            if (current.includes(normalized)) {
                return null;
            }
            return [...current, normalized];
        },
    });
}
export async function removeChannelAllowFromStoreEntry(params) {
    return await updateAllowFromStoreEntry({
        channel: params.channel,
        entry: params.entry,
        accountId: params.accountId,
        env: params.env,
        apply: (current, normalized) => {
            const next = current.filter((entry) => entry !== normalized);
            return next.length === current.length ? null : next;
        },
    });
}
export async function listChannelPairingRequests(channel, env = process.env, accountId) {
    const filePath = resolvePairingPath(channel, env);
    return await withFileLock(filePath, { version: 1, requests: [] }, async () => {
        const { value } = await readJsonFile(filePath, {
            version: 1,
            requests: [],
        });
        const reqs = Array.isArray(value.requests) ? value.requests : [];
        const nowMs = Date.now();
        const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(reqs, nowMs);
        const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(prunedExpired, PAIRING_PENDING_MAX);
        if (expiredRemoved || cappedRemoved) {
            await writeJsonFile(filePath, {
                version: 1,
                requests: pruned,
            });
        }
        const normalizedAccountId = accountId?.trim().toLowerCase() || "";
        const filtered = normalizedAccountId
            ? pruned.filter((entry) => String(entry.meta?.accountId ?? "")
                .trim()
                .toLowerCase() === normalizedAccountId)
            : pruned;
        return filtered
            .filter((r) => r &&
            typeof r.id === "string" &&
            typeof r.code === "string" &&
            typeof r.createdAt === "string")
            .slice()
            .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
    });
}
export async function upsertChannelPairingRequest(params) {
    const env = params.env ?? process.env;
    const filePath = resolvePairingPath(params.channel, env);
    return await withFileLock(filePath, { version: 1, requests: [] }, async () => {
        const { value } = await readJsonFile(filePath, {
            version: 1,
            requests: [],
        });
        const now = new Date().toISOString();
        const nowMs = Date.now();
        const id = normalizeId(params.id);
        const normalizedAccountId = params.accountId?.trim();
        const baseMeta = params.meta && typeof params.meta === "object"
            ? Object.fromEntries(Object.entries(params.meta)
                .map(([k, v]) => [k, String(v ?? "").trim()])
                .filter(([_, v]) => Boolean(v)))
            : undefined;
        const meta = normalizedAccountId ? { ...baseMeta, accountId: normalizedAccountId } : baseMeta;
        let reqs = Array.isArray(value.requests) ? value.requests : [];
        const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(reqs, nowMs);
        reqs = prunedExpired;
        const existingIdx = reqs.findIndex((r) => r.id === id);
        const existingCodes = new Set(reqs.map((req) => String(req.code ?? "")
            .trim()
            .toUpperCase()));
        if (existingIdx >= 0) {
            const existing = reqs[existingIdx];
            const existingCode = existing && typeof existing.code === "string" ? existing.code.trim() : "";
            const code = existingCode || generateUniqueCode(existingCodes);
            const next = {
                id,
                code,
                createdAt: existing?.createdAt ?? now,
                lastSeenAt: now,
                meta: meta ?? existing?.meta,
            };
            reqs[existingIdx] = next;
            const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
            await writeJsonFile(filePath, {
                version: 1,
                requests: capped,
            });
            return { code, created: false };
        }
        const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
        reqs = capped;
        if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
            if (expiredRemoved || cappedRemoved) {
                await writeJsonFile(filePath, {
                    version: 1,
                    requests: reqs,
                });
            }
            return { code: "", created: false };
        }
        const code = generateUniqueCode(existingCodes);
        const next = {
            id,
            code,
            createdAt: now,
            lastSeenAt: now,
            ...(meta ? { meta } : {}),
        };
        await writeJsonFile(filePath, {
            version: 1,
            requests: [...reqs, next],
        });
        return { code, created: true };
    });
}
export async function approveChannelPairingCode(params) {
    const env = params.env ?? process.env;
    const code = params.code.trim().toUpperCase();
    if (!code)
        return null;
    const filePath = resolvePairingPath(params.channel, env);
    return await withFileLock(filePath, { version: 1, requests: [] }, async () => {
        const { value } = await readJsonFile(filePath, {
            version: 1,
            requests: [],
        });
        const reqs = Array.isArray(value.requests) ? value.requests : [];
        const nowMs = Date.now();
        const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
        const normalizedAccountId = params.accountId?.trim().toLowerCase() || "";
        const idx = pruned.findIndex((r) => {
            if (String(r.code ?? "").toUpperCase() !== code) {
                return false;
            }
            if (!normalizedAccountId) {
                return true;
            }
            return (String(r.meta?.accountId ?? "")
                .trim()
                .toLowerCase() === normalizedAccountId);
        });
        if (idx < 0) {
            if (removed) {
                await writeJsonFile(filePath, {
                    version: 1,
                    requests: pruned,
                });
            }
            return null;
        }
        const entry = pruned[idx];
        if (!entry)
            return null;
        pruned.splice(idx, 1);
        await writeJsonFile(filePath, {
            version: 1,
            requests: pruned,
        });
        const entryAccountId = String(entry.meta?.accountId ?? "").trim() || undefined;
        await addChannelAllowFromStoreEntry({
            channel: params.channel,
            entry: entry.id,
            accountId: params.accountId?.trim() || entryAccountId,
            env,
        });
        return { id: entry.id, entry };
    });
}
/** @deprecated Use readChannelAllowFromStore instead */
export const readProviderAllowFromStore = readChannelAllowFromStore;
/** @deprecated Use upsertChannelPairingRequest instead */
export const upsertProviderPairingRequest = upsertChannelPairingRequest;
