import Database from 'better-sqlite3';
import { join } from 'node:path';
import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
function rowToShareLink(row) {
    return {
        token: row.token,
        repo: row.repo,
        entryType: row.entry_type,
        entryId: row.entry_id,
        createdByUserId: row.created_by_user_id,
        createdAt: row.created_at,
        revokedAt: row.revoked_at,
        entityCreatedAt: row.entity_created_at ?? null,
    };
}
/** 16 bytes → 22-char base64url ≈ 128 bits. Permanent public bearer URL, so we
 *  use a full 128 bits rather than trimming — the entropy is free. */
function generateToken() {
    return `shr_${randomBytes(16).toString('base64url')}`;
}
/** Bump when columns change; migrations run in runMigrations(). Mirrors the
 *  CommentService / SqliteEncryptedStore version-gated pattern. */
const SCHEMA_VERSION = 2;
export class ShareLinkService {
    db;
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir))
            mkdirSync(sgDir, { recursive: true });
        // These rows are live bearer tokens — keep them off git AND out of any
        // workspace export bundle (the export route has its own ignore list; this
        // gitignore covers the commit path).
        this.ensureGitignore(sgDir);
        const dbPath = join(sgDir, 'share-links.db');
        this.db = new Database(dbPath);
        if (process.platform !== 'win32') {
            try {
                chmodSync(dbPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.db.pragma('journal_mode = WAL');
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS share_links (
        token              TEXT PRIMARY KEY,
        repo               TEXT NOT NULL,
        entry_type         TEXT NOT NULL,
        entry_id           TEXT NOT NULL,
        created_by_user_id INTEGER NOT NULL,
        created_at         TEXT NOT NULL DEFAULT (datetime('now')),
        revoked_at         TEXT
      )
    `);
        // At most one ACTIVE link per entry → mint is idempotent; revoked rows
        // are exempt so a re-share after revoke doesn't collide.
        this.db.exec(`
      CREATE UNIQUE INDEX IF NOT EXISTS idx_share_links_active_entry
        ON share_links(repo, entry_type, entry_id) WHERE revoked_at IS NULL
    `);
        this.db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
        this.runMigrations();
    }
    /** Keep the token DB (and its WAL sidecars) out of git. Bearer tokens must
     *  never be committed. Mirrors AssetIndexService.ensureGitignore. */
    ensureGitignore(sgDir) {
        const gitignorePath = join(sgDir, '.gitignore');
        const entries = ['share-links.db', 'share-links.db-wal', 'share-links.db-shm'];
        let content = '';
        if (existsSync(gitignorePath))
            content = readFileSync(gitignorePath, 'utf-8');
        const missing = entries.filter(e => !content.includes(e));
        if (missing.length > 0) {
            const suffix = (content && !content.endsWith('\n') ? '\n' : '') +
                '# Public share-link bearer tokens — never commit\n' +
                missing.join('\n') + '\n';
            writeFileSync(gitignorePath, content + suffix, 'utf-8');
        }
    }
    runMigrations() {
        const row = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
        const current = row ? parseInt(row.value, 10) || 0 : 0;
        if (current >= SCHEMA_VERSION)
            return;
        // v2: track the shared entry's created_at so a delete+recreate at the same
        // coords can't inherit an old token (the CREATE TABLE above is the v1 shape,
        // so a fresh DB also lands here and gains the column via this ALTER).
        if (current < 2) {
            this.db.exec(`ALTER TABLE share_links ADD COLUMN entity_created_at TEXT`);
        }
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION));
    }
    /**
     * Return the active token for an entry, creating one if none exists.
     * Idempotent: repeated calls for the same entry return the same token until
     * it is revoked.
     */
    mint(repo, entryType, entryId, createdByUserId, entityCreatedAt = null) {
        const existing = this.getActiveByEntry(repo, entryType, entryId);
        if (existing)
            return existing;
        // INSERT OR IGNORE so a concurrent mint for the same entry (e.g. a
        // double-click) loses harmlessly against the partial unique index instead
        // of throwing a 500. Re-select by coords afterward returns whichever token
        // won — ours, or the concurrent winner's.
        const token = generateToken();
        this.db.prepare(`INSERT OR IGNORE INTO share_links (token, repo, entry_type, entry_id, created_by_user_id, entity_created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(token, repo, entryType, entryId, createdByUserId, entityCreatedAt);
        return this.getActiveByEntry(repo, entryType, entryId);
    }
    /** The active link for an entry, or null if it isn't shared. Lets the entry
     *  view show "Unshare" only when a live link exists. */
    getActiveByEntry(repo, entryType, entryId) {
        const row = this.db.prepare(`SELECT * FROM share_links WHERE repo = ? AND entry_type = ? AND entry_id = ? AND revoked_at IS NULL`).get(repo, entryType, entryId);
        return row ? rowToShareLink(row) : null;
    }
    /** Revoke an entry's active link (the per-entry "Unshare" action). Returns
     *  true if a live link was revoked, false if the entry wasn't shared. */
    revokeByEntry(repo, entryType, entryId) {
        const res = this.db.prepare(`UPDATE share_links SET revoked_at = datetime('now') WHERE repo = ? AND entry_type = ? AND entry_id = ? AND revoked_at IS NULL`).run(repo, entryType, entryId);
        return res.changes > 0;
    }
    /** Resolve a token to its entry. Returns null when the token is unknown OR
     *  revoked — callers fail-closed (404) on null so neither case is an oracle. */
    resolve(token) {
        const row = this.db.prepare(`SELECT * FROM share_links WHERE token = ? AND revoked_at IS NULL`).get(token);
        return row ? rowToShareLink(row) : null;
    }
    /** Revoke a single link. Returns true if an active link was revoked. */
    revoke(token) {
        const res = this.db.prepare(`UPDATE share_links SET revoked_at = datetime('now') WHERE token = ? AND revoked_at IS NULL`).run(token);
        return res.changes > 0;
    }
    /** All active links (for the future management UI / audit). */
    listActive() {
        const rows = this.db.prepare(`SELECT * FROM share_links WHERE revoked_at IS NULL ORDER BY created_at DESC`).all();
        return rows.map(rowToShareLink);
    }
    close() {
        this.db.close();
    }
}
//# sourceMappingURL=share-link-service.js.map