import { execFile } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import os from "node:os";
import path from "node:path";

export const MOODS = ["focused", "low-energy", "maintenance", "creative", "urgent"];
export const BUSYNESS = ["busy", "normal", "open"];
export const MINUTE_OPTIONS = [15, 30, 60, 120];
export const FOCUS_INTENTS = ["balanced", "prs", "new-code", "issue-triage", "maintenance"];

const DEFAULT_PREFERENCES = { mood: "focused", minutes: 30, busyness: "normal", focusIntent: "balanced" };
const DEFAULT_CONFIG = { repos: [], onboarded: false };

const CACHE_TTL_MS = 2 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 18 * 1000;
const PROJECTS_DIR = path.join(os.homedir(), "Projects");
const LOCAL_PROJECT_DIRS = [
    PROJECTS_DIR,
    path.join(os.homedir(), "src"),
    path.join(os.homedir(), "source"),
    path.join(os.homedir(), "code"),
    path.join(os.homedir(), "GitHub"),
];
const WORKTREES_DIR = path.join(PROJECTS_DIR, "copilot-worktrees");
const APP_DATA_DB_PATH = path.join(os.homedir(), ".copilot", "data.db");
const SESSION_STORE_PATH = path.join(os.homedir(), ".copilot", "session-store.db");
const ARTIFACT_DIR = path.join(os.homedir(), ".copilot", "extensions", "work-hub", "artifacts");
const PREFERENCES_PATH = path.join(ARTIFACT_DIR, "preferences.json");
const CONFIG_PATH = path.join(ARTIFACT_DIR, "config.json");

let cachedModel = null;
let cachedAt = 0;
let refreshPromise = null;
let discoveryCache = { at: 0, value: null };
let copilotSession = null;

function nowMs() {
    return Date.now();
}

export function setCopilotSession(session) {
    copilotSession = session || null;
    invalidateCache();
}

export function daysSince(dateLike) {
    if (!dateLike) return null;
    const time = new Date(dateLike).getTime();
    if (!Number.isFinite(time)) return null;
    return Math.max(0, Math.floor((nowMs() - time) / 86400000));
}

export function ageLabel(days) {
    if (days === null || days === undefined) return "unknown";
    if (days === 0) return "today";
    if (days === 1) return "1 day";
    if (days < 30) return `${days} days`;
    const months = Math.floor(days / 30);
    if (months < 18) return `${months} mo`;
    return `${Math.floor(days / 365)} yr`;
}

function isHumanLogin(login) {
    if (!login) return false;
    return !/\[bot\]$/i.test(login) && !/-bot$/i.test(login) && login.toLowerCase() !== "github-actions";
}

function normalizeLogin(entity) {
    if (!entity) return "";
    if (typeof entity === "string") return entity;
    return entity.login || entity.name || entity.slug || "";
}

function knownUserLogins(extraLogin) {
    return new Set([extraLogin].filter(Boolean).map((value) => value.toLowerCase()));
}

function ownerOf(slug) {
    return String(slug || "").split("/")[0] || "";
}

function nameOf(slug) {
    return String(slug || "").split("/")[1] || "";
}

function ownerEnv(owner) {
    const token = process.env[`COPILOT_GH_ACCOUNT_github_2E_com_${owner}`];
    if (owner && token) {
        return { GH_HOST: "github.com", GH_TOKEN: token };
    }
    return {};
}

function parseRepoSlugFromRemote(remote) {
    const value = String(remote || "").trim();
    if (!value) return null;
    const ssh = /^git@[^:]+:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(value);
    if (ssh) return `${ssh[1]}/${ssh[2]}`;
    try {
        const url = new URL(value);
        const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/");
        if (parts.length >= 2 && parts[0] && parts[1]) return `${parts[0]}/${parts[1]}`;
    } catch {
        // Not a URL; fall through to owner/repo parsing.
    }
    const slug = value.replace(/\.git$/i, "");
    return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
}

function guessLocalPath(slug) {
    const owner = ownerOf(slug);
    const name = nameOf(slug);
    const candidates = [
        path.join(PROJECTS_DIR, name),
        path.join(PROJECTS_DIR, `${owner}_${name}`),
    ];
    for (const candidate of candidates) {
        if (existsSync(candidate)) return candidate;
    }
    return null;
}

function run(command, args, options = {}) {
    return new Promise((resolve) => {
        execFile(
            command,
            args,
            {
                cwd: options.cwd,
                env: { ...process.env, ...(options.env || {}) },
                timeout: options.timeoutMs || COMMAND_TIMEOUT_MS,
                maxBuffer: 1024 * 1024 * 8,
            },
            (error, stdout, stderr) => {
                if (error) {
                    resolve({ ok: false, stdout: stdout || "", stderr: stderr || "", message: (stderr || error.message || "Command failed").trim(), code: error.code });
                    return;
                }
                resolve({ ok: true, stdout: stdout || "", stderr: stderr || "" });
            },
        );
    });
}

async function ghJson(args, owner, label, options = {}) {
    const result = await run("gh", args, { env: ownerEnv(owner), timeoutMs: options.timeoutMs });
    if (!result.ok) {
        return { ok: false, value: null, error: { source: label, message: result.message } };
    }
    try {
        return { ok: true, value: result.stdout.trim() ? JSON.parse(result.stdout) : null, error: null };
    } catch (error) {
        return { ok: false, value: null, error: { source: label, message: `Invalid JSON from gh: ${error.message}` } };
    }
}

/* ---------------- preferences ---------------- */

export function normalizePreferences(input) {
    const source = input && typeof input === "object" ? input : {};
    const mood = MOODS.includes(source.mood) ? source.mood : DEFAULT_PREFERENCES.mood;
    const busyness = BUSYNESS.includes(source.busyness) ? source.busyness : DEFAULT_PREFERENCES.busyness;
    const focusIntent = FOCUS_INTENTS.includes(source.focusIntent) ? source.focusIntent : DEFAULT_PREFERENCES.focusIntent;
    const minutesNumber = Number(source.minutes);
    const minutes = MINUTE_OPTIONS.includes(minutesNumber) ? minutesNumber : DEFAULT_PREFERENCES.minutes;
    return { mood, busyness, minutes, focusIntent };
}

export async function readPreferences() {
    try {
        const raw = await readFile(PREFERENCES_PATH, "utf8");
        return normalizePreferences(JSON.parse(raw));
    } catch (error) {
        if (error && error.code === "ENOENT") return { ...DEFAULT_PREFERENCES };
        return { ...DEFAULT_PREFERENCES };
    }
}

export async function writePreferences(input) {
    const preferences = normalizePreferences(input);
    await mkdir(ARTIFACT_DIR, { recursive: true });
    await writeFile(PREFERENCES_PATH, `${JSON.stringify(preferences, null, 2)}\n`, "utf8");
    return preferences;
}

export async function setFocusContext(input) {
    const normalized = await writePreferences(input);
    invalidateCache();
    return normalized;
}

/* ---------------- repo config ---------------- */

function normalizeRepoEntry(entry) {
    if (!entry) return null;
    const slug = typeof entry === "string" ? entry.trim() : String(entry.slug || "").trim();
    if (!/^[^/\s]+\/[^/\s]+$/.test(slug)) return null;
    const weightNumber = Number(entry && entry.weight);
    const weight = Number.isFinite(weightNumber) && weightNumber > 0 ? Math.min(3, weightNumber) : 1;
    const resolvedPath = entry && typeof entry.path === "string" && entry.path ? entry.path : guessLocalPath(slug);
    return { slug, name: nameOf(slug), owner: ownerOf(slug), weight, path: resolvedPath || null };
}

function normalizeRepoList(list) {
    const seen = new Set();
    const repos = [];
    for (const entry of Array.isArray(list) ? list : []) {
        const normalized = normalizeRepoEntry(entry);
        if (!normalized || seen.has(normalized.slug.toLowerCase())) continue;
        seen.add(normalized.slug.toLowerCase());
        repos.push(normalized);
    }
    return repos;
}

export async function readConfig() {
    try {
        const raw = await readFile(CONFIG_PATH, "utf8");
        const parsed = JSON.parse(raw);
        const repos = normalizeRepoList(parsed.repos);
        return { repos, onboarded: Boolean(parsed.onboarded) || repos.length > 0 };
    } catch (error) {
        if (!(error && error.code === "ENOENT")) {
            // fall through to seed
        }
    }
    await writeConfig(DEFAULT_CONFIG);
    return { ...DEFAULT_CONFIG };
}

export async function writeConfig(config) {
    const repos = normalizeRepoList(config && config.repos);
    const onboarded = Boolean(config && config.onboarded);
    await mkdir(ARTIFACT_DIR, { recursive: true });
    await writeFile(CONFIG_PATH, `${JSON.stringify({ repos, onboarded }, null, 2)}\n`, "utf8");
    return { repos, onboarded };
}

export async function setTrackedRepos(slugs, onboarded = true) {
    const config = await writeConfig({ repos: Array.isArray(slugs) ? slugs : [], onboarded });
    invalidateCache();
    return config;
}

export async function addRepos(slugs) {
    const config = await readConfig();
    const existing = new Set(config.repos.map((repo) => repo.slug.toLowerCase()));
    const merged = [...config.repos];
    for (const slug of Array.isArray(slugs) ? slugs : [slugs]) {
        const normalized = normalizeRepoEntry(slug);
        if (normalized && !existing.has(normalized.slug.toLowerCase())) {
            existing.add(normalized.slug.toLowerCase());
            merged.push(normalized);
        }
    }
    const written = await writeConfig({ repos: merged, onboarded: merged.length > 0 || config.onboarded });
    invalidateCache();
    return written;
}

export async function removeRepos(slugs) {
    const config = await readConfig();
    const drop = new Set((Array.isArray(slugs) ? slugs : [slugs]).map((slug) => String(slug).toLowerCase()));
    const repos = config.repos.filter((repo) => !drop.has(repo.slug.toLowerCase()));
    const written = await writeConfig({ repos, onboarded: config.onboarded && repos.length > 0 });
    invalidateCache();
    return written;
}

async function listLocalProjects(tracked) {
    const seenPaths = new Set();
    const projects = [];
    for (const root of LOCAL_PROJECT_DIRS) {
        if (!existsSync(root)) continue;
        let entries = [];
        try {
            entries = await readdir(root, { withFileTypes: true });
        } catch {
            continue;
        }
        for (const entry of entries.filter((item) => item.isDirectory()).slice(0, 200)) {
            const projectPath = path.join(root, entry.name);
            if (seenPaths.has(projectPath) || !existsSync(path.join(projectPath, ".git"))) continue;
            seenPaths.add(projectPath);
            const remote = await run("git", ["-C", projectPath, "remote", "get-url", "origin"], { timeoutMs: 4000 });
            const slug = remote.ok ? parseRepoSlugFromRemote(remote.stdout) : null;
            if (!slug) continue;
            projects.push({
                slug,
                name: entry.name,
                path: projectPath,
                source: "local",
                description: projectPath,
                tracked: tracked.has(slug.toLowerCase()),
            });
        }
    }
    projects.sort((a, b) => a.slug.localeCompare(b.slug));
    return projects;
}

export async function listAvailableRepos(force = false) {
    if (!force && discoveryCache.value && nowMs() - discoveryCache.at < 5 * 60 * 1000) {
        return discoveryCache.value;
    }
    const config = await readConfig();
    const currentLogin = await getCurrentLogin();
    const owners = new Set([currentLogin, ...config.repos.map((repo) => repo.owner)].filter(Boolean));
    const tracked = new Set(config.repos.map((repo) => repo.slug.toLowerCase()));
    const errors = [];
    const repos = [];
    await Promise.all(
        [...owners].map(async (owner) => {
            const result = await ghJson(
                ["repo", "list", owner, "--limit", "100", "--json", "nameWithOwner,description,pushedAt,isArchived,isPrivate,isFork,stargazerCount"],
                owner,
                `discover:${owner}`,
                { timeoutMs: 12000 },
            );
            if (!result.ok) {
                if (result.error) errors.push(result.error);
                return;
            }
            for (const item of Array.isArray(result.value) ? result.value : []) {
                repos.push({
                    slug: item.nameWithOwner,
                    owner,
                    description: item.description || "",
                    pushedAt: item.pushedAt || null,
                    pushedAgeDays: daysSince(item.pushedAt),
                    isArchived: Boolean(item.isArchived),
                    isPrivate: Boolean(item.isPrivate),
                    isFork: Boolean(item.isFork),
                    stars: Number(item.stargazerCount || 0),
                    tracked: tracked.has(String(item.nameWithOwner).toLowerCase()),
                });
            }
        }),
    );
    repos.sort((a, b) => (b.pushedAt || "").localeCompare(a.pushedAt || ""));
    const projects = await listLocalProjects(tracked);
    const value = { owners: [...owners], repos, projects, errors };
    discoveryCache = { at: nowMs(), value };
    return value;
}

/* ---------------- sessions ---------------- */

async function getCopilotSessions(repos) {
    const appInventory = await getAppWorkspaceInventory(repos);
    if (appInventory && (appInventory.inventory.length || !appInventory.errors.length)) {
        const seen = new Set();
        const perRepoCounts = new Map();
        const sessions = [];
        for (const item of appInventory.inventory) {
            if (!item.repository) continue;
            if (item.ageDays !== null && item.ageDays > 14) continue;
            const key = `${item.repository}|${item.branch || ""}|${item.summary || ""}`;
            const count = perRepoCounts.get(item.repository) || 0;
            if (seen.has(key) || count >= 3) continue;
            seen.add(key);
            perRepoCounts.set(item.repository, count + 1);
            sessions.push(item);
        }
        return { sessions, errors: appInventory.errors };
    }
    if (!existsSync(SESSION_STORE_PATH)) {
        return { sessions: [], errors: [{ source: "copilot-sessions", message: "Session store was not found." }] };
    }
    const repoList = repos.map((repo) => `'${repo.slug.replaceAll("'", "''")}'`).join(",") || "''";
    const query = [
        "SELECT id,cwd,repository,branch,summary,created_at,updated_at",
        "FROM sessions",
        `WHERE repository IN (${repoList})`,
        `OR cwd LIKE '${WORKTREES_DIR.replaceAll("'", "''")}/%'`,
        "ORDER BY updated_at DESC",
        "LIMIT 120",
    ].join(" ");
    const result = await run("sqlite3", ["-json", SESSION_STORE_PATH, query], { timeoutMs: 8000 });
    if (!result.ok) {
        if (/spawn sqlite3 ENOENT/i.test(result.message)) return { sessions: [], errors: [] };
        return { sessions: [], errors: [{ source: "copilot-sessions", message: result.message }] };
    }
    try {
        const rows = result.stdout.trim() ? JSON.parse(result.stdout) : [];
        const normalized = rows.map((row) => ({
            id: row.id,
            cwd: row.cwd,
            repository: normalizeSessionRepo(row.repository, row.cwd, repos),
            branch: row.branch,
            summary: row.summary || row.branch || "Open session",
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            ageDays: daysSince(row.updated_at),
        }));
        const seen = new Set();
        const perRepoCounts = new Map();
        const sessions = [];
        for (const item of normalized) {
            if (!item.repository) continue;
            if (item.ageDays !== null && item.ageDays > 14) continue;
            const key = `${item.repository}|${item.branch || ""}|${item.summary || ""}`;
            const count = perRepoCounts.get(item.repository) || 0;
            if (seen.has(key) || count >= 3) continue;
            seen.add(key);
            perRepoCounts.set(item.repository, count + 1);
            sessions.push(item);
        }
        return { sessions, errors: [] };
    } catch (error) {
        return { sessions: [], errors: [{ source: "copilot-sessions", message: `Invalid session store JSON: ${error.message}` }] };
    }
}

function normalizeSessionRepo(repository, cwd, repos) {
    if (repository) return repository;
    if (!cwd) return null;
    const normalizedCwd = normalizeFsPath(cwd);
    const match = repos.find((repo) => {
        const repoPath = normalizeFsPath(repo.path);
        return repoPath && (normalizedCwd === repoPath || normalizedCwd.startsWith(`${repoPath}/`));
    });
    return match ? match.slug : null;
}

function normalizeFsPath(value) {
    return String(value || "").replaceAll("\\", "/").replace(/\/+$/, "");
}

function isSessionWorktree(cwd) {
    const normalized = normalizeFsPath(cwd);
    if (!normalized) return false;
    return /\/copilot-worktrees\/[^/]+\/[^/]+$/i.test(normalized) || normalized.includes("/.copilot/repos/copilot-worktrees/");
}

function normalizeRuntimeSession(entry, repos) {
    if (!entry || entry.isRemote) return null;
    const context = entry.context || {};
    const cwd = context.cwd || "";
    const repository = normalizeSessionRepo(context.repository, cwd, repos);
    if (!repository && !cwd) return null;
    const updatedAt = entry.modifiedTime || entry.startTime || null;
    const createdAt = entry.startTime || updatedAt;
    const ageDays = daysSince(updatedAt);
    const isWorktree = isSessionWorktree(cwd);
    const exists = Boolean(cwd && existsSync(cwd));
    return {
        id: entry.sessionId,
        cwd,
        repository: repository || "(unmapped)",
        branch: context.branch || "",
        summary: (entry.name || entry.summary || context.branch || "Open session").split("\n")[0].slice(0, 100),
        createdAt,
        updatedAt,
        ageDays,
        ageLabel: ageDays === null ? "unknown" : ageLabel(ageDays),
        bucket: stalenessBucket(ageDays),
        isWorktree,
        orphaned: isWorktree && !exists,
        source: "app",
    };
}

function appWorkspaceRepo(row, repos) {
    if (row.github_owner && row.github_repo) return `${row.github_owner}/${row.github_repo}`;
    return normalizeSessionRepo(row.source_pr_repo_full_name || row.source_issue_repo_full_name || row.created_pr_repo_full_name, row.path || row.main_repo_path, repos);
}

function normalizeAppWorkspace(row, repos) {
    if (!row) return null;
    const cwd = row.path || row.main_repo_path || "";
    const repository = appWorkspaceRepo(row, repos);
    const updatedAt = row.updated_at || row.created_at || null;
    const ageDays = daysSince(updatedAt);
    const isWorktree = row.workspace_type === "worktree" || isSessionWorktree(cwd);
    const exists = Boolean(cwd && existsSync(cwd));
    return {
        id: row.id,
        appSessionId: row.session_id || "",
        cwd,
        repository: repository || row.project_name || "(unmapped)",
        branch: row.branch || "",
        summary: (row.name || row.branch || "Open session").split("\n")[0].slice(0, 100),
        createdAt: row.created_at,
        updatedAt,
        ageDays,
        ageLabel: ageDays === null ? "unknown" : ageLabel(ageDays),
        bucket: stalenessBucket(ageDays),
        isWorktree,
        orphaned: isWorktree && !exists,
        source: "app",
        projectName: row.project_name || "",
        workspaceType: row.workspace_type || "",
    };
}

function queryAppWorkspaces() {
    if (!existsSync(APP_DATA_DB_PATH)) return null;
    const db = new DatabaseSync(APP_DATA_DB_PATH, { readOnly: true });
    try {
        return db
            .prepare(`
                SELECT
                    w.id,
                    w.name,
                    w.branch,
                    w.created_at,
                    w.updated_at,
                    w.archived_at,
                    w.session_id,
                    w.workspace_type,
                    w.source_pr_repo_full_name,
                    w.source_issue_repo_full_name,
                    w.created_pr_repo_full_name,
                    p.github_owner,
                    p.github_repo,
                    p.name AS project_name,
                    p.main_repo_path,
                    wt.path
                FROM workspaces w
                LEFT JOIN projects p ON p.id = w.project_id
                LEFT JOIN worktrees wt ON wt.id = w.worktree_id
                WHERE w.archived_at IS NULL
                ORDER BY w.updated_at DESC
                LIMIT 500
            `)
            .all();
    } finally {
        db.close();
    }
}

async function getAppWorkspaceInventory(repos) {
    try {
        const rows = queryAppWorkspaces();
        if (!rows) return null;
        const inventory = rows.map((row) => normalizeAppWorkspace(row, repos)).filter(Boolean);
        return { inventory, errors: [] };
    } catch (error) {
        return { inventory: [], errors: [{ source: "app-sessions", message: `Could not read app workspace sessions: ${error.message}` }] };
    }
}

async function getRuntimeSessionInventory(repos) {
    if (!copilotSession?.rpc?.sessions?.list) return null;
    try {
        const result = await copilotSession.rpc.sessions.list({ source: "local", metadataLimit: 500, includeDetached: false });
        const rows = Array.isArray(result?.sessions) ? result.sessions : [];
        const seen = new Set();
        const inventory = [];
        for (const row of rows) {
            const item = normalizeRuntimeSession(row, repos);
            if (!item) continue;
            const key = `${item.id}|${item.cwd}|${item.repository}|${item.branch}`;
            if (seen.has(key)) continue;
            seen.add(key);
            inventory.push(item);
        }
        inventory.sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0));
        return { inventory, errors: [] };
    } catch (error) {
        return { inventory: [], errors: [{ source: "copilot-sessions", message: `Could not list app sessions: ${error.message}` }] };
    }
}

function stalenessBucket(ageDays) {
    if (ageDays === null) return "unknown";
    if (ageDays < 2) return "fresh";
    if (ageDays < 7) return "aging";
    if (ageDays < 30) return "stale";
    return "ancient";
}

// Full session inventory for the cleanup tab: no age filter, no per-repo cap.
async function getSessionInventory(repos) {
    const appInventory = await getAppWorkspaceInventory(repos);
    if (appInventory && (appInventory.inventory.length || !appInventory.errors.length)) {
        return appInventory;
    }
    const runtimeInventory = await getRuntimeSessionInventory(repos);
    if (runtimeInventory && (runtimeInventory.inventory.length || !runtimeInventory.errors.length)) {
        return runtimeInventory;
    }
    if (!existsSync(SESSION_STORE_PATH)) {
        return { inventory: [], errors: [{ source: "copilot-sessions", message: "Session store was not found." }] };
    }
    const query = [
        "SELECT id,cwd,repository,branch,summary,created_at,updated_at",
        "FROM sessions",
        "ORDER BY updated_at DESC",
        "LIMIT 400",
    ].join(" ");
    const result = await run("sqlite3", ["-json", SESSION_STORE_PATH, query], { timeoutMs: 8000 });
    if (!result.ok) {
        if (/spawn sqlite3 ENOENT/i.test(result.message)) return { inventory: [], errors: [] };
        return { inventory: [], errors: [{ source: "copilot-sessions", message: result.message }] };
    }
    try {
        const rows = result.stdout.trim() ? JSON.parse(result.stdout) : [];
        const seen = new Set();
        const inventory = [];
        for (const row of rows) {
            const repository = normalizeSessionRepo(row.repository, row.cwd, repos);
            const ageDays = daysSince(row.updated_at);
            const isWorktree = isSessionWorktree(row.cwd);
            const exists = Boolean(row.cwd && existsSync(row.cwd));
            const key = `${repository || row.cwd || ""}|${row.branch || ""}|${row.summary || ""}`;
            if (seen.has(key)) continue;
            seen.add(key);
            inventory.push({
                id: row.id,
                cwd: row.cwd || "",
                repository: repository || "(unmapped)",
                branch: row.branch || "",
                summary: (row.summary || row.branch || "Open session").split("\n")[0].slice(0, 100),
                createdAt: row.created_at,
                updatedAt: row.updated_at,
                ageDays,
                ageLabel: ageDays === null ? "unknown" : ageLabel(ageDays),
                bucket: stalenessBucket(ageDays),
                isWorktree,
                orphaned: isWorktree && !exists,
            });
        }
        return { inventory, errors: [] };
    } catch (error) {
        return { inventory: [], errors: [{ source: "copilot-sessions", message: `Invalid session store JSON: ${error.message}` }] };
    }
}

/* ---------------- local git ---------------- */

async function getLocalGitState(repo) {
    if (!repo.path || !existsSync(repo.path)) {
        return { available: false };
    }
    const [statusResult, commitResult] = await Promise.all([
        run("git", ["-C", repo.path, "status", "--short", "--branch", "--untracked-files=no"], { timeoutMs: 8000 }),
        run("git", ["-C", repo.path, "log", "-1", "--format=%cI"], { timeoutMs: 8000 }),
    ]);
    const state = { available: true, path: repo.path };
    if (statusResult.ok) {
        const lines = statusResult.stdout.trim().split("\n").filter(Boolean);
        state.branchLine = lines[0] || "";
        state.dirtyCount = lines.slice(1).length;
        state.ahead = /ahead (\d+)/.exec(state.branchLine)?.[1] ? Number(/ahead (\d+)/.exec(state.branchLine)[1]) : 0;
        state.behind = /behind (\d+)/.exec(state.branchLine)?.[1] ? Number(/behind (\d+)/.exec(state.branchLine)[1]) : 0;
    } else {
        state.error = statusResult.message;
    }
    if (commitResult.ok) {
        state.lastCommitAt = commitResult.stdout.trim() || null;
        state.lastCommitAgeDays = daysSince(state.lastCommitAt);
    }
    return state;
}

/* ---------------- repo collection ---------------- */

async function collectRepo(repo, userLogins, sessionsByRepo) {
    const errors = [];
    const [metadataResult, releaseResult, tagsResult, prsResult, issuesResult, localGit, deployments] = await Promise.all([
        ghJson(["api", `repos/${repo.slug}`], repo.owner, `${repo.slug}:metadata`),
        ghJson(["api", `repos/${repo.slug}/releases/latest`], repo.owner, `${repo.slug}:latest-release`),
        ghJson(["api", `repos/${repo.slug}/tags?per_page=1`], repo.owner, `${repo.slug}:latest-tag`),
        ghJson(
            ["pr", "list", "--repo", repo.slug, "--state", "open", "--limit", "50", "--json", "number,title,author,updatedAt,createdAt,isDraft,reviewDecision,url,headRefName,baseRefName,statusCheckRollup,labels,assignees"],
            repo.owner,
            `${repo.slug}:prs`,
        ),
        ghJson(
            ["issue", "list", "--repo", repo.slug, "--state", "open", "--limit", "50", "--json", "number,title,author,updatedAt,createdAt,url,labels,assignees,comments"],
            repo.owner,
            `${repo.slug}:issues`,
        ),
        getLocalGitState(repo),
        getDeployments(repo),
    ]);

    for (const result of [metadataResult, prsResult, issuesResult]) {
        if (!result.ok && result.error) errors.push(result.error);
    }
    if (!releaseResult.ok && releaseResult.error && !/Not Found/i.test(releaseResult.error.message)) errors.push(releaseResult.error);
    if (!tagsResult.ok && tagsResult.error) errors.push(tagsResult.error);
    if (localGit.error) errors.push({ source: `${repo.slug}:local-git`, message: localGit.error });
    if (deployments.error) errors.push(deployments.error);

    const metadata = metadataResult.value || {};
    const prs = Array.isArray(prsResult.value) ? prsResult.value.map((pr) => normalizePr(repo, pr, userLogins)) : [];
    const issues = Array.isArray(issuesResult.value) ? issuesResult.value.map((issue) => normalizeIssue(repo, issue, userLogins)) : [];
    const latestRelease = releaseResult.ok && releaseResult.value ? normalizeRelease(releaseResult.value, "release") : null;
    const latestTag = tagsResult.ok && Array.isArray(tagsResult.value) && tagsResult.value[0] ? normalizeRelease(tagsResult.value[0], "tag") : null;
    const release = latestRelease || latestTag;
    const releaseAgeDays = release ? daysSince(release.publishedAt) : null;
    const pushedAgeDays = daysSince(metadata.pushed_at);
    const activeSessions = sessionsByRepo.get(repo.slug) || [];
    const needsHumanPrs = prs.filter((pr) => pr.needsHumanReview || pr.requestedFromJames || pr.failingChecks);
    const humanIssues = issues.filter((issue) => issue.assignedToJames || issue.isHumanAuthored);
    const latestDeploy = deployments.latest || null;
    const failedDeploy = deployments.environments.some((env) => ["failure", "error"].includes(env.state));

    const status = summarizeRepoStatus({ errors, prs, releaseAgeDays, pushedAgeDays, activeSessions, metadata, needsHumanPrs, latestDeploy, failedDeploy });

    return {
        ...repo,
        url: metadata.html_url || `https://github.com/${repo.slug}`,
        description: metadata.description || repo.description || "",
        defaultBranch: metadata.default_branch || "main",
        isArchived: Boolean(metadata.archived),
        isPrivate: Boolean(metadata.private),
        language: metadata.language || "",
        stars: Number(metadata.stargazers_count || 0),
        pushedAt: metadata.pushed_at || null,
        pushedAgeDays,
        latestRelease: release,
        releaseAgeDays,
        releaseAgeLabel: ageLabel(releaseAgeDays),
        deployments: deployments.environments,
        latestDeploy,
        deployAgeDays: latestDeploy ? latestDeploy.ageDays : null,
        deployAgeLabel: ageLabel(latestDeploy ? latestDeploy.ageDays : null),
        failedDeploy,
        openPrCount: prs.length,
        openIssueCount: issues.length,
        needsHumanPrCount: needsHumanPrs.length,
        humanIssueCount: humanIssues.length,
        prs,
        issues,
        localGit,
        activeSessions,
        status,
        errors,
    };
}

async function getDeployments(repo) {
    const list = await ghJson(["api", `repos/${repo.slug}/deployments?per_page=30`], repo.owner, `${repo.slug}:deployments`);
    if (!list.ok) {
        const error = list.error && /Not Found/i.test(list.error.message) ? null : list.error;
        return { environments: [], latest: null, error };
    }
    const rows = Array.isArray(list.value) ? list.value : [];
    const byEnv = new Map();
    for (const deploy of rows) {
        const env = deploy.environment || "unknown";
        const current = byEnv.get(env);
        if (!current || new Date(deploy.created_at) > new Date(current.created_at)) byEnv.set(env, deploy);
    }
    const latestPerEnv = [...byEnv.values()].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 4);
    const environments = await Promise.all(
        latestPerEnv.map(async (deploy) => {
            const statusResult = await ghJson(["api", `repos/${repo.slug}/deployments/${deploy.id}/statuses?per_page=1`], repo.owner, `${repo.slug}:deploy-status`);
            const statusRow = statusResult.ok && Array.isArray(statusResult.value) && statusResult.value[0] ? statusResult.value[0] : null;
            return {
                environment: deploy.environment || "unknown",
                ref: deploy.ref || "",
                createdAt: deploy.created_at,
                ageDays: daysSince(deploy.created_at),
                state: statusRow ? String(statusRow.state || "unknown") : "unknown",
                url: statusRow ? statusRow.environment_url || "" : "",
                isProduction: Boolean(deploy.production_environment) || /prod|pages|release/i.test(deploy.environment || ""),
            };
        }),
    );
    return { environments, latest: environments[0] || null, error: null };
}

function normalizeRelease(value, kind) {
    return {
        kind,
        name: value.name || value.tag_name || "Latest tag",
        tagName: value.tag_name || value.name || "",
        publishedAt: value.published_at || value.created_at || value.commit?.committer?.date || null,
        url: value.html_url || "",
    };
}

function normalizePr(repo, pr, userLogins) {
    const authorLogin = normalizeLogin(pr.author);
    const assignees = Array.isArray(pr.assignees) ? pr.assignees.map(normalizeLogin).filter(Boolean) : [];
    const labels = Array.isArray(pr.labels) ? pr.labels.map((label) => label.name || label).filter(Boolean) : [];
    const failingChecks = Array.isArray(pr.statusCheckRollup)
        ? pr.statusCheckRollup.some((check) => ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED"].includes(String(check.conclusion || check.status || "").toUpperCase()))
        : false;
    const assignedToJames = assignees.some((login) => userLogins.has(login.toLowerCase()));
    const reviewRequired = pr.reviewDecision === "REVIEW_REQUIRED" || pr.reviewDecision === "CHANGES_REQUESTED";
    const isHumanAuthored = isHumanLogin(authorLogin);
    return {
        repo: repo.slug,
        type: "pr",
        number: pr.number,
        title: pr.title || `PR #${pr.number}`,
        url: pr.url || `https://github.com/${repo.slug}/pull/${pr.number}`,
        author: authorLogin,
        isHumanAuthored,
        createdAt: pr.createdAt,
        updatedAt: pr.updatedAt,
        updatedAgeDays: daysSince(pr.updatedAt),
        isDraft: Boolean(pr.isDraft),
        reviewDecision: pr.reviewDecision || "UNKNOWN",
        assignedToJames,
        requestedFromJames: assignedToJames && reviewRequired,
        needsHumanReview: isHumanAuthored && reviewRequired,
        failingChecks,
        labels,
        headRefName: pr.headRefName || "",
        baseRefName: pr.baseRefName || "",
    };
}

function normalizeIssue(repo, issue, userLogins) {
    const authorLogin = normalizeLogin(issue.author);
    const assignees = Array.isArray(issue.assignees) ? issue.assignees.map(normalizeLogin).filter(Boolean) : [];
    const labels = Array.isArray(issue.labels) ? issue.labels.map((label) => label.name || label).filter(Boolean) : [];
    const assignedToJames = assignees.some((login) => userLogins.has(login.toLowerCase()));
    const isHumanAuthored = isHumanLogin(authorLogin);
    return {
        repo: repo.slug,
        type: "issue",
        number: issue.number,
        title: issue.title || `Issue #${issue.number}`,
        url: issue.url || `https://github.com/${repo.slug}/issues/${issue.number}`,
        author: authorLogin,
        isHumanAuthored,
        assignedToJames,
        createdAt: issue.createdAt,
        updatedAt: issue.updatedAt,
        updatedAgeDays: daysSince(issue.updatedAt),
        labels,
        comments: Number(issue.comments || 0),
    };
}

function summarizeRepoStatus(input) {
    if (input.metadata.archived) return { tone: "muted", label: "Archived", detail: "Repository is archived." };
    if (input.errors.length >= 3) return { tone: "danger", label: "Needs setup", detail: "Several data sources failed." };
    if (input.failedDeploy) return { tone: "danger", label: "Deploy failed", detail: `Latest ${input.latestDeploy ? input.latestDeploy.environment : "deployment"} did not succeed.` };
    if (input.needsHumanPrs.length > 0) return { tone: "attention", label: "Review needed", detail: `${input.needsHumanPrs.length} PRs need human attention.` };
    if (input.prs.some((pr) => pr.failingChecks)) return { tone: "danger", label: "Checks failing", detail: "Open PR checks need attention." };
    const recentGoodDeploy = input.latestDeploy && input.latestDeploy.state === "success" && input.latestDeploy.ageDays !== null && input.latestDeploy.ageDays <= 30;
    if ((input.releaseAgeDays === null || input.releaseAgeDays > 120) && recentGoodDeploy) {
        return { tone: "active", label: "Deploying", detail: `Deployed to ${input.latestDeploy.environment} ${ageLabel(input.latestDeploy.ageDays)} ago.` };
    }
    if (input.releaseAgeDays === null) return { tone: "neutral", label: "No release", detail: "No release or tag was found." };
    if (input.releaseAgeDays > 120) return { tone: "attention", label: "Release stale", detail: `Last release/tag was ${ageLabel(input.releaseAgeDays)} ago.` };
    if (input.pushedAgeDays !== null && input.pushedAgeDays > 90) return { tone: "neutral", label: "Quiet", detail: `Default branch activity is ${ageLabel(input.pushedAgeDays)} old.` };
    if (input.activeSessions.length > 0) return { tone: "active", label: "Recent work", detail: `${input.activeSessions.length} recent session signals.` };
    return { tone: "good", label: "Healthy", detail: "No urgent signal detected." };
}

/* ---------------- focus + recommendations ---------------- */

function buildFocusItems(repos, sessions) {
    const items = [];
    for (const repo of repos) {
        for (const pr of repo.prs) {
            let score = 20 * repo.weight;
            const reasons = [];
            const tags = [];
            if (pr.requestedFromJames) { score += 70; reasons.push("assigned review"); tags.push("needs-review"); }
            else if (pr.needsHumanReview) { score += 45; reasons.push("human review needed"); tags.push("needs-review"); }
            if (pr.failingChecks) { score += 30; reasons.push("checks failing"); tags.push("failing-checks"); }
            if (pr.isHumanAuthored) tags.push("human");
            if (!pr.isDraft) score += 10; else tags.push("draft");
            if (pr.updatedAgeDays !== null && pr.updatedAgeDays <= 2) { score += 12; reasons.push("recently active"); tags.push("recent"); }
            items.push({
                id: `${repo.slug}#pr-${pr.number}`,
                kind: "pr",
                repo: repo.slug,
                number: pr.number,
                title: pr.title,
                url: pr.url,
                score: Math.round(score),
                minutes: pr.failingChecks ? 60 : 30,
                energy: pr.failingChecks ? "focused" : "medium",
                reasons,
                what: `Review PR #${pr.number} and decide whether it needs feedback, fixes, or merge attention.`,
                how: pr.failingChecks ? "Open the PR detail, inspect failing checks first, then request a Copilot review or jump into an implementation session if the failure needs code." : "Open the PR detail, scan the summary and comments, then approve/request changes or ask Copilot for a review.",
                tags,
                updatedAgeDays: pr.updatedAgeDays,
                detail: `PR #${pr.number} by ${pr.author || "unknown"} · ${pr.reviewDecision}`,
            });
        }
        for (const issue of repo.issues) {
            let score = 12 * repo.weight;
            const reasons = [];
            const tags = [];
            if (issue.assignedToJames) { score += 55; reasons.push("assigned to you"); tags.push("assigned"); }
            if (issue.isHumanAuthored) { score += 18; reasons.push("human issue"); tags.push("human"); }
            if (issue.comments > 0) score += Math.min(14, issue.comments * 2);
            if (issue.updatedAgeDays !== null && issue.updatedAgeDays <= 3) { score += 10; tags.push("recent"); }
            items.push({
                id: `${repo.slug}#issue-${issue.number}`,
                kind: "issue",
                repo: repo.slug,
                number: issue.number,
                title: issue.title,
                url: issue.url,
                score: Math.round(score),
                minutes: 30,
                energy: "medium",
                reasons,
                what: `Triage issue #${issue.number} and choose whether it is ready for implementation, needs clarification, or should become a cloud session.`,
                how: issue.assignedToJames ? "Because it is assigned to you, start by reading the latest comments, then assign it to a cloud session or open a local implementation plan." : "Read the description and recent comments, refine the spec if it is fuzzy, or assign it when it is actionable.",
                tags,
                updatedAgeDays: issue.updatedAgeDays,
                detail: `Issue #${issue.number} by ${issue.author || "unknown"} · ${issue.comments} comments`,
            });
        }
        if (repo.releaseAgeDays === null || repo.releaseAgeDays > 120) {
            const staleScore = repo.releaseAgeDays === null ? 24 : Math.min(65, 20 + Math.floor(repo.releaseAgeDays / 12));
            items.push({
                id: `${repo.slug}#release`,
                kind: "release",
                repo: repo.slug,
                title: repo.releaseAgeDays === null ? "Establish release baseline" : `Review stale release cadence (${ageLabel(repo.releaseAgeDays)})`,
                url: repo.url,
                score: Math.round(staleScore * repo.weight),
                minutes: 60,
                energy: "focused",
                reasons: [repo.releaseAgeDays === null ? "no release/tag" : "release stale"],
                what: repo.releaseAgeDays === null ? "Create a release baseline so the repo has a known published state." : "Review whether the release cadence is stale and decide if current main is ready to ship.",
                how: "Check recent commits, tags, and deployments, then either cut a release/tag or record what is blocking the next one.",
                tags: ["stale-release"],
                updatedAgeDays: repo.releaseAgeDays,
                detail: repo.latestRelease ? `Latest ${repo.latestRelease.kind}: ${repo.latestRelease.tagName || repo.latestRelease.name}` : "No release or tag detected",
            });
        }
        if (repo.failedDeploy && repo.latestDeploy) {
            items.push({
                id: `${repo.slug}#deploy`,
                kind: "deploy",
                repo: repo.slug,
                title: `Fix failed deployment to ${repo.latestDeploy.environment}`,
                url: repo.latestDeploy.url || repo.url,
                score: Math.round(75 * repo.weight),
                minutes: 60,
                energy: "focused",
                reasons: ["deployment failed"],
                what: `Investigate the failed ${repo.latestDeploy.environment} deployment and decide if it blocks shipping.`,
                how: "Open the deployment, inspect the failed status/logs, then create a fix session if the failure points to code or configuration.",
                tags: ["failing-deploy"],
                updatedAgeDays: repo.latestDeploy.ageDays,
                detail: `${repo.latestDeploy.environment} · ${repo.latestDeploy.state} · ${ageLabel(repo.latestDeploy.ageDays)} ago`,
            });
        }
    }
    for (const recentSession of sessions.filter((entry) => entry.repository)) {
        if (recentSession.ageDays !== null && recentSession.ageDays > 7) continue;
        const staleSession = recentSession.ageDays !== null && recentSession.ageDays >= 2;
        const sessionLabel = (recentSession.summary || "open session").split("\n")[0].trim().slice(0, 80);
        items.push({
            id: `${recentSession.id}#session`,
            kind: "session",
            repo: recentSession.repository,
            sessionId: recentSession.id,
            branch: recentSession.branch || "",
            title: `Triage session: ${sessionLabel}`,
            url: "",
            score: Math.max(18, 46 - (recentSession.ageDays || 0) * 4) + (staleSession ? 10 : 0),
            minutes: 15,
            energy: "low",
            reasons: [staleSession ? "session left open, needs triage" : "recent session to triage"],
            what: "Triage an existing coding session so active work does not go stale.",
            how: "Jump to the session, read the latest agent state, then decide whether to continue, close, or clean it up.",
            tags: staleSession ? ["triage", "active-session", "stale-session"] : ["triage", "active-session"],
            updatedAgeDays: recentSession.ageDays,
            detail: `${recentSession.branch || "unknown branch"} · updated ${ageLabel(recentSession.ageDays)} ago`,
        });
    }
    return items.sort((a, b) => b.score - a.score);
}

function adjustScore(item, preferences) {
    let score = item.score;
    if (preferences.focusIntent === "prs") {
        if (item.kind === "pr") score += 45;
        if (item.tags.includes("needs-review") || item.tags.includes("failing-checks")) score += 18;
        if (item.kind === "issue") score -= 8;
    }
    if (preferences.focusIntent === "new-code") {
        if (item.kind === "issue") score += 40;
        if (item.tags.includes("assigned")) score += 15;
        if (item.kind === "deploy" || item.kind === "release") score += 8;
        if (item.kind === "pr" && !item.tags.includes("failing-checks")) score -= 10;
    }
    if (preferences.focusIntent === "issue-triage") {
        if (item.kind === "issue") score += 45;
        if (item.tags.includes("human") || item.tags.includes("recent")) score += 12;
        if (item.kind === "pr") score -= 12;
    }
    if (preferences.focusIntent === "maintenance") {
        if (["release", "deploy", "session"].includes(item.kind)) score += 42;
        if (item.tags.includes("stale-session") || item.tags.includes("stale-release")) score += 15;
        if (item.kind === "issue" && !item.tags.includes("assigned")) score -= 10;
    }
    if (preferences.mood === "low-energy") {
        if (item.minutes <= 15 || item.energy === "low") score += 25;
        if (item.kind === "release" || item.energy === "focused") score -= 20;
    }
    if (preferences.mood === "maintenance" && ["issue", "release", "session"].includes(item.kind)) score += 22;
    if (preferences.mood === "creative") {
        if (["release", "session"].includes(item.kind)) score += 18;
        if (item.kind === "pr") score -= 6;
    }
    if (preferences.mood === "urgent") {
        if (item.kind === "pr" || item.tags.includes("failing-checks")) score += 32;
        if (item.minutes > preferences.minutes) score -= 10;
    }
    if (preferences.mood === "focused" && item.energy === "focused") score += 16;
    if (preferences.busyness === "busy") {
        if (item.minutes <= 15) score += 24;
        if (item.minutes >= 60) score -= 24;
    }
    if (preferences.busyness === "open" && item.minutes >= 60) score += 12;
    return Math.round(score);
}

function buildRecommendations(items, preferences) {
    const adjusted = items.map((item) => ({ ...item, adjustedScore: adjustScore(item, preferences) })).sort((a, b) => b.adjustedScore - a.adjustedScore);
    const maxItems = preferences.minutes <= 15 ? 2 : preferences.minutes <= 30 ? 3 : preferences.minutes <= 60 ? 4 : 6;
    let remaining = preferences.minutes;
    const picks = [];
    for (const item of adjusted) {
        if (picks.length >= maxItems) break;
        const cost = Math.min(item.minutes, remaining);
        if (cost <= 0) break;
        if (item.minutes > remaining && preferences.minutes !== 15) continue;
        picks.push({ ...item, plannedMinutes: cost });
        remaining -= cost;
    }
    if (picks.length === 0 && adjusted[0]) {
        picks.push({ ...adjusted[0], plannedMinutes: Math.min(preferences.minutes, adjusted[0].minutes) });
    }
    return picks;
}

function summarizeDashboard(repos, sessions, focusItems, errors, inventory = []) {
    return {
        repoCount: repos.length,
        openPrCount: repos.reduce((sum, repo) => sum + repo.openPrCount, 0),
        openIssueCount: repos.reduce((sum, repo) => sum + repo.openIssueCount, 0),
        needsHumanPrCount: repos.reduce((sum, repo) => sum + repo.needsHumanPrCount, 0),
        humanIssueCount: repos.reduce((sum, repo) => sum + repo.humanIssueCount, 0),
        staleReleaseCount: repos.filter((repo) => repo.releaseAgeDays === null || repo.releaseAgeDays > 120).length,
        failedDeployCount: repos.filter((repo) => repo.failedDeploy).length,
        deployedRepoCount: repos.filter((repo) => repo.latestDeploy).length,
        activeSessionCount: sessions.filter((entry) => entry.repository && (entry.ageDays === null || entry.ageDays <= 14)).length,
        sessionInventoryCount: inventory.length,
        staleSessionCount: inventory.filter((s) => s.bucket === "stale" || s.bucket === "ancient" || s.orphaned).length,
        topFocusCount: focusItems.length,
        errorCount: errors.length,
    };
}

/* ---------------- item detail + actions ---------------- */

function normalizeComments(rows) {
    if (!Array.isArray(rows)) return [];
    return rows.slice(-8).map((c) => ({
        author: normalizeLogin(c.author),
        body: String(c.body || "").slice(0, 2000),
        createdAt: c.createdAt || c.created_at || null,
        ageDays: daysSince(c.createdAt || c.created_at),
        url: c.url || "",
    }));
}

export async function getItemDetail({ repo, type, number } = {}) {
    const slug = String(repo || "");
    const owner = ownerOf(slug);
    const num = Number(number);
    if (!slug.includes("/") || !num || !["pr", "issue"].includes(type)) {
        throw new Error("A repo slug, item type (pr|issue), and number are required.");
    }
    if (type === "pr") {
        const fields = "number,title,body,state,author,url,isDraft,reviewDecision,labels,assignees,additions,deletions,changedFiles,mergeable,mergeStateStatus,comments,createdAt,updatedAt,headRefName,baseRefName,statusCheckRollup";
        const result = await ghJson(["pr", "view", String(num), "--repo", slug, "--json", fields], owner, `${slug}#pr-${num}:detail`);
        if (!result.ok || !result.value) throw new Error(result.error?.message || "Could not load pull request.");
        const pr = result.value;
        const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
        const failing = checks.filter((c) => ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED"].includes(String(c.conclusion || c.status || "").toUpperCase()));
        return {
            type: "pr",
            repo: slug,
            number: pr.number,
            title: pr.title || `PR #${num}`,
            body: String(pr.body || "").slice(0, 6000),
            state: pr.state,
            isDraft: Boolean(pr.isDraft),
            author: normalizeLogin(pr.author),
            url: pr.url,
            reviewDecision: pr.reviewDecision || "UNKNOWN",
            labels: (pr.labels || []).map((l) => l.name || l).filter(Boolean),
            assignees: (pr.assignees || []).map(normalizeLogin).filter(Boolean),
            additions: pr.additions ?? null,
            deletions: pr.deletions ?? null,
            changedFiles: pr.changedFiles ?? null,
            mergeable: pr.mergeable || "UNKNOWN",
            mergeStateStatus: pr.mergeStateStatus || "",
            headRefName: pr.headRefName || "",
            baseRefName: pr.baseRefName || "",
            checksTotal: checks.length,
            checksFailing: failing.length,
            createdAt: pr.createdAt,
            updatedAt: pr.updatedAt,
            ageDays: daysSince(pr.updatedAt),
            comments: normalizeComments(pr.comments),
        };
    }
    const fields = "number,title,body,state,author,url,labels,assignees,comments,createdAt,updatedAt";
    const result = await ghJson(["issue", "view", String(num), "--repo", slug, "--json", fields], owner, `${slug}#issue-${num}:detail`);
    if (!result.ok || !result.value) throw new Error(result.error?.message || "Could not load issue.");
    const issue = result.value;
    return {
        type: "issue",
        repo: slug,
        number: issue.number,
        title: issue.title || `Issue #${num}`,
        body: String(issue.body || "").slice(0, 6000),
        state: issue.state,
        author: normalizeLogin(issue.author),
        url: issue.url,
        labels: (issue.labels || []).map((l) => l.name || l).filter(Boolean),
        assignees: (issue.assignees || []).map(normalizeLogin).filter(Boolean),
        createdAt: issue.createdAt,
        updatedAt: issue.updatedAt,
        ageDays: daysSince(issue.updatedAt),
        comments: normalizeComments(issue.comments),
    };
}

const ITEM_ACTIONS = {
    comment: { verb: "comment", needsBody: true, both: true },
    close: { verb: "close", both: true },
    reopen: { verb: "reopen", both: true },
    "assign-me": { verb: "edit", issueOnly: true, extra: ["--add-assignee", "@me"] },
    "unassign-me": { verb: "edit", issueOnly: true, extra: ["--remove-assignee", "@me"] },
    "request-me": { verb: "edit", prOnly: true, extra: ["--add-reviewer", "@me"] },
    "copilot-review": { prOnly: true, api: true },
    ready: { verb: "ready", prOnly: true },
    approve: { verb: "review", prOnly: true, extra: ["--approve"] },
    merge: { verb: "merge", prOnly: true, extra: ["--squash"] },
};

export async function runItemAction({ repo, type, number, action, body } = {}) {
    const slug = String(repo || "");
    const owner = ownerOf(slug);
    const num = Number(number);
    if (!slug.includes("/") || !num || !["pr", "issue"].includes(type)) {
        throw new Error("A repo slug, item type (pr|issue), and number are required.");
    }
    const spec = ITEM_ACTIONS[action];
    if (!spec) throw new Error(`Unknown action: ${action}`);
    if (spec.prOnly && type !== "pr") throw new Error("That action only applies to pull requests.");
    if (spec.issueOnly && type !== "issue") throw new Error("That action only applies to issues.");
    if (spec.api && action === "copilot-review") {
        const apiArgs = ["api", "--method", "POST", `repos/${slug}/pulls/${num}/requested_reviewers`, "-f", "reviewers[]=copilot-pull-request-reviewer[bot]"];
        const apiResult = await run("gh", apiArgs, { env: ownerEnv(owner) });
        if (!apiResult.ok) throw new Error(apiResult.message || "Could not request a Copilot review. Copilot code review may not be enabled for this repo.");
        invalidateCache();
        return { ok: true, action, output: "Requested a review from Copilot." };
    }
    const args = [type, spec.verb, String(num), "--repo", slug];
    if (spec.needsBody) {
        const text = String(body || "").trim();
        if (!text) throw new Error("A comment body is required.");
        args.push("--body", text);
    }
    if (spec.extra) args.push(...spec.extra);
    const result = await run("gh", args, { env: ownerEnv(owner) });
    if (!result.ok) throw new Error(result.message || "Action failed.");
    invalidateCache();
    return { ok: true, action, output: (result.stdout || "").trim() || `${action} succeeded.` };
}

/* ---------------- orchestration ---------------- */

export function invalidateCache() {
    cachedModel = null;
    cachedAt = 0;
}

export async function collectDashboardState(force = false) {
    if (!force && cachedModel && nowMs() - cachedAt < CACHE_TTL_MS) return cachedModel;
    if (refreshPromise) return refreshPromise;
    refreshPromise = (async () => {
        const [preferences, config] = await Promise.all([readPreferences(), readConfig()]);
        const [currentLogin, sessionState] = await Promise.all([getCurrentLogin(), getCopilotSessions(config.repos)]);
        const sessionInventoryState = await getSessionInventory(config.repos);
        const userLogins = knownUserLogins(currentLogin);
        const sessionsByRepo = new Map();
        for (const recentSession of sessionState.sessions) {
            if (!recentSession.repository) continue;
            const list = sessionsByRepo.get(recentSession.repository) || [];
            list.push(recentSession);
            sessionsByRepo.set(recentSession.repository, list);
        }
        const repos = await Promise.all(config.repos.map((repo) => collectRepo(repo, userLogins, sessionsByRepo)));
        const focusItems = buildFocusItems(repos, sessionState.sessions);
        const recommendations = buildRecommendations(focusItems, preferences);
        const errors = [...sessionState.errors, ...repos.flatMap((repo) => repo.errors.map((error) => ({ ...error, repo: repo.slug })))];
        cachedModel = {
            generatedAt: new Date().toISOString(),
            onboarded: config.onboarded && config.repos.length > 0,
            preferences,
            currentLogin,
            options: { moods: MOODS, busyness: BUSYNESS, minutes: MINUTE_OPTIONS, focusIntents: FOCUS_INTENTS },
            summary: summarizeDashboard(repos, sessionState.sessions, focusItems, errors, sessionInventoryState.inventory),
            repos,
            sessions: sessionState.sessions,
            sessionInventory: sessionInventoryState.inventory,
            focusItems,
            recommendations,
            errors,
        };
        cachedAt = nowMs();
        refreshPromise = null;
        return cachedModel;
    })();
    try {
        return await refreshPromise;
    } catch (error) {
        refreshPromise = null;
        throw error;
    }
}

async function getCurrentLogin() {
    const result = await ghJson(["api", "user"], "", "current-user", { timeoutMs: 8000 });
    return result.ok && result.value && result.value.login ? result.value.login : null;
}
