import Database from 'better-sqlite3';
import { join } from 'node:path';
import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
export class AssetIndexService {
    db;
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir))
            mkdirSync(sgDir, { recursive: true });
        this.ensureGitignore(sgDir);
        const dbPath = join(sgDir, 'assets.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 assets (
        asset_id     TEXT PRIMARY KEY,
        repo         TEXT NOT NULL,
        owner_type   TEXT NOT NULL,
        owner_id     TEXT NOT NULL,
        filename     TEXT NOT NULL,
        content_type TEXT NOT NULL,
        media_type   TEXT NOT NULL,
        name         TEXT,
        created_at   TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Idempotent migration: add uploader_user_id to assets.db tables that
        // predate it (real workspaces already carry rows). PRAGMA table_info is
        // the portable "column exists?" check; ALTER ADD COLUMN backfills NULL
        // for existing rows, which is exactly the "unknown uploader" semantics.
        const cols = this.db.prepare(`PRAGMA table_info(assets)`).all();
        if (!cols.some(c => c.name === 'uploader_user_id')) {
            this.db.exec(`ALTER TABLE assets ADD COLUMN uploader_user_id TEXT`);
        }
        // Asset-as-embed: caption + size live next to the bytes (DB-only) so they
        // survive deletion of any embedding entity. Idempotent ADD COLUMN backfills
        // NULL for existing rows.
        if (!cols.some(c => c.name === 'caption')) {
            this.db.exec(`ALTER TABLE assets ADD COLUMN caption TEXT`);
        }
        if (!cols.some(c => c.name === 'size')) {
            this.db.exec(`ALTER TABLE assets ADD COLUMN size INTEGER`);
        }
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_type, owner_id)`);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_assets_repo ON assets(repo)`);
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS migration_ledger (
        repo     TEXT NOT NULL,
        old_key  TEXT NOT NULL,
        asset_id TEXT NOT NULL,
        PRIMARY KEY (repo, old_key)
      )
    `);
    }
    /** Keep assets.db (+ WAL/SHM) out of the workspace config repo — it's
     *  local, durable on the workspace volume, and not safely rebuildable from
     *  bodies, so it's never committed. Mirrors auth.db / secrets.db. */
    ensureGitignore(sgDir) {
        const gitignorePath = join(sgDir, '.gitignore');
        const entries = ['assets.db', 'assets.db-wal', 'assets.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' : '') +
                '# Asset ownership index — local, durable on the workspace volume\n' +
                missing.join('\n') + '\n';
            writeFileSync(gitignorePath, content + suffix, 'utf-8');
        }
    }
    /** Insert or replace the row for an assetId (idempotent on re-run).
     *  `caption` is intentionally NOT in the ON CONFLICT update set: it's
     *  written later by the async caption pass via `setCaption`, and a re-put
     *  (e.g. migration) must not clobber an existing caption back to null. */
    put(input) {
        this.db.prepare(`
      INSERT INTO assets (asset_id, repo, owner_type, owner_id, filename, content_type, media_type, name, uploader_user_id, size, created_at)
      VALUES (@assetId, @repo, @ownerType, @ownerId, @filename, @contentType, @mediaType, @name, @uploaderUserId, @size, COALESCE(@createdAt, datetime('now')))
      ON CONFLICT(asset_id) DO UPDATE SET
        repo=excluded.repo, owner_type=excluded.owner_type, owner_id=excluded.owner_id,
        filename=excluded.filename, content_type=excluded.content_type,
        media_type=excluded.media_type, name=excluded.name,
        uploader_user_id=excluded.uploader_user_id, size=excluded.size
    `).run({
            assetId: input.assetId,
            repo: input.repo,
            ownerType: input.ownerType,
            ownerId: input.ownerId,
            filename: input.filename,
            contentType: input.contentType,
            mediaType: input.mediaType,
            name: input.name ?? null,
            uploaderUserId: input.uploaderUserId ?? null,
            size: input.size ?? null,
            createdAt: input.createdAt ?? null,
        });
    }
    /** Set (or clear) the caption for an asset. Written by the async caption
     *  pass; isolated from `put` so it never touches ownership/size columns. */
    setCaption(assetId, caption) {
        this.db.prepare(`UPDATE assets SET caption = ? WHERE asset_id = ?`).run(caption, assetId);
    }
    /** True if `user` may delete the given asset: the original uploader, or a
     *  workspace owner. Unknown-uploader rows (null) are owner-only. This is the
     *  D2 rule for the workspace asset store; folder-scoped assets continue to
     *  use folder write access. `isWorkspaceOwner` is passed in to keep this
     *  service free of an auth-service dependency. */
    canDelete(assetId, userId, isWorkspaceOwner) {
        if (isWorkspaceOwner)
            return true;
        if (userId === undefined)
            return false;
        const row = this.get(assetId);
        if (!row)
            return false;
        return row.uploaderUserId === String(userId);
    }
    get(assetId) {
        const row = this.db.prepare(`SELECT * FROM assets WHERE asset_id = ?`).get(assetId);
        return row ? this.toRow(row) : null;
    }
    /**
     * Assets owned by `(repo, ownerType, ownerId)`. The `repo` scope is
     * mandatory: entity ids are unique only within (repo, type), NOT across the
     * workspace — `note/foo` can exist in two repos. Matching on owner alone
     * would let a delete/rename in repo A touch repo B's identically-named
     * entity's assets (cross-repo data loss).
     */
    listByOwner(repo, ownerType, ownerId) {
        const rows = this.db.prepare(`SELECT * FROM assets WHERE repo = ? AND owner_type = ? AND owner_id = ? ORDER BY created_at`).all(repo, ownerType, ownerId);
        return rows.map(r => this.toRow(r));
    }
    listByRepo(repo) {
        const rows = this.db.prepare(`SELECT * FROM assets WHERE repo = ? ORDER BY created_at`).all(repo);
        return rows.map(r => this.toRow(r));
    }
    /**
     * Reassign every asset owned by `(repo, fromType, fromId)`. Used by mutations:
     *   - rename:      reassignOwner(repo, type, oldId, { ownerId: newId })
     *   - changeType:  reassignOwner(repo, oldType, id, { ownerType: newType })
     *   - cross-repo move/transfer: reassignOwner(sourceRepo, type, id, { repo: target })
     * `repo` scopes the match (see listByOwner) — without it a rename in one repo
     * would reassign a same-named entity's assets in another. Returns rows updated.
     * No blob movement — keys are immutable.
     */
    reassignOwner(repo, fromType, fromId, changes) {
        const sets = [];
        // Distinct param names: @matchRepo scopes the WHERE; @newRepo is the SET.
        // Sharing one `@repo` would let a repo-change overwrite the match value.
        const params = { matchRepo: repo, fromType, fromId };
        if (changes.ownerType !== undefined) {
            sets.push('owner_type = @ownerType');
            params.ownerType = changes.ownerType;
        }
        if (changes.ownerId !== undefined) {
            sets.push('owner_id = @ownerId');
            params.ownerId = changes.ownerId;
        }
        if (changes.repo !== undefined) {
            sets.push('repo = @newRepo');
            params.newRepo = changes.repo;
        }
        if (sets.length === 0)
            return 0;
        const res = this.db.prepare(`UPDATE assets SET ${sets.join(', ')} WHERE repo = @matchRepo AND owner_type = @fromType AND owner_id = @fromId`).run(params);
        return res.changes;
    }
    /**
     * Relocate a single free-standing asset to another folder, addressed by its
     * immutable assetId (the asset manager's move). No blob movement — the key is
     * `{assetId}/{filename}`, independent of repo; only the index row's scope
     * changes. The relocated asset is normalized to folder-owned by its new repo
     * (ownerType='folder', ownerId=targetRepo) — a free-standing asset's owner IS
     * its folder, so this keeps the row coherent rather than stranding a stale
     * owner_type/owner_id in the destination. Returns true if a row was updated.
     * The caller gates this to free-standing assets.
     */
    moveByAssetId(assetId, targetRepo) {
        const res = this.db.prepare(`UPDATE assets SET repo = @repo, owner_type = 'folder', owner_id = @repo WHERE asset_id = @assetId`).run({ repo: targetRepo, assetId });
        return res.changes > 0;
    }
    delete(assetId) {
        this.db.prepare(`DELETE FROM assets WHERE asset_id = ?`).run(assetId);
    }
    // ── migration ledger (transient; dropped post-cutover) ───────────────────
    ledgerGet(repo, oldKey) {
        const row = this.db.prepare(`SELECT asset_id FROM migration_ledger WHERE repo = ? AND old_key = ?`).get(repo, oldKey);
        return row ? row.asset_id : null;
    }
    ledgerPut(repo, oldKey, assetId) {
        this.db.prepare(`INSERT OR IGNORE INTO migration_ledger (repo, old_key, asset_id) VALUES (?, ?, ?)`).run(repo, oldKey, assetId);
    }
    ledgerEntries() {
        const rows = this.db.prepare(`SELECT repo, old_key, asset_id FROM migration_ledger`).all();
        return rows.map(r => ({ repo: r.repo, oldKey: r.old_key, assetId: r.asset_id }));
    }
    dropLedger() {
        this.db.exec(`DROP TABLE IF EXISTS migration_ledger`);
    }
    close() {
        this.db.close();
    }
    toRow(r) {
        return {
            assetId: r.asset_id,
            repo: r.repo,
            ownerType: r.owner_type,
            ownerId: r.owner_id,
            filename: r.filename,
            contentType: r.content_type,
            mediaType: r.media_type,
            name: r.name ?? null,
            uploaderUserId: r.uploader_user_id ?? null,
            caption: r.caption ?? null,
            size: r.size ?? null,
            createdAt: r.created_at,
        };
    }
}
//# sourceMappingURL=asset-index-service.js.map