// engine/workspace-manager.ts — SPEC-363: Multi-tenant Workspace Dashboard
import { getProjects, addProject } from '../storage/global-projects-store.js';
import { specStore } from '../storage/index.js';
import { hashProjectPath, readJson } from '../storage/base-store.js';
import { glob } from 'glob';
// ---------------------------------------------------------------------------
// SPEC-789: Project discovery fallback
// ---------------------------------------------------------------------------
/**
 * Resolve the data/projects base directory.
 * Uses PLANU_PROJECTS_DIR env var for testability, falls back to `data/projects`.
 */
function projectsBaseDir() {
    return process.env.PLANU_PROJECTS_DIR ?? 'data/projects';
}
/**
 * Walk `data/projects/{hash}/knowledge.json` to discover project paths that
 * were initialized but never explicitly registered in the global registry.
 * Each knowledge.json contains the original `projectPath` field.
 *
 * Returns a deduplicated list merged with the registry list.
 * Also backfills discovered projects into the registry (fire-and-forget) so
 * subsequent calls use the fast registry path.
 */
async function getProjectsWithFallback() {
    const registered = await getProjects();
    const registeredPaths = new Set(registered.map((p) => p.path));
    // Discover hashes from on-disk directories
    const pattern = `${projectsBaseDir()}/*/knowledge.json`;
    let knowledgeFiles;
    try {
        knowledgeFiles = await glob(pattern, { absolute: false });
    }
    catch {
        return registered;
    }
    if (knowledgeFiles.length === 0) {
        return registered;
    }
    const discovered = [];
    for (const kf of knowledgeFiles) {
        try {
            const knowledge = await readJson(kf, {});
            const projectPath = knowledge.projectPath;
            if (typeof projectPath !== 'string' || projectPath === '') {
                continue;
            }
            if (!registeredPaths.has(projectPath)) {
                // Backfill into registry fire-and-forget so next call is instant
                addProject(projectPath).catch(() => {
                    /* best-effort */
                });
                const hash = hashProjectPath(projectPath);
                discovered.push({
                    path: projectPath,
                    hash,
                    registeredAt: new Date(0).toISOString(),
                    specCount: 0,
                });
                registeredPaths.add(projectPath);
            }
        }
        catch {
            /* skip unreadable files */
        }
    }
    return [...registered, ...discovered];
}
// ---------------------------------------------------------------------------
// Stats per project
// ---------------------------------------------------------------------------
// Status weights for health score computation — higher means closer to done
const STATUS_WEIGHTS = {
    done: 1.0,
    review: 0.7,
    implementing: 0.6,
    approved: 0.4,
    draft: 0.1,
};
const VELOCITY_BONUS_DAYS = 14;
const VELOCITY_PENALTY_DAYS = 60;
const VELOCITY_BONUS = 10;
const VELOCITY_PENALTY = 10;
function computeHealthScore(specs) {
    const totalCount = specs.length;
    if (totalCount === 0) {
        return 100;
    }
    const weightedSum = specs.reduce((sum, s) => {
        const weight = STATUS_WEIGHTS[s.status] ?? 0;
        return sum + weight;
    }, 0);
    const base = Math.round((weightedSum / totalCount) * 100);
    // Velocity bonus/penalty based on recent done activity
    const now = Date.now();
    const bonusThreshold = VELOCITY_BONUS_DAYS * 24 * 60 * 60 * 1000;
    const penaltyThreshold = VELOCITY_PENALTY_DAYS * 24 * 60 * 60 * 1000;
    const doneSpecs = specs.filter((s) => s.status === 'done');
    const hasRecentDone = doneSpecs.some((s) => now - new Date(s.updatedAt).getTime() <= bonusThreshold);
    const hasOldDone = doneSpecs.some((s) => now - new Date(s.updatedAt).getTime() <= penaltyThreshold);
    let score = base;
    if (hasRecentDone) {
        score = Math.min(100, score + VELOCITY_BONUS);
    }
    else if (doneSpecs.length === 0 || !hasOldDone) {
        score = Math.max(0, score - VELOCITY_PENALTY);
    }
    return score;
}
async function getProjectStats(project) {
    const projectId = hashProjectPath(project.path);
    try {
        const specs = await specStore.listSpecs(projectId);
        const doneCount = specs.filter((s) => s.status === 'done').length;
        const inProgressCount = specs.filter((s) => s.status === 'implementing').length;
        const blockedCount = specs.filter((s) => s.status === 'review').length;
        const approvedCount = specs.filter((s) => s.status === 'approved').length;
        const totalCount = specs.length;
        const healthScore = computeHealthScore(specs);
        return {
            path: project.path,
            hash: projectId,
            registeredAt: project.registeredAt,
            specCount: totalCount,
            doneCount,
            implementingCount: inProgressCount,
            reviewCount: blockedCount,
            approvedCount,
            healthScore,
        };
    }
    catch {
        return {
            path: project.path,
            hash: projectId,
            registeredAt: project.registeredAt,
            specCount: 0,
            doneCount: 0,
            implementingCount: 0,
            reviewCount: 0,
            approvedCount: 0,
            healthScore: 0,
        };
    }
}
// ---------------------------------------------------------------------------
// Overview
// ---------------------------------------------------------------------------
export async function buildWorkspaceOverview(healthThreshold = 50) {
    const projects = await getProjectsWithFallback();
    const statsArr = await Promise.all(projects.map((p) => getProjectStats(p)));
    void healthThreshold; // used for filtering in health, not overview
    return {
        projectCount: statsArr.length,
        projects: statsArr,
        generatedAt: new Date().toISOString(),
    };
}
// ---------------------------------------------------------------------------
// Health
// ---------------------------------------------------------------------------
export async function buildWorkspaceHealth(healthThreshold = 50) {
    const overview = await buildWorkspaceOverview(healthThreshold);
    const stats = overview.projects;
    const totalSpecs = stats.reduce((s, p) => s + p.specCount, 0);
    const totalDone = stats.reduce((s, p) => s + p.doneCount, 0);
    const totalInProgress = stats.reduce((s, p) => s + p.implementingCount, 0);
    const totalBlocked = stats.reduce((s, p) => s + p.reviewCount, 0);
    const avgHealth = stats.length === 0
        ? 100
        : Math.round(stats.reduce((s, p) => s + p.healthScore, 0) / stats.length);
    const projectsAboveThreshold = stats.filter((p) => p.healthScore >= healthThreshold).length;
    const projectsBelowThreshold = stats.filter((p) => p.healthScore < healthThreshold).length;
    return {
        totalProjects: stats.length,
        totalSpecs,
        totalDone,
        totalImplementing: totalInProgress,
        totalInReview: totalBlocked,
        averageHealthScore: avgHealth,
        projectsAboveThreshold,
        projectsBelowThreshold,
        generatedAt: new Date().toISOString(),
    };
}
// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
export async function runBulkOperation(operation, projectPaths, dryRun = false) {
    const allProjects = await getProjectsWithFallback();
    let targets = allProjects;
    if (projectPaths !== undefined && projectPaths.length > 0) {
        targets = allProjects.filter((p) => projectPaths.includes(p.path));
    }
    const results = [];
    for (const project of targets) {
        if (dryRun) {
            results.push({
                path: project.path,
                status: 'skipped',
                message: `dry-run: would run ${operation}`,
            });
            continue;
        }
        try {
            if (operation === 'validate-all') {
                const projectId = hashProjectPath(project.path);
                const specs = await specStore.listSpecs(projectId);
                results.push({
                    path: project.path,
                    status: 'ok',
                    message: `${String(specs.length)} specs found`,
                });
            }
            else {
                // generate-reports
                results.push({
                    path: project.path,
                    status: 'ok',
                    message: 'report queued',
                });
            }
        }
        catch (err) {
            results.push({
                path: project.path,
                status: 'error',
                message: err instanceof Error ? err.message : String(err),
            });
        }
    }
    return {
        operation,
        dryRun,
        projectsAffected: targets.length,
        results,
    };
}
// ---------------------------------------------------------------------------
// Workspace report (HTML)
// ---------------------------------------------------------------------------
export async function generateWorkspaceReport() {
    const health = await buildWorkspaceHealth();
    const overview = await buildWorkspaceOverview();
    const rows = overview.projects
        .map((p) => `<tr>` +
        `<td>${escHtml(p.path)}</td>` +
        `<td>${String(p.specCount)}</td>` +
        `<td>${String(p.doneCount)}</td>` +
        `<td>${String(p.implementingCount)}</td>` +
        `<td>${String(p.reviewCount)}</td>` +
        `<td style="color:${p.healthScore >= 70 ? 'green' : p.healthScore >= 40 ? 'orange' : 'red'}">${String(p.healthScore)}%</td>` +
        `</tr>`)
        .join('\n');
    return (`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Workspace Report</title>` +
        `<style>body{font-family:sans-serif;margin:20px}table{border-collapse:collapse;width:100%}` +
        `th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f0f0f0}</style></head>` +
        `<body>` +
        `<h1>Workspace Report</h1>` +
        `<p>Generated: ${new Date().toISOString()}</p>` +
        `<h2>Summary</h2>` +
        `<ul>` +
        `<li>Projects: ${String(health.totalProjects)}</li>` +
        `<li>Total specs: ${String(health.totalSpecs)}</li>` +
        `<li>Done: ${String(health.totalDone)}</li>` +
        `<li>Implementing: ${String(health.totalImplementing)}</li>` +
        `<li>In review: ${String(health.totalInReview)}</li>` +
        `<li>Avg health score: ${String(health.averageHealthScore)}%</li>` +
        `</ul>` +
        `<h2>Projects</h2>` +
        `<table><thead><tr><th>Path</th><th>Specs</th><th>Done</th><th>Implementing</th><th>In Review</th><th>Health</th></tr></thead>` +
        `<tbody>${rows}</tbody></table>` +
        `</body></html>`);
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
export async function searchWorkspace(query, maxResults = 50) {
    const projects = await getProjectsWithFallback();
    const q = query.toLowerCase();
    const results = [];
    for (const project of projects) {
        if (results.length >= maxResults) {
            break;
        }
        const projectId = hashProjectPath(project.path);
        try {
            const specs = await specStore.listSpecs(projectId);
            for (const spec of specs) {
                if (results.length >= maxResults) {
                    break;
                }
                const matchTitle = spec.title.toLowerCase().includes(q);
                const matchId = spec.id.toLowerCase().includes(q);
                if (matchTitle || matchId) {
                    results.push({
                        projectPath: project.path,
                        specId: spec.id,
                        title: spec.title,
                        matchedIn: matchTitle ? 'title' : 'id',
                        status: spec.status,
                    });
                }
            }
        }
        catch {
            // Skip projects with errors
        }
    }
    return results;
}
// ---------------------------------------------------------------------------
// Alerts — stale specs
// ---------------------------------------------------------------------------
export async function getWorkspaceAlerts(staleDaysThreshold = 14) {
    const projects = await getProjectsWithFallback();
    const alerts = [];
    const now = Date.now();
    const threshold = staleDaysThreshold * 24 * 60 * 60 * 1000;
    for (const project of projects) {
        const projectId = hashProjectPath(project.path);
        try {
            const specs = await specStore.listSpecs(projectId);
            for (const spec of specs) {
                if (spec.status === 'review' || spec.status === 'implementing') {
                    const updatedAt = new Date(spec.updatedAt).getTime();
                    const daysDiff = Math.floor((now - updatedAt) / (24 * 60 * 60 * 1000));
                    if (now - updatedAt > threshold) {
                        alerts.push({
                            projectPath: project.path,
                            specId: spec.id,
                            title: spec.title,
                            status: spec.status,
                            daysSinceUpdate: daysDiff,
                        });
                    }
                }
            }
        }
        catch {
            // Skip
        }
    }
    return alerts.sort((a, b) => b.daysSinceUpdate - a.daysSinceUpdate);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function escHtml(text) {
    return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
//# sourceMappingURL=workspace-manager.js.map