/**
 * notifications/service.ts
 *
 * NotificationService — composes the read source (subscribed teams), the pure
 * matcher (report → channels), and the deliver adapters (one per channel type)
 * into a fire-and-forget dispatcher behind the `NotificationServicePort`. The
 * composition root wires it into `AppContext.notifications` only when
 * `SLACK_BOT_TOKEN` is set, exactly as `loadSinks` is gated.
 *
 * Fire-and-forget (P6): neither method throws. The publish step has already
 * written the report to the system of record by the time it dispatches, so a
 * notification failure must never propagate back into the pipeline. A rejecting
 * source yields an empty result; a rejecting adapter is caught by the retry
 * helper and recorded as a `failed` outcome. Deliveries run concurrently via
 * `Promise.allSettled` so one failing channel never blocks the rest.
 *
 * @see packages/core/src/ports/notification.ts — the port this implements
 * @see packages/core/src/notifications/match.ts — the pure matcher
 * @see packages/eval/src/orchestration/steps/publish-report-step.ts — runSinks,
 *   the fire-and-forget precedent this mirrors
 */
import { channelKey, matchReportToChannels, } from "../_vendor/ailf-core/index.js";
import { withRetryResult } from "../sinks/retry.js";
import { SlackNotificationAdapter } from "./slack-adapter.js";
import { ContentLakeTeamSubscriptionSource } from "./team-subscription-source.js";
import { WebhookNotificationAdapter } from "./webhook-adapter.js";
export class NotificationService {
    adapters;
    retry;
    source;
    constructor(options) {
        this.adapters = new Map(options.adapters.map((a) => [a.type, a]));
        this.retry = options.retry ?? {};
        this.source = options.source;
    }
    async dispatchReport(report) {
        try {
            const teams = await this.source.loadSubscribedTeams();
            const deliveries = matchReportToChannels(report, teams, "immediate");
            return await this.deliverAll(deliveries, { kind: "report", report });
        }
        catch (error) {
            console.warn(`NotificationService.dispatchReport failed: ${errorMessage(error)}`);
            return [];
        }
    }
    async dispatchDigest(digest, cadence, teams) {
        try {
            const items = selectChannelsByCadence(teams, cadence).map((target) => ({
                digest,
                target,
            }));
            return await this.deliverScoped(items, cadence);
        }
        catch (error) {
            console.warn(`NotificationService.dispatchDigest failed: ${errorMessage(error)}`);
            return [];
        }
    }
    /**
     * Deliver a per-channel scoped digest: each item pairs a resolved channel
     * target with the digest built from *that channel's* windowed reports. Unlike
     * `dispatchDigest` (one shared roll-up to every cadence channel), each
     * delivery carries its own digest, so a `repos`-scoped channel receives a
     * roll-up of only its repos' reports.
     *
     * This is the M6 notify-sweep delivery path: the sweep does the windowing and
     * per-channel scope/event matching (via the pure `matchReportToChannels`) and
     * hands the resolved per-channel digests here. Never throws (P6). Not on the
     * core `NotificationServicePort` — that contract covers the pipeline's inline
     * dispatch and the cadence-broadcast primitive; the sweep uses the concrete
     * service it builds via `createNotificationService`.
     */
    async dispatchScopedDigests(items, cadence) {
        try {
            return await this.deliverScoped(items, cadence);
        }
        catch (error) {
            console.warn(`NotificationService.dispatchScopedDigests failed: ${errorMessage(error)}`);
            return [];
        }
    }
    async deliverAll(deliveries, content) {
        if (deliveries.length === 0)
            return [];
        const settled = await Promise.allSettled(deliveries.map((target) => this.deliverOne(target, content)));
        return settled.map((result, i) => result.status === "fulfilled"
            ? result.value
            : rejectedResult(deliveries[i], result.reason));
    }
    /**
     * Deliver each (target, digest) pair concurrently — one digest per channel.
     * Shared core for `dispatchDigest` (same digest to every cadence channel) and
     * `dispatchScopedDigests` (per-channel scoped digests). Mirrors `deliverAll`,
     * but pairs each delivery with its own digest content.
     */
    async deliverScoped(items, cadence) {
        if (items.length === 0)
            return [];
        const settled = await Promise.allSettled(items.map(({ digest, target }) => this.deliverOne(target, { cadence, digest, kind: "digest" })));
        return settled.map((result, i) => result.status === "fulfilled"
            ? result.value
            : rejectedResult(items[i].target, result.reason));
    }
    async deliverOne(target, content) {
        const base = {
            channelKey: channelKey(target.channel),
            channelType: target.channel.type,
            team: target.team.slug,
        };
        const adapter = this.adapters.get(target.channel.type);
        if (!adapter) {
            return {
                ...base,
                outcome: { reason: "no_adapter_for_type", status: "skipped" },
            };
        }
        const outcome = await withRetryResult(() => adapter.deliver(target, content), {
            isFailed: (o) => o.status === "failed",
            onThrow: (error) => ({ error: errorMessage(error), status: "failed" }),
            ...this.retry,
        });
        return { ...base, outcome };
    }
}
/**
 * Channels subscribed at a given cadence, deduplicated by channel key. Digests
 * select purely by cadence — there is no report to event/scope-match. Folding
 * each channel's scope into a per-channel windowed digest is deferred to the M6
 * sweep; here every cadence-matched channel receives the same roll-up.
 */
function selectChannelsByCadence(teams, cadence) {
    const seen = new Set();
    const out = [];
    for (const team of teams) {
        for (const channel of team.notifications ?? []) {
            if ((channel.delivery ?? "immediate") !== cadence)
                continue;
            const key = channelKey(channel);
            if (seen.has(key))
                continue;
            seen.add(key);
            out.push({
                channel,
                team: { displayName: team.displayName, slug: team.slug },
            });
        }
    }
    return out;
}
function errorMessage(error) {
    return error instanceof Error ? error.message : String(error);
}
/**
 * Map a rejected settle to a `failed` result. `deliverOne` is written never to
 * reject, so this is defensive — it keeps the per-channel no-throw guarantee
 * total even if a future change lets a rejection escape.
 */
function rejectedResult(target, reason) {
    return {
        channelKey: channelKey(target.channel),
        channelType: target.channel.type,
        outcome: { error: errorMessage(reason), status: "failed" },
        team: target.team.slug,
    };
}
/**
 * Build the production NotificationService: a Content Lake team source plus the
 * channel adapters whose credentials are present. The Slack adapter is added
 * when `token` (SLACK_BOT_TOKEN) is set; the webhook adapter when
 * `webhookSigningSecret` (AILF_WEBHOOK_SIGNING_SECRET) is set. At least one is
 * required by the composition-root gate before this is called.
 */
export function createNotificationService(opts) {
    const adapters = [];
    if (opts.token)
        adapters.push(new SlackNotificationAdapter(opts.token, opts.dashboardBaseUrl));
    if (opts.webhookSigningSecret) {
        adapters.push(new WebhookNotificationAdapter({
            signingSecret: opts.webhookSigningSecret,
            ...(opts.webhookEnv ? { env: opts.webhookEnv } : {}),
        }));
    }
    return new NotificationService({
        adapters,
        source: new ContentLakeTeamSubscriptionSource(opts.client),
    });
}
