/**
 * Preview-confirm apply binding and stale-guard behaviour.
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';

let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
let counter = 0;

function seedNode(): { id: number; name: string } {
    counter += 1;
    const name = `bp-preview-node-${counter}`;
    const db = DatabaseService.getInstance().getDb();
    const result = db.prepare(
        `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
         VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
    ).run(name, Date.now());
    return { id: result.lastInsertRowid as number, name };
}

function validBlueprintBody(nodeId: number) {
    return {
        name: `bp-preview-${counter + 1}`,
        compose_content: 'services:\n  app:\n    image: nginx\n',
        selector: { type: 'nodes', ids: [nodeId] },
        drift_mode: 'observe',
    };
}

beforeAll(async () => {
    tmpDir = await setupTestDb();
    ({ app } = await import('../index'));
    ({ DatabaseService } = await import('../services/DatabaseService'));
    ({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
    adminCookie = await loginAsTestAdmin(app);
});

afterAll(() => cleanupTestDb(tmpDir));

beforeEach(() => {
    vi.restoreAllMocks();
    vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileConfirmedPlan').mockResolvedValue({ outcomes: [] });
    const db = DatabaseService.getInstance().getDb();
    db.prepare('DELETE FROM blueprint_deployments').run();
    db.prepare('DELETE FROM blueprints').run();
    db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
});

describe('POST /api/blueprints/:id/apply confirm binding', () => {
    it('rejects apply without planFingerprint', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(400);
        expect(res.body.code).toBe('CONFIRM_REQUIRED');
    });

    it('rejects stale fingerprint with PREVIEW_STALE', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);

        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: 'deadbeef',
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(409);
        expect(res.body.code).toBe('PREVIEW_STALE');
    });

    it('persists approval and calls reconcileConfirmedPlan with executor actions', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);
        expect(preview.body.effectiveApproval).toBe('pending');
        expect(preview.body.planFingerprint).toBeTruthy();

        const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: preview.body.planFingerprint,
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(200);
        expect(res.body.effectiveApproval).toBe('approved');
        expect(res.body.outcomes).toEqual([]);
        expect(res.body.outcomeSummary).toEqual({
            total: 0,
            ok: 0,
            failed: 0,
            pending: 0,
            skipped: 0,
        });
        expect(reconcileSpy).toHaveBeenCalledWith(created.body.id, preview.body.executorActions);

        const detail = await request(app)
            .get(`/api/blueprints/${created.body.id}`)
            .set('Cookie', adminCookie);
        expect(detail.status).toBe(200);
        expect(detail.body.effectiveApproval).toBe('approved');

        const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
        expect(stored.approval_status).toBe('approved');
        expect(stored.approved_intent_fingerprint).toBe(preview.body.planFingerprint);
        expect(stored.updated_at).toBe(created.body.updated_at);
    });

    it('returns PREVIEW_STALE when compose drifts between preview and approval persist', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);

        const db = DatabaseService.getInstance();
        const originalSet = db.setBlueprintApproval.bind(db);
        vi.spyOn(db, 'setBlueprintApproval').mockImplementation((id, input) => {
            // Concurrent edit landed compose v2 before approval was written; Apply
            // still persists the v1 fingerprint onto that row.
            db.getDb().prepare('UPDATE blueprints SET compose_content = ? WHERE id = ?').run(
                'services:\n  app:\n    image: nginx:evil\n',
                id,
            );
            return originalSet(id, input);
        });

        const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: preview.body.planFingerprint,
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(409);
        expect(res.body.code).toBe('PREVIEW_STALE');
        expect(reconcileSpy).not.toHaveBeenCalled();

        const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
        expect(stored.approval_status).toBe('pending');
        expect(stored.approved_intent_fingerprint).toBeNull();
    });

    it('returns PREVIEW_STALE when reconcileConfirmedPlan refuses after approval', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);

        vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
            outcomes: [],
            refused: true,
        });

        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: preview.body.planFingerprint,
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(409);
        expect(res.body.code).toBe('PREVIEW_STALE');

        const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
        expect(stored.approval_status).toBe('pending');
        expect(stored.approved_intent_fingerprint).toBeNull();
    });

    it('reports live pending approval when approval is cleared during reconcile', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);

        vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockImplementationOnce(async (id) => {
            // Concurrent edit invalidated approval while the confirmed snapshot ran.
            DatabaseService.getInstance().clearBlueprintApproval(id);
            return {
                outcomes: [{
                    nodeId: node.id,
                    nodeName: node.name,
                    action: 'create',
                    status: 'ok',
                }],
            };
        });

        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: preview.body.planFingerprint,
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(200);
        expect(res.body.effectiveApproval).toBe('pending');
        expect(res.body.message).toMatch(/approval is no longer current/i);
        expect(res.body.outcomes).toHaveLength(1);
        expect(res.body.outcomes[0].status).toBe('ok');
        expect(res.body.outcomeSummary.ok).toBe(1);

        const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
        expect(stored.approval_status).toBe('pending');
    });

    it('returns failed outcomes without pretending the rollout was clean', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);

        vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
            outcomes: [{
                nodeId: node.id,
                nodeName: node.name,
                action: 'create',
                status: 'failed',
                error: 'EHOSTUNREACH',
            }],
        });

        const res = await request(app)
            .post(`/api/blueprints/${created.body.id}/apply`)
            .set('Cookie', adminCookie)
            .send({
                planFingerprint: preview.body.planFingerprint,
                actions: preview.body.confirmableActions,
            });
        expect(res.status).toBe(200);
        expect(res.body.effectiveApproval).toBe('approved');
        expect(res.body.message).toMatch(/node failures/i);
        expect(res.body.outcomeSummary.failed).toBe(1);
        expect(res.body.outcomes[0].error).toBe('EHOSTUNREACH');
    });

    it('marks create as blocked when an unmanaged same-name stack already exists', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const composeDir = process.env.COMPOSE_DIR!;
        DatabaseService.getInstance().getDb()
            .prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
            .run(composeDir, node.id);

        const stackDir = path.join(composeDir, created.body.name as string);
        fs.mkdirSync(stackDir, { recursive: true });
        fs.writeFileSync(path.join(stackDir, 'docker-compose.yml'), 'services:\n  app:\n    image: nginx\n');

        const { BlueprintService } = await import('../services/BlueprintService');
        const conflict = await BlueprintService.getInstance().hasNameConflict(
            created.body.name as string,
            DatabaseService.getInstance().getNode(node.id)!,
            created.body.id as number,
        );
        expect(conflict).toBe(true);

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);
        const change = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
        expect(change.action).toBe('blocked_name_conflict');
        expect(change.severity).toBe('blocker');
        expect(preview.body.summary.blocker).toBeGreaterThan(0);
        // Name-conflict detection must not mutate unmanaged alternate compose files.
        expect(fs.readFileSync(path.join(stackDir, 'docker-compose.yml'), 'utf-8'))
            .toBe('services:\n  app:\n    image: nginx\n');
    });

    it('hasNameConflict is false for a matching marker and true for a foreign marker', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        const composeDir = process.env.COMPOSE_DIR!;
        DatabaseService.getInstance().getDb()
            .prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
            .run(composeDir, node.id);
        const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
        const stackName = created.body.name as string;
        const blueprintId = created.body.id as number;
        const stackDir = path.join(composeDir, stackName);
        fs.mkdirSync(stackDir, { recursive: true });
        fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n  app:\n    image: nginx\n');
        fs.writeFileSync(
            path.join(stackDir, '.blueprint.json'),
            JSON.stringify({ blueprintId, revision: 1, lastApplied: 1 }),
        );

        const { BlueprintService } = await import('../services/BlueprintService');
        const svc = BlueprintService.getInstance();
        expect(await svc.hasNameConflict(stackName, nodeObj, blueprintId)).toBe(false);

        fs.writeFileSync(
            path.join(stackDir, '.blueprint.json'),
            JSON.stringify({ blueprintId: blueprintId + 99, revision: 1, lastApplied: 1 }),
        );
        expect(await svc.hasNameConflict(stackName, nodeObj, blueprintId)).toBe(true);
    });

    it('blocks rename while a non-withdrawn deployment exists', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send(validBlueprintBody(node.id));
        expect(created.status).toBe(201);

        DatabaseService.getInstance().upsertDeployment({
            blueprint_id: created.body.id,
            node_id: node.id,
            status: 'active',
            applied_revision: 1,
            last_deployed_at: Date.now(),
        });

        const res = await request(app)
            .put(`/api/blueprints/${created.body.id}`)
            .set('Cookie', adminCookie)
            .send({ name: 'renamed-while-live' });
        expect(res.status).toBe(409);
        expect(res.body.code).toBe('RENAME_BLOCKED');
    });
});

describe('preview projection status precedence', () => {
    it('emits informational in_flight_deploy for a leaving deploying row, not remove', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send({
                ...validBlueprintBody(node.id),
                selector: { type: 'nodes', ids: [] },
            });
        expect(created.status).toBe(201);

        DatabaseService.getInstance().upsertDeployment({
            blueprint_id: created.body.id,
            node_id: node.id,
            status: 'deploying',
            applied_revision: 1,
        });

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);
        const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
        expect(row?.action).toBe('in_flight_deploy');
        expect(row?.kind).toBe('informational');
        expect(preview.body.executorActions.some(
            (a: { nodeId: number; action: string }) => a.nodeId === node.id && a.action === 'remove',
        )).toBe(false);
    });

    it('emits clear_stale_guard for never-deployed pending_state_review leavers', async () => {
        const node = seedNode();
        counter += 1;
        const created = await request(app)
            .post('/api/blueprints')
            .set('Cookie', adminCookie)
            .send({
                name: `bp-stale-${counter}`,
                compose_content: 'services:\n  app:\n    image: nginx\n    volumes:\n      - data:/data\nvolumes:\n  data:\n',
                selector: { type: 'nodes', ids: [] },
                drift_mode: 'observe',
            });
        expect(created.status).toBe(201);

        DatabaseService.getInstance().upsertDeployment({
            blueprint_id: created.body.id,
            node_id: node.id,
            status: 'pending_state_review',
            last_deployed_at: null,
        });

        const preview = await request(app)
            .get(`/api/blueprints/${created.body.id}/preview`)
            .set('Cookie', adminCookie);
        expect(preview.status).toBe(200);
        const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
        expect(row?.action).toBe('clear_stale_guard');
        expect(preview.body.executorActions).toContainEqual({ nodeId: node.id, action: 'clear_stale_guard' });
    });
});
