import { AgentRunStatus } from '../../../../packages/core/src/domain/generated/output.js';
import { deriveNodeState, deriveProgress, deriveLifecycle, } from '../components/common/feature-node/derive-feature-state.js';
import { isProcessAlive } from '../../../../packages/core/src/infrastructure/services/process/is-process-alive.js';
import { computeWorktreePath } from '../../../../packages/core/src/infrastructure/services/ide-launchers/compute-worktree-path.js';
export function buildGraphNodes(repositories, featuresWithRuns, options) {
    // Normalize path separators so Windows backslash paths match forward-slash paths
    const normalizePath = (p) => p.replace(/\\/g, '/');
    // App-scoped features attach to their parent application node, not to a
    // repository — the client's `derive-graph` emits an app→feature edge from
    // `Feature.applicationId`. We must NOT group them under `featuresByRepo`,
    // otherwise the second-pass virtual-repo loop would invent a duplicate
    // `repositoryNode` for the application's repo path and attach the feature
    // there instead.
    const renderedAppIds = new Set();
    for (const app of options?.applications ?? []) {
        renderedAppIds.add(app.id);
    }
    // Group features by normalized repository path, except app-scoped ones.
    // App-scoped features are emitted standalone below; the client wires them
    // to their application via `applicationId` in the feature node data.
    const featuresByRepo = {};
    const appScopedFeatures = [];
    featuresWithRuns.forEach((entry) => {
        if (entry.feature.applicationId && renderedAppIds.has(entry.feature.applicationId)) {
            appScopedFeatures.push(entry);
            return;
        }
        const repoKey = normalizePath(entry.feature.repositoryPath);
        if (!featuresByRepo[repoKey]) {
            featuresByRepo[repoKey] = [];
        }
        featuresByRepo[repoKey].push(entry);
    });
    const nodes = [];
    const edges = [];
    // Track which repository paths have been rendered (to avoid orphan duplicates)
    const coveredPaths = new Set();
    // First, add nodes for all persisted repositories (including those without features)
    for (const repo of repositories) {
        const normalizedRepoPath = normalizePath(repo.path);
        coveredPaths.add(normalizedRepoPath);
        const repoNodeId = `repo-${repo.id}`;
        const gitInfo = options?.repoGitInfo?.get(repo.path);
        const gitInfoStatus = options?.repoGitStatus?.get(repo.path) ?? 'loading';
        nodes.push({
            id: repoNodeId,
            type: 'repositoryNode',
            position: { x: 0, y: 0 },
            data: {
                name: repo.name,
                repositoryPath: normalizedRepoPath,
                id: repo.id,
                createdAt: repo.createdAt instanceof Date ? repo.createdAt.getTime() : Number(repo.createdAt),
                gitInfoStatus,
                ...(gitInfo && {
                    branch: gitInfo.branch,
                    commitMessage: gitInfo.commitMessage,
                    committer: gitInfo.committer,
                    behindCount: gitInfo.behindCount,
                }),
            },
        });
        const repoFeatures = featuresByRepo[normalizedRepoPath] ?? [];
        appendFeatureNodes(repoFeatures, repoNodeId, featuresWithRuns, nodes, edges, repo.name, options);
    }
    // Second pass: group orphaned features under virtual repository nodes
    for (const [repoPath, orphanFeatures] of Object.entries(featuresByRepo)) {
        if (coveredPaths.has(repoPath))
            continue;
        const virtualRepoNodeId = `virtual-repo-${repoPath}`;
        const repoName = repoPath.split('/').filter(Boolean).at(-1) ?? repoPath;
        nodes.push({
            id: virtualRepoNodeId,
            type: 'repositoryNode',
            position: { x: 0, y: 0 },
            data: { name: repoName, repositoryPath: repoPath },
        });
        appendFeatureNodes(orphanFeatures, virtualRepoNodeId, featuresWithRuns, nodes, edges, repoName, options);
    }
    // Add parent→child dependency edges
    for (const { feature } of featuresWithRuns) {
        if (feature.parentId) {
            const parentNodeId = `feat-${feature.parentId}`;
            const childNodeId = `feat-${feature.id}`;
            if (nodes.some((n) => n.id === parentNodeId) && nodes.some((n) => n.id === childNodeId)) {
                edges.push({
                    id: `dep-${parentNodeId}-${childNodeId}`,
                    source: parentNodeId,
                    target: childNodeId,
                    type: 'dependencyEdge',
                });
            }
        }
    }
    // Render application nodes — first-class peers of repository nodes on the
    // canvas. The caller passes every application; the client's `derive-graph`
    // wires app→feature dependency edges from each feature's `applicationId`.
    for (const app of options?.applications ?? []) {
        const data = {
            id: app.id,
            name: app.name,
            description: app.description,
            status: app.status,
            repositoryPath: normalizePath(app.repositoryPath),
            additionalPathCount: app.additionalPaths.length,
        };
        nodes.push({
            id: `app-${app.id}`,
            type: 'applicationNode',
            position: { x: 0, y: 0 },
            data,
        });
    }
    // Emit app-scoped feature nodes standalone. We deliberately do NOT push a
    // server-side edge here — `parseMaps` only uses `dependencyEdge` types to
    // recover parent/child relationships, and the client's `derive-graph`
    // builds the app→feature edge from `Feature.applicationId` so every render
    // is consistent regardless of how the feature arrived (initial SSR, SSE,
    // optimistic creation).
    appendFeatureNodes(appScopedFeatures, '', featuresWithRuns, nodes, edges, undefined, options);
    return { nodes, edges };
}
function appendFeatureNodes(repoFeatures, repoNodeId, allFeaturesWithRuns, nodes, edges, repoName, options) {
    // Sort by createdAt so newest features appear last (bottom) in the layout
    const sorted = [...repoFeatures].sort((a, b) => {
        const aTime = a.feature.createdAt instanceof Date
            ? a.feature.createdAt.getTime()
            : Number(a.feature.createdAt);
        const bTime = b.feature.createdAt instanceof Date
            ? b.feature.createdAt.getTime()
            : Number(b.feature.createdAt);
        return aTime - bTime;
    });
    sorted.forEach(({ feature, run }) => {
        // Resolve blockedBy display name from parent feature
        let blockedBy;
        if (feature.parentId && feature.lifecycle === 'Blocked') {
            const parentEntry = allFeaturesWithRuns.find((e) => e.feature.id === feature.parentId);
            if (parentEntry) {
                blockedBy = parentEntry.feature.name;
            }
        }
        // Detect crashed agents: DB says running/pending but PID is dead
        const isActive = run?.status === AgentRunStatus.running || run?.status === AgentRunStatus.pending;
        const pidAlive = isActive && run?.pid ? isProcessAlive(run.pid) : undefined;
        const nodeData = {
            name: feature.name,
            description: feature.description ?? feature.slug,
            featureId: feature.id,
            lifecycle: deriveLifecycle(feature, run),
            repositoryPath: feature.repositoryPath,
            branch: feature.branch,
            worktreePath: feature.worktreePath ?? computeWorktreePath(feature.repositoryPath, feature.branch),
            specPath: feature.specPath,
            state: deriveNodeState(feature, run, pidAlive !== undefined ? { isPidAlive: pidAlive } : undefined),
            progress: deriveProgress(feature),
            summary: feature.description,
            userQuery: feature.userQuery,
            createdAt: feature.createdAt instanceof Date ? feature.createdAt.getTime() : feature.createdAt,
            ...(feature.fast && { fastMode: true }),
            ...(feature.applicationId && { applicationId: feature.applicationId }),
            approvalGates: feature.approvalGates,
            push: feature.push,
            openPr: feature.openPr,
            forkAndPr: feature.forkAndPr,
            commitSpecs: feature.commitSpecs,
            enableEvidence: feature.enableEvidence ?? options?.enableEvidence ?? false,
            commitEvidence: feature.commitEvidence ?? options?.commitEvidence ?? false,
            ciWatchEnabled: feature.ciWatchEnabled ?? options?.ciWatchEnabled ?? true,
            ...(repoName && { repositoryName: repoName }),
            ...(run?.agentType && { agentType: run.agentType }),
            ...(run?.modelId && { modelId: run.modelId }),
            ...(run?.error && { errorMessage: run.error }),
            ...(blockedBy && { blockedBy }),
            ...(feature.agentRunId != null && { hasAgentRun: true }),
            ...(feature.plan != null && { hasPlan: true }),
            ...(feature.pr && {
                pr: {
                    url: feature.pr.url,
                    number: feature.pr.number,
                    status: feature.pr.status,
                    ciStatus: feature.pr.ciStatus,
                    commitHash: feature.pr.commitHash,
                    mergeable: feature.pr.mergeable,
                },
            }),
        };
        const featureNodeId = `feat-${feature.id}`;
        nodes.push({
            id: featureNodeId,
            type: 'featureNode',
            position: { x: 0, y: 0 },
            data: nodeData,
        });
        // App-scoped features (called with an empty repoNodeId sentinel) get no
        // server-side edge — the client's `derive-graph` wires them to their
        // application from `Feature.applicationId`.
        if (!repoNodeId) {
            return;
        }
        // Child features connect via parent→child dependency edge, not directly to repo
        if (!feature.parentId) {
            edges.push({
                id: `edge-${repoNodeId}-${featureNodeId}`,
                source: repoNodeId,
                target: featureNodeId,
                style: { strokeDasharray: '5 5' },
            });
        }
    });
}
