/**
 * Redirect graph utilities — pure functions for chain/loop detection,
 * loop-prevention on write, path normalization, and stale detection.
 *
 * Lives in cms-core (not plugin-redirects) because the API layer needs it and
 * the dependency direction forbids core importing a plugin. The plugin's
 * request-time middleware can stay independent.
 */
/**
 * Normalize a path for matching: drop query/hash, ensure a single leading
 * slash, strip a trailing slash (except root), and lowercase. External
 * absolute URLs are returned lowercased without a forced leading slash.
 */
export function normalizeRedirectPath(input) {
    if (!input)
        return '/';
    let p = String(input).trim();
    if (/^https?:\/\//i.test(p))
        return p.toLowerCase();
    // Strip query + hash.
    p = p.split('#')[0].split('?')[0];
    if (!p.startsWith('/'))
        p = `/${p}`;
    if (p.length > 1 && p.endsWith('/'))
        p = p.slice(0, -1);
    return p.toLowerCase();
}
/** True when a redirect points to an external (absolute) destination. */
export function isExternal(to) {
    return /^https?:\/\//i.test(to.trim());
}
/** Build a normalized from -> edge map of enabled rules. */
export function buildEdgeMap(rules) {
    const map = new Map();
    for (const r of rules) {
        if (r.enabled === false)
            continue;
        const fromNorm = normalizeRedirectPath(r.from);
        if (map.has(fromNorm))
            continue; // first rule wins (priority handled upstream)
        map.set(fromNorm, { to: r.to, toNorm: normalizeRedirectPath(r.to), id: r.id });
    }
    return map;
}
/**
 * Follow the redirect map from `start` until a path with no further mapping
 * (terminal) or a cycle is reached. Bounded by `maxHops`.
 */
export function resolveChain(map, start, maxHops = 10) {
    const startNorm = normalizeRedirectPath(start);
    const hops = [startNorm];
    const seen = new Set([startNorm]);
    let current = startNorm;
    for (let i = 0; i < maxHops; i++) {
        const edge = map.get(current);
        if (!edge) {
            return { hops, terminal: current, loop: false };
        }
        const next = edge.toNorm;
        hops.push(next);
        if (seen.has(next)) {
            return { hops, terminal: next, loop: true };
        }
        seen.add(next);
        current = next;
    }
    return { hops, terminal: current, loop: false };
}
/**
 * Detect multi-hop chains (3+ nodes => 2+ redirects) and loops across the
 * rule set. Returns one entry per chain/loop keyed by a stable id derived from
 * the starting source.
 */
export function detectChainsAndLoops(rules) {
    const map = buildEdgeMap(rules);
    const results = [];
    const reported = new Set();
    for (const [from] of map) {
        if (reported.has(from))
            continue;
        const res = resolveChain(map, from);
        if (res.loop) {
            // Only report loops once per cycle (mark all hops reported).
            res.hops.forEach((h) => reported.add(h));
            const breakTo = res.hops.find((h) => h !== from && h !== res.terminal) ?? res.terminal;
            results.push({
                id: `loop:${from}`,
                kind: 'loop',
                hops: res.hops,
                suggestedFrom: from,
                suggestedTo: breakTo,
            });
        }
        else if (res.hops.length >= 3) {
            // Mark intermediate sources reported so we don't emit sub-chains.
            res.hops.slice(0, -1).forEach((h) => reported.add(h));
            results.push({
                id: `chain:${from}`,
                kind: 'chain',
                hops: res.hops,
                suggestedFrom: from,
                suggestedTo: res.terminal,
            });
        }
    }
    return results;
}
/**
 * Would adding (or updating to) `from -> to` create a loop or self-redirect?
 * Simulates the new edge over the existing rule set.
 */
export function wouldCreateLoop(rules, from, to, ignoreId) {
    const fromNorm = normalizeRedirectPath(from);
    const toNorm = normalizeRedirectPath(to);
    if (fromNorm === toNorm)
        return true;
    if (isExternal(to))
        return false; // external targets can't loop internally
    const others = rules.filter((r) => r.id !== ignoreId);
    const map = buildEdgeMap(others);
    map.set(fromNorm, { to, toNorm, id: ignoreId ?? '__new__' });
    const res = resolveChain(map, fromNorm);
    return res.loop;
}
const STALE_DAYS = 60;
/** A redirect is stale when it hasn't been hit in 60+ days. */
export function isRedirectStale(lastHitAt, now = new Date()) {
    if (!lastHitAt)
        return true;
    const last = typeof lastHitAt === 'string' ? new Date(lastHitAt) : lastHitAt;
    if (Number.isNaN(last.getTime()))
        return true;
    const ageMs = now.getTime() - last.getTime();
    return ageMs > STALE_DAYS * 24 * 60 * 60 * 1000;
}
//# sourceMappingURL=graph.js.map