/**
 * src/autonomy/allowlist.ts
 *
 * Who can DM the agent? Three players matter:
 *   • The owner (auto-allowed by onboarding)
 *   • Team members the owner explicitly authorizes
 *   • Customers / leads the agent itself was asked to contact (auto-allowed
 *     when the outreach happens, so their replies route through)
 *
 * The `inbound_access` policy decides the unlisted-sender behavior:
 *   • mode='auto'  → anyone can DM (legacy / open mode)
 *   • mode='ask'   → unknown senders create an escalation; nothing is sent
 *   • mode='never' → unknown senders are silently dropped
 *
 * Identifiers are channel-normalized: WhatsApp = E.164 with +, Telegram =
 * tg-<numeric_user_id>, email = lowercase, etc. The same person across
 * channels gets one row per channel; the `display_name` ties them together.
 */
import { randomUUID } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { evaluate as evaluatePolicy } from './policies.js';
const log = createChildLogger('autonomy:allowlist');
/**
 * Normalize a channel identifier so lookups are deterministic across
 * inbound paths. WhatsApp arrives as +966xxx, Telegram as tg-<id>, email
 * lowercased.
 */
export function normalizeIdentifier(channel, raw) {
    const v = (raw ?? '').trim();
    if (!v)
        return '';
    if (channel === 'whatsapp' || channel === 'sms') {
        const digits = v.replace(/\D/g, '');
        return digits ? '+' + digits : '';
    }
    if (channel === 'telegram') {
        if (v.startsWith('tg-'))
            return v;
        if (/^\d+$/.test(v))
            return `tg-${v}`;
        return v;
    }
    if (channel === 'email')
        return v.toLowerCase();
    return v;
}
export async function addAllowed(tenantId, input) {
    const id = (input.identifier || '').trim();
    if (!id)
        return null;
    const normalized = normalizeIdentifier(input.channel, id);
    if (!normalized)
        return null;
    const now = new Date().toISOString();
    const existing = await find(tenantId, input.channel, normalized);
    if (existing) {
        const patch = {
            display_name: input.display_name ?? existing.display_name,
            role: input.role ?? existing.role,
            reason: input.reason ?? existing.reason,
            granted_until: input.granted_until ?? existing.granted_until,
            enabled: 1,
        };
        await supabase
            .from('allowed_contacts')
            .update(patch)
            .eq('tenant_id', tenantId)
            .eq('id', existing.id);
        return { ...existing, ...patch, enabled: true };
    }
    const row = {
        id: randomUUID(),
        tenant_id: tenantId,
        channel: input.channel,
        identifier: normalized,
        display_name: input.display_name ?? null,
        role: input.role ?? 'contact',
        added_by: input.added_by ?? 'owner',
        reason: input.reason ?? null,
        granted_until: input.granted_until ?? null,
        enabled: true,
        last_inbound_at: null,
        created_at: now,
    };
    const { error } = await supabase.from('allowed_contacts').insert(row);
    if (error) {
        log.warn({ tenantId, channel: input.channel, error: error.message }, 'addAllowed failed');
        return null;
    }
    log.info({ tenantId, channel: input.channel, identifier: normalized, role: row.role, added_by: row.added_by }, 'contact authorized');
    return row;
}
async function find(tenantId, channel, identifier) {
    const { data } = await supabase
        .from('allowed_contacts')
        .select('*')
        .eq('tenant_id', tenantId)
        .eq('channel', channel)
        .eq('identifier', identifier)
        .maybeSingle();
    return data ?? null;
}
/**
 * Decide whether to route an inbound DM to the brain. The flow:
 *   1. If the sender is on the allow list → allow.
 *   2. Otherwise look up the inbound_access policy:
 *      - 'auto'  → allow but auto-add (legacy / open mode for first-touch UX)
 *      - 'ask'   → block + caller should queue an escalation
 *      - 'never' → block silently
 */
export async function decideInbound(tenantId, channel, identifier, displayName) {
    const normalized = normalizeIdentifier(channel, identifier);
    if (!normalized) {
        return { allowed: false, reason: 'unparsable identifier', contact: null, policyMode: 'never' };
    }
    // Check exact channel match first, then 'any'
    let contact = await find(tenantId, channel, normalized);
    if (!contact) {
        contact = await find(tenantId, 'any', normalized);
    }
    if (contact && contact.enabled) {
        if (contact.granted_until && new Date(contact.granted_until).getTime() < Date.now()) {
            return { allowed: false, reason: 'authorization expired', contact, policyMode: 'never' };
        }
        // Touch last_inbound_at for analytics
        void supabase
            .from('allowed_contacts')
            .update({ last_inbound_at: new Date().toISOString() })
            .eq('tenant_id', tenantId)
            .eq('id', contact.id)
            .then(() => undefined);
        return { allowed: true, reason: `authorized as ${contact.role}`, contact, policyMode: 'auto' };
    }
    // Unknown sender — consult policy
    const decision = await evaluatePolicy({ tenantId, policyKey: 'inbound_access' });
    const mode = decision.mode;
    if (mode === 'auto') {
        // Open mode: auto-add and allow
        const added = await addAllowed(tenantId, {
            channel,
            identifier: normalized,
            display_name: displayName ?? undefined,
            added_by: 'auto',
            role: 'contact',
            reason: 'auto-added on first inbound (policy=auto)',
        });
        return {
            allowed: true,
            reason: 'inbound_access=auto, first-touch',
            contact: added,
            policyMode: 'auto',
        };
    }
    if (mode === 'ask') {
        return {
            allowed: false,
            reason: 'unknown sender, inbound_access=ask — escalating',
            contact: null,
            policyMode: 'ask',
        };
    }
    return {
        allowed: false,
        reason: 'unknown sender, inbound_access=never',
        contact: null,
        policyMode: 'never',
    };
}
/**
 * Convenience helper for outbound tools: when the agent contacts someone
 * (send_email, send_whatsapp, schedule_followup, etc.), auto-allow them so
 * their reply routes through. The tool layer should call this so customers
 * never have to manually re-authorize an inbound reply.
 */
export async function recordOutreach(tenantId, channel, identifier, displayName, role = 'contact') {
    await addAllowed(tenantId, {
        channel,
        identifier,
        display_name: displayName ?? undefined,
        added_by: 'agent_outreach',
        role,
        reason: 'auto-added because agent reached out first',
    });
}
export async function listAllowed(tenantId, channel) {
    let q = supabase
        .from('allowed_contacts')
        .select('*')
        .eq('tenant_id', tenantId)
        .order('created_at', { ascending: false });
    if (channel)
        q = q.eq('channel', channel);
    const { data, error } = await q;
    if (error) {
        log.warn({ error: error.message }, 'listAllowed failed');
        return [];
    }
    return (data ?? []);
}
export async function removeAllowed(tenantId, channel, identifier) {
    const normalized = normalizeIdentifier(channel, identifier);
    if (!normalized)
        return;
    await supabase
        .from('allowed_contacts')
        .delete()
        .eq('tenant_id', tenantId)
        .eq('channel', channel)
        .eq('identifier', normalized);
}
//# sourceMappingURL=allowlist.js.map