/**
 * User ↔ Person bridge
 *
 * Resolves between auth.db user accounts and person entities. The pointer
 * lives on the person side as `user_handle` (scalar handle, not a wikilink —
 * users are out-of-graph identities). Workspace-unique: at most one person
 * entity per auth handle, workspace-wide.
 *
 * The lookup primitives here are intentionally cheap (in-memory scan of
 * already-loaded person entities). No persistent cache in v1 — if profiling
 * later shows this is hot, add one with explicit invalidation on entity
 * writes and on createUser/deleteUser.
 */
import { isWorkspaceOwner } from './auth-service.js';
import { log } from '../utils/log.js';
export class BridgeError extends Error {
    code;
    details;
    constructor(code, message, details) {
        super(message);
        this.code = code;
        this.details = details;
        this.name = 'BridgeError';
    }
}
/**
 * Forward lookup: given an auth user handle, return the person entity that
 * represents them. Returns null when the user has no linked entity (legacy,
 * unprovisioned, or explicitly unlinked).
 */
export function findPersonRefForUser(workspace, userHandle) {
    for (const entity of workspace.list('person')) {
        const data = entity.data;
        if (data.user_handle === userHandle) {
            return { repo: entity.repoName, entityId: data.entity_id };
        }
    }
    return null;
}
/**
 * Reverse lookup: given a person-entity target ID (the wikilink target, e.g.
 * `fritz` from `[[fritz]]`), return the auth user that entity is linked to.
 * Returns null when the entity isn't linked, doesn't exist, or refers to a
 * non-existent user (pre-link case).
 */
export function findUserForPersonRef(workspace, authService, targetId) {
    // The wikilink target ID alone isn't unique across repos — multiple repos
    // can have a `person/fritz`. We scan for the first match carrying a
    // user_handle. In practice the team-repo entity is the canonical one and
    // satellite copies don't carry the handle, so the first match is right.
    for (const entity of workspace.list('person')) {
        const data = entity.data;
        if (data.entity_id === targetId && typeof data.user_handle === 'string') {
            return authService.findUserByHandle(data.user_handle) ?? null;
        }
    }
    return null;
}
/**
 * Bulk forward map: handle → personRef for every linked person in the
 * workspace. Useful for `list_workspace_users` and for migration reporting.
 */
export function getHandleToPersonRefMap(workspace) {
    const map = new Map();
    for (const entity of workspace.list('person')) {
        const data = entity.data;
        if (typeof data.user_handle === 'string') {
            // First write wins. If two entities somehow carry the same handle,
            // the uniqueness check at write time should have blocked it; if it
            // slipped through (e.g. seeded data), we surface only one and the
            // migration script's ambiguous-skip path catches the rest.
            if (!map.has(data.user_handle)) {
                map.set(data.user_handle, {
                    repo: entity.repoName,
                    entityId: data.entity_id,
                });
            }
        }
    }
    return map;
}
/**
 * Workspace-wide uniqueness check. Throws BridgeError(USER_HANDLE_CONFLICT)
 * when another person entity already carries `userHandle`. The `exceptRef`
 * lets update paths skip the check for the entity being written.
 *
 * Called from the write path (graph-api create/update, agent graph-tools
 * create/update) before delegating to graph.create/update.
 */
export function assertUserHandleUnique(workspace, userHandle, exceptRef) {
    for (const entity of workspace.list('person')) {
        const data = entity.data;
        if (data.user_handle !== userHandle)
            continue;
        const entityId = data.entity_id;
        if (exceptRef && exceptRef.repo === entity.repoName && exceptRef.entityId === entityId) {
            continue;
        }
        throw new BridgeError('USER_HANDLE_CONFLICT', `user_handle "${userHandle}" is already linked to ${entity.repoName}/${entityId}`, { conflictingRef: { repo: entity.repoName, entityId } });
    }
}
/**
 * Authz gate: only workspace owners can stamp or change `user_handle` on a
 * person entity. Mirrors the `ownerOnly()` gate on the dedicated
 * `link_person_to_user` / `unlink_person_from_user` agent tools — without
 * this, a non-owner member with folder-write access could rewrite the
 * bridge via the generic update_entity REST/agent path, "claiming"
 * another user's auth identity through the agent's Current User prompt.
 *
 * Rules:
 *   - Payload doesn't include `user_handle` → no-op (caller wasn't trying
 *     to change the bridge).
 *   - Payload's value equals the current stored value → no-op (a form
 *     resubmit including the unchanged field shouldn't 403).
 *   - Caller is a workspace owner → allowed.
 *   - Anyone else → throw OWNER_REQUIRED.
 *
 * Pass `currentHandle = ''` for create paths (no prior entity).
 */
export function assertCanChangeUserHandle(authUser, currentHandle, newHandle) {
    if (newHandle === undefined)
        return;
    const normalized = typeof newHandle === 'string' ? newHandle : '';
    if (normalized === currentHandle)
        return;
    if (authUser && isWorkspaceOwner(authUser.role))
        return;
    throw new BridgeError('OWNER_REQUIRED', 'Workspace owner role required to change the linked account on a person entity.', { currentHandle, attemptedHandle: normalized });
}
/**
 * Inspect a frontmatter payload for `user_handle` and, if present, validate
 * uniqueness against the current workspace state. No-op when the field is
 * absent or empty.
 *
 * `exceptRef` is the entity being written (for updates) so the check
 * doesn't reject an entity for clashing with itself.
 */
export function validateUserHandleInPayload(workspace, data, exceptRef) {
    const handle = data.user_handle;
    if (typeof handle !== 'string' || handle.length === 0)
        return;
    assertUserHandleUnique(workspace, handle, exceptRef);
}
/**
 * Map an AuthUser to a GitUser for commit attribution. Mirrors `reqGitUser`
 * in graph-api-access.ts — kept here so the registration / CLI sites
 * (which don't have a Fastify request) can call into the bridge without
 * importing the request-scoped helper.
 */
export function authUserToGitUser(user) {
    return {
        id: user.handle,
        name: user.displayName,
        email: `${user.handle}@studiograph.local`,
    };
}
/**
 * Resolve a person entity for a freshly-registered (or existing) user.
 *
 *   1. If the user is already linked, return.
 *   2. If a person entity carries `email === user.email` and no `user_handle`,
 *      stamp the handle onto it ("linked" — preserves the user-edited record).
 *   3. Otherwise auto-create a person entity in the workspace's team repo
 *      with the user's name/email and the new handle.
 *
 * Returns `{ action: 'skipped' }` when no suitable team repo exists and no
 * email-matchable entity is available — the caller can warn the operator
 * but should never block user creation on this.
 */
export async function ensurePersonForUser(workspace, authService, authUser, gitUser) {
    // 1. Already linked? No-op.
    const existing = findPersonRefForUser(workspace, authUser.handle);
    if (existing) {
        return { action: 'already-linked', ref: existing };
    }
    // 2. Email-match an existing entity that has no handle yet. Don't touch
    //    entities that already carry a different handle — that's the
    //    multi-match case the migration script logs as ambiguous.
    if (authUser.email) {
        const normalized = authUser.email.toLowerCase().trim();
        const candidates = [];
        for (const entity of workspace.list('person')) {
            const data = entity.data;
            const entityEmail = typeof data.email === 'string' ? data.email.toLowerCase().trim() : '';
            if (entityEmail !== normalized)
                continue;
            if (typeof data.user_handle === 'string' && data.user_handle.length > 0)
                continue;
            candidates.push({ repo: entity.repoName, entityId: data.entity_id });
        }
        if (candidates.length === 1) {
            const target = candidates[0];
            const graph = workspace.getGraph(target.repo);
            // graph.update merges the supplied data over the current frontmatter,
            // so we only need to write the new field + audit metadata.
            await graph.update({
                entityType: 'person',
                entityId: target.entityId,
                data: {
                    user_handle: authUser.handle,
                    updated_at: new Date().toISOString(),
                    updated_by: gitUser.id,
                },
                commitMessage: `Link person/${target.entityId} to user ${authUser.handle}`,
                user: gitUser,
            });
            log.info(`[user-person-bridge] linked person ${target.repo}/${target.entityId} to user ${authUser.handle}`);
            return { action: 'linked', ref: target };
        }
        if (candidates.length > 1) {
            log.warn(`[user-person-bridge] ${candidates.length} person entities match email ${normalized} for user ${authUser.handle}; ` +
                `skipping auto-link. Resolve via the agent's link_person_to_user tool or the Profile settings.`);
            // Fall through to auto-create — the user still gets a record, and the
            // ambiguous matches can be reconciled later.
        }
    }
    // 3. Auto-create in a writable repo. If none can be chosen, skip with a
    //    warning — never block user creation on this. The user can link or
    //    create a record themselves later (home CTA / Settings → Profile).
    const targetRepo = pickProvisioningRepo(workspace, authService, authUser.id, authUser.role);
    if (!targetRepo) {
        log.warn(`[user-person-bridge] no writable repo available for user ${authUser.handle}; ` +
            `person entity NOT auto-provisioned. They can link/create one from Settings → Profile.`);
        return { action: 'skipped', reason: 'no-writable-repo' };
    }
    const graph = workspace.getGraph(targetRepo);
    const entityId = allocateEntityId(workspace, authUser.handle);
    const now = new Date().toISOString();
    await graph.create({
        entityType: 'person',
        entityId,
        data: {
            entity_type: 'person',
            entity_id: entityId,
            name: authUser.displayName,
            email: authUser.email,
            category: 'employee',
            status: 'current',
            user_handle: authUser.handle,
            created_at: now,
            updated_at: now,
            created_by: gitUser.id,
            updated_by: gitUser.id,
        },
        content: '',
        commitMessage: `Auto-provision person/${entityId} for user ${authUser.handle}`,
        user: gitUser,
    });
    log.info(`[user-person-bridge] auto-provisioned person ${targetRepo}/${entityId} for user ${authUser.handle}`);
    return { action: 'created', ref: { repo: targetRepo, entityId } };
}
/**
 * Self-service link: stamp `authUser.handle` onto a SPECIFIC person entity the
 * user picked (the home "which record is you?" picker). Unlike the owner-gated
 * `link_person_to_user` (which can target ANY user's account), this only ever
 * writes the CALLER's OWN handle onto an UNLINKED person — so it's safe without
 * an owner gate: it can't claim another user's identity (their record already
 * carries a handle → rejected) and confers no privileges (auth role comes from
 * the account, not the entity).
 *
 * Throws `BridgeError`:
 *   - `PERSON_ALREADY_LINKED` — the target already carries a different handle.
 *   - `USER_HANDLE_CONFLICT`  — the caller's handle is already on another person.
 * No-op when the target already carries the caller's handle (idempotent).
 *
 * The caller resolves `ref` (including read-access) and the "already linked
 * elsewhere" precheck before calling.
 */
export async function linkChosenPersonToUser(workspace, authUser, ref, gitUser) {
    const entity = workspace.getGraph(ref.repo).get('person', ref.entityId);
    const current = entity.data.user_handle;
    if (typeof current === 'string' && current.length > 0) {
        if (current === authUser.handle)
            return; // already linked to me — idempotent
        throw new BridgeError('PERSON_ALREADY_LINKED', `Person ${ref.repo}/${ref.entityId} is already linked to user "${current}".`, { currentHandle: current });
    }
    // Workspace-wide uniqueness: my handle must not already sit on another person.
    assertUserHandleUnique(workspace, authUser.handle, ref);
    await workspace.getGraph(ref.repo).update({
        entityType: 'person',
        entityId: ref.entityId,
        data: {
            user_handle: authUser.handle,
            updated_at: new Date().toISOString(),
            updated_by: gitUser.id,
        },
        commitMessage: `Link person/${ref.entityId} to user ${authUser.handle} (self-service)`,
        user: gitUser,
    });
}
/**
 * Pick the repo where a registering user's person entity should be
 * auto-provisioned (the email-match path links in place; only the create
 * branch needs a destination).
 *
 *   1. First non-private repo the user actually has write access to
 *      (covers the first-owner bootstrap path where every non-personal
 *      repo gets admin granted before this runs).
 *   2. User's personal repo as last-resort.
 *
 * The access check closes the squat vector: a non-owner member no longer
 * silently plants a person entity in a shared repo they have no
 * folder-access to.
 */
function pickProvisioningRepo(workspace, authService, userId, userRole) {
    // Workspace owners + folder-admins-of-shared-repos can plant records in
    // shared space; others can't. Iterate non-private repos and pick the
    // first one the user can write to.
    for (const repo of workspace.getAllRepoConfigs()) {
        if (repo.personal)
            continue;
        if (userRole === 'owner')
            return repo.name;
        const folderRole = authService.getFolderRole(userId, repo.name);
        if (folderRole === 'admin' || folderRole === 'editor')
            return repo.name;
    }
    const personal = workspace.getUserPersonalRepo(userId);
    return personal ? personal.name : null;
}
/**
 * Choose an entity ID for the auto-provisioned person. Defaults to the
 * user's handle (already kebab-case and workspace-unique among users), but
 * falls back to `<handle>-2`, `<handle>-3`, … if a non-user person entity
 * already occupies that slug somewhere in the workspace. Mirrors
 * `AuthService.allocateHandle`'s collision strategy.
 */
function allocateEntityId(workspace, handle) {
    if (!workspace.findEntityById(handle))
        return handle;
    for (let suffix = 2; suffix < 1000; suffix++) {
        const candidate = `${handle}-${suffix}`;
        if (!workspace.findEntityById(candidate))
            return candidate;
    }
    // Pathological. Defer to the handle (graph.create will reject and we'll
    // surface the conflict to the caller).
    return handle;
}
//# sourceMappingURL=user-person-bridge.js.map