/**
 * Insights + Preferences API routes
 *
 * Per-user AI insights and home screen preferences.
 */
import { join } from 'path';
import { homedir } from 'os';
import { existsSync, readdirSync, rmSync } from 'fs';
import * as access from '../../services/access-control.js';
import { findPersonRefForUser } from '../../services/user-person-bridge.js';
import { parseRef, formatUserRef } from '../../core/refs.js';
import { ensurePersonalFolder } from '../../services/personal-folder.js';
import { Workspace } from '../../core/workspace.js';
import { reqGitUser } from './graph-api-access.js';
import { log } from '../../utils/log.js';
/** Slugify a free-text task title into a filesystem-safe entity id. */
export function slugifyTitle(title) {
    const base = title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 60);
    return base || 'task';
}
/** A person/repo id is a kebab/alphanumeric slug — no path, spaces, or dots. */
export function isValidSlug(s) {
    return /^[a-z0-9][a-z0-9-]*$/i.test(s);
}
/** True when a repo-relative folder path contains a traversal segment — the
 *  guard for the capture surface's folder override before it reaches the graph. */
export function isUnsafeFolderPath(folderPath) {
    return folderPath.split('/').some((seg) => seg === '..' || seg === '.');
}
function getUserId(req) {
    return req.user?.id ?? 0;
}
function getAccessibleRepoNames(req, wm, authService) {
    const user = req.user;
    // Open mode (no user) keeps the legacy filter that excludes personal folders.
    // System folders (the asset store) are excluded from graph-health too.
    if (!user)
        return wm.getAllRepoConfigs().filter(r => !r.personal && !r.system).map(r => r.name);
    // Authed: delegate to access-control so personal-folder restrictions and
    // owner-on-shared-folder semantics match the HTTP read endpoints; drop the
    // system asset store so it doesn't skew lint/orphan/graph-health.
    return access.getAccessibleRepos(user, wm, authService).filter(r => !r.system).map(r => r.name);
}
export async function registerInsightsApiRoutes(fastify, workspaceManager, authService, workspacePath, wsHub, chatSessionService) {
    // GET /api/home — aggregated homepage data: the caller's open tasks (full,
    // not windowed — feeds the triage list incl. overdue + undated) plus all
    // meetings within a date window (feeds the week calendar). One round trip
    // across every accessible folder.
    //
    // Filtering is server-side (the bridge is resolved here anyway): when authed
    // we return only the caller's tasks; in open mode (no user) we return all.
    // The CLIENT owns week boundaries and "today" — it passes weekStart/weekEnd
    // and the server does a pure string-range compare on the meeting date, so a
    // server-vs-client timezone difference can't shift a meeting into the wrong
    // week. When the params are absent (very first paint) we fall back to a
    // server-today 3-week window; the client immediately re-fetches with explicit
    // bounds, so the fallback is never load-bearing.
    fastify.get('/api/home', async (req, reply) => {
        const repoNames = getAccessibleRepoNames(req, workspaceManager, authService);
        const user = req.user;
        const openMode = !user;
        const isYmd = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
        const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
        let { weekStart, weekEnd } = req.query;
        if (!isYmd(weekStart) || !isYmd(weekEnd)) {
            const t = new Date();
            const start = new Date(t);
            start.setDate(t.getDate() - 7);
            const end = new Date(t);
            end.setDate(t.getDate() + 13);
            weekStart = ymd(start);
            weekEnd = ymd(end);
        }
        // "Completed today" window as UTC instants. The client sends its local
        // midnight boundaries (timezone-correct, since completed_at is UTC); fall
        // back to the server-local day on first paint (client refetches immediately).
        const isIso = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(s);
        let dayStart = req.query.dayStart;
        let dayEnd = req.query.dayEnd;
        if (!isIso(dayStart) || !isIso(dayEnd)) {
            const t = new Date();
            const s = new Date(t.getFullYear(), t.getMonth(), t.getDate());
            const e = new Date(s);
            e.setDate(s.getDate() + 1);
            dayStart = s.toISOString();
            dayEnd = e.toISOString();
        }
        // Resolve the bridge first so we can scope tasks server-side. Null when the
        // user has no linked person record, or the record lives in a repo they
        // can't read (would leak membership otherwise).
        const myPersonRef = user ? findPersonRefForUser(workspaceManager, user.handle) : null;
        const currentUserPersonId = myPersonRef && (!user || access.hasRepoAccess(user, myPersonRef.repo, workspaceManager, authService))
            ? myPersonRef.entityId
            : null;
        // Account identity is the source of truth. A task is mine if its assignee is
        // my account ref (`[[user:<id>]]`) OR — for legacy data — my linked person
        // entity (`[[my-person]]`). Both forms union to "me". Account-ref matching
        // needs no person record + no repo-access gate, so My Tasks works for every
        // authed user with zero setup. Open mode → all.
        const currentUserId = user ? String(user.id) : null;
        const taskIsMine = (assignee) => {
            if (openMode)
                return true;
            const ref = parseRef(assignee);
            if (!ref)
                return false;
            if (ref.kind === 'user')
                return currentUserId != null && ref.userId === currentUserId;
            return currentUserPersonId != null && ref.id === currentUserPersonId;
        };
        const myOpenTasks = [];
        // Tasks the caller completed today — shown struck-through at the bottom of
        // My Tasks so the day's progress stays visible. Keyed off completed_at
        // (auto-stamped on status → done), not the due date.
        const completedToday = [];
        const meetings = [];
        for (const name of repoNames) {
            let graph;
            try {
                graph = workspaceManager.getGraph(name);
            }
            catch {
                continue;
            }
            for (const entity of graph.search({ entityType: 'task' })) {
                const d = entity.data;
                // Enum: backlog/todo/in-progress/in-review/done/cancelled.
                const status = typeof d.status === 'string' ? d.status : 'todo';
                if (status === 'cancelled')
                    continue;
                if (!taskIsMine(d.assignee))
                    continue;
                const completedAt = typeof d.completed_at === 'string' ? d.completed_at : undefined;
                const base = {
                    id: entity.id,
                    title: d.name ?? entity.id,
                    repo: name,
                    status,
                    priority: d.priority,
                    assignee: d.assignee,
                    dueDate: typeof d.date === 'string' ? d.date : undefined,
                    completedAt,
                };
                if (status === 'done') {
                    // Only today's completions surface; older done tasks stay hidden.
                    // ISO-UTC instants compare lexicographically, so a string range works.
                    if (completedAt && completedAt >= dayStart && completedAt < dayEnd)
                        completedToday.push(base);
                    continue;
                }
                myOpenTasks.push(base);
            }
            for (const entity of graph.search({ entityType: 'event' })) {
                const d = entity.data;
                const date = typeof d.date === 'string' ? d.date : '';
                const day = date.slice(0, 10);
                // Pure string-range compare — no server-side Date math on the boundary.
                if (!day || day < weekStart || day > weekEnd)
                    continue;
                meetings.push({
                    id: entity.id,
                    title: d.name ?? entity.id,
                    repo: name,
                    date,
                    attendees: d.attendees,
                });
            }
        }
        meetings.sort((a, b) => a.date.localeCompare(b.date));
        return reply.send({
            myOpenTasks,
            completedToday,
            meetings,
            currentUserPersonId,
            currentUserId,
            windowStart: weekStart,
            windowEnd: weekEnd,
            openMode,
        });
    });
    // POST /api/home/tasks — fast inline task capture from the homepage.
    // Opinionated: always status=todo, assigned to the caller, dropped into the
    // caller's personal folder (auto-provisioned if missing). The server owns the
    // entity id (slug the title + de-collide) so a double-submit can't create two
    // and a free-text title can't smuggle a path. Disabled for callers without a
    // Assigned to the caller's account by default (account identity is the source
    // of truth — no person entity required, so this works for every authed user).
    fastify.post('/api/home/tasks', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const title = (req.body?.title ?? '').trim();
        if (!title)
            return reply.status(400).send({ error: 'Task title is required' });
        const date = req.body?.date;
        if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
            return reply.status(400).send({ error: 'date must be YYYY-MM-DD' });
        }
        const body = req.body?.body;
        if (body !== undefined && typeof body !== 'string') {
            return reply.status(400).send({ error: 'body must be a string' });
        }
        // Assignee: the caller's own account by default (`[[user:<id>|@handle]]`).
        // An override can target another account (userId) or an external person
        // entity (entityId, written as a bare-slug wikilink). Account identity needs
        // no person record, so there's no bridge precondition here anymore.
        let assignee = formatUserRef(user.id, user.handle);
        const ao = req.body?.assignee;
        if (ao && ao.userId != null) {
            const target = authService.findUserById(Number(ao.userId));
            if (!target) {
                return reply.status(400).send({ error: 'Invalid assignee user' });
            }
            assignee = formatUserRef(target.id, target.handle);
        }
        else if (ao && ao.entityId) {
            const e = String(ao.entityId).trim();
            const r = String(ao.repo ?? '').trim();
            if (!isValidSlug(e) || (r && !isValidSlug(r))) {
                return reply.status(400).send({ error: 'Invalid assignee reference' });
            }
            assignee = `[[${e}]]`;
        }
        // Destination: a validated folder the caller can write to, else the
        // caller's personal folder (auto-provisioned if missing).
        const fo = req.body?.folder;
        let targetRepoName;
        let targetFolderPath = '';
        if (fo && fo.repo) {
            const repo = String(fo.repo).trim();
            const folderPath = String(fo.folderPath ?? '').trim();
            if (isUnsafeFolderPath(folderPath)) {
                return reply.status(400).send({ error: 'Invalid folder path' });
            }
            // The caller's own personal folder isn't a "shared" write per the access
            // model — allow it directly; any other repo goes through the write gate.
            const ownPersonal = workspaceManager.getUserPersonalRepo(user.id)?.name;
            if (repo !== ownPersonal && !access.canWriteRepo(user, repo, workspaceManager, authService)) {
                return reply.status(403).send({ error: 'No write access to the selected folder', code: 'NO_FOLDER_ACCESS' });
            }
            targetRepoName = repo;
            targetFolderPath = folderPath;
        }
        else {
            let personalRepo = workspaceManager.getUserPersonalRepo(user.id);
            if (!personalRepo) {
                try {
                    const ws = new Workspace(workspaceManager.getWorkspacePath());
                    await ensurePersonalFolder(ws, authService, user);
                    workspaceManager.reloadConfig();
                    personalRepo = workspaceManager.getUserPersonalRepo(user.id);
                }
                catch (err) {
                    log.error(`[home] personal-folder provision failed: ${err?.message ?? err}`);
                }
            }
            if (!personalRepo) {
                return reply.status(500).send({ error: 'Could not resolve a personal folder for this task' });
            }
            targetRepoName = personalRepo.name;
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(targetRepoName);
        }
        catch {
            return reply.status(500).send({ error: 'Target folder graph unavailable' });
        }
        // Server-owned id: slug the title, de-collide against existing tasks.
        const baseId = slugifyTitle(title);
        let entityId = baseId;
        for (let i = 2;; i++) {
            try {
                graph.get('task', entityId);
                entityId = `${baseId}-${i}`;
            }
            catch {
                break;
            }
            if (i > 50) {
                entityId = `${baseId}-${Date.now()}`;
                break;
            }
        }
        const now = new Date().toISOString();
        const data = {
            entity_type: 'task',
            entity_id: entityId,
            name: title,
            status: 'todo',
            assignee,
            created_at: now,
            updated_at: now,
            created_by: 'web',
            updated_by: 'web',
        };
        if (date)
            data.date = date;
        try {
            const entity = await graph.create({
                entityType: 'task',
                entityId,
                data,
                content: typeof body === 'string' ? body : '',
                folderPath: targetFolderPath || undefined,
                commitMessage: `Create task/${entityId} from homepage capture`,
                user: reqGitUser(req),
            });
            const actor = user.displayName ?? 'API';
            wsHub?.broadcast({
                type: 'entity_change', action: 'created', repo: targetRepoName,
                entityType: 'task', entityId, actor, source: 'api', timestamp: now,
            });
            return reply.status(201).send({ repo: targetRepoName, entityId, entity });
        }
        catch (err) {
            return reply.status(400).send({ error: err?.message ?? 'Failed to create task' });
        }
    });
    // GET /api/graph/health — graph health report (lint, orphans, stale, broken links)
    fastify.get('/api/graph/health', async (req, reply) => {
        try {
            const { analyzeGraphHealth } = await import('../../services/graph-maintenance.js');
            const report = await analyzeGraphHealth(workspaceManager);
            return reply.send(report);
        }
        catch (err) {
            return reply.status(500).send({ error: err.message ?? 'Health check failed' });
        }
    });
    // GET /api/preferences — get user preferences
    fastify.get('/api/preferences', async (req, reply) => {
        const userId = getUserId(req);
        return reply.send({
            accent_color: authService.getAccentColor(userId),
            onboarded: authService.getOnboarded(userId),
            onboarding_deferred: authService.getOnboardingDeferred(userId),
        });
    });
    // PUT /api/preferences — update user preferences
    fastify.put('/api/preferences', async (req, reply) => {
        const userId = getUserId(req);
        if (req.body?.accent_color !== undefined) {
            authService.setAccentColor(userId, req.body.accent_color);
        }
        if (req.body?.onboarded !== undefined) {
            authService.setOnboarded(userId, req.body.onboarded);
        }
        if (req.body?.onboarding_deferred !== undefined) {
            authService.setOnboardingDeferred(userId, req.body.onboarding_deferred);
        }
        return reply.send({ ok: true });
    });
    // ── Generic per-user settings (today: Deepgram key, calendar, default folder — all meeting-capture) ──
    // Storage is generic JSON; meeting-capture UI is the only current consumer and is itself feature-gated.
    // If future features add their own keys, gate at the key level in their own handlers, not here.
    //
    // Phase 8: `authService.setUserSettings()` partitions keys by Key Registry
    // classification — `user-secret` keys land in `settings_ciphertext`, the
    // rest stay in `settings_json`. `getUserSettings()` decrypts and merges
    // both halves so this endpoint's wire contract is unchanged.
    // GET /api/preferences/settings — get all per-user settings
    fastify.get('/api/preferences/settings', async (req, reply) => {
        const userId = getUserId(req);
        return reply.send(authService.getUserSettings(userId));
    });
    // PATCH /api/preferences/settings — merge partial settings update
    fastify.patch('/api/preferences/settings', async (req, reply) => {
        const userId = getUserId(req);
        if (!req.body || typeof req.body !== 'object') {
            return reply.status(400).send({ error: 'Body must be a JSON object' });
        }
        // setUserSettings is registry-aware: it classifies each merged key via
        // getKeyDef and routes user-secret entries through the encrypted column.
        authService.setUserSettings(userId, req.body);
        return reply.send(authService.getUserSettings(userId));
    });
    // POST /api/reset — full per-user fresh-start. Wipes sessions + memory
    // AND resets DB-backed agent state: onboarded flag and meeting-capture
    // settings. Preserves the user account, accent color, and API tokens.
    // Use this for QA flows where a tester needs to re-experience onboarding
    // from scratch.
    //
    // (Pre-C5 there was also a `/api/clear` route for the narrower "wipe
    // sessions/memory only" case. It was removed when `studiograph clear`
    // was folded into `studiograph reset` — see cli-owner-audit §3.3.)
    fastify.post('/api/reset', async (req, reply) => {
        const userId = getUserId(req);
        if (!userId)
            return reply.status(401).send({ error: 'Authentication required' });
        try {
            const baseDir = join(homedir(), '.studiograph');
            const sessions = clearUserDir(join(baseDir, 'sessions', String(userId)), '.json');
            const memory = clearUserDir(join(baseDir, 'memory', String(userId)), '.md');
            authService.resetUserState(userId);
            // Also wipe the server-stored chat history (sessions + messages). Without
            // this the rail's transcript rows would survive a "fresh start" and
            // re-hydrate on the next list fetch.
            chatSessionService?.deleteAllForUser(userId);
            // See note in /api/clear above — broadcast so any open tabs drop
            // their localStorage transcript and re-fetch the user's now-reset
            // state (onboarded flag, etc.).
            wsHub?.broadcastToUser?.(String(userId), {
                type: 'state_cleared',
                target: 'all',
                timestamp: new Date().toISOString(),
            });
            return reply.send({ ok: true, cleared: { sessions, memory }, dbStateReset: true });
        }
        catch (err) {
            log.error('[reset] failed:', err?.stack || err?.message || err);
            return reply.status(500).send({ error: err?.message || 'Reset failed' });
        }
    });
}
function clearUserDir(dirPath, ext) {
    if (!existsSync(dirPath))
        return 0;
    const files = readdirSync(dirPath).filter(f => f.endsWith(ext));
    for (const f of files)
        rmSync(join(dirPath, f));
    return files.length;
}
//# sourceMappingURL=insights-api.js.map