// ==========================================
// API Response Contract Tests
// ==========================================
// These tests validate that frontend code
// correctly handles the actual backend
// response shapes. Every bug from the v0.9.0
// rollout was a contract mismatch.
//
// Run: node --test src/js/__tests__/api-contracts.test.js
// ==========================================

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');

// ── Admin /users response ────────────────────
// Normalized: uses standard {data: [...], total: N} envelope.
// Has siblings → _unwrap does NOT strip. Frontend reads .data and .total.

describe('GET /admin/users response contract', () => {
    // ACTUAL backend response shape from ListUsers handler:
    //   c.JSON(200, gin.H{"data": users, "total": total})
    const backendResponse = {
        data: [
            { id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true },
            { id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true },
        ],
        total: 2,
    };

    it('response has "data" array', () => {
        assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
    });

    it('frontend extraction reads .data (siblings present, _unwrap passes through)', () => {
        const users = backendResponse.data || [];
        assert.equal(users.length, 2);
        assert.equal(users[0].id, 'u1');
    });

    it('each user has required fields for member dropdown', () => {
        for (const u of backendResponse.data) {
            assert.ok(u.id, 'user must have id');
            assert.ok(u.username || u.email, 'user must have username or email');
            assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean');
        }
    });

    it('total is numeric', () => {
        assert.equal(typeof backendResponse.total, 'number');
        assert.ok(backendResponse.total >= backendResponse.data.length);
    });
});

// ── /models/enabled response ─────────────────
// Returns {data: [...], default_model: "..."}

describe('GET /models/enabled response contract', () => {
    // ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id.
    // Mock uses provider_config_id as primary (Go struct canonical name)
    // and config_id as the alias (populated by resolver for frontend compat).
    const backendResponse = {
        data: [
            {
                id: 'cfg1:gpt-4o',
                model_id: 'gpt-4o',
                display_name: 'GPT-4o',
                provider_config_id: 'cfg1',
                config_id: 'cfg1',       // Alias — MUST match provider_config_id
                provider_name: 'OpenAI',
                provider_type: 'openai',
                source: 'catalog',
                scope: 'global',
                capabilities: { streaming: true, vision: true, tool_calling: true },
                pricing: {},
            },
        ],
        default_model: '',
    };

    it('response has "data" array', () => {
        assert.ok(Array.isArray(backendResponse.data));
    });

    it('each model has required fields for selector', () => {
        for (const m of backendResponse.data) {
            assert.ok(m.model_id || m.id, 'must have model_id or id');
            assert.ok(m.display_name || m.model_id, 'must have display_name or model_id');
        }
    });

    it('catalog models have provider_config_id (backend canonical)', () => {
        const catalogModels = backendResponse.data.filter(m => m.source === 'catalog');
        for (const m of catalogModels) {
            assert.ok(m.provider_config_id,
                'catalog model must have provider_config_id (Go struct canonical name)');
        }
    });

    it('catalog models have config_id alias matching provider_config_id', () => {
        const catalogModels = backendResponse.data.filter(m => m.source === 'catalog');
        for (const m of catalogModels) {
            assert.ok(m.config_id,
                'catalog model must have config_id (frontend alias)');
            assert.equal(m.config_id, m.provider_config_id,
                'config_id alias must equal provider_config_id');
        }
    });

    it('capabilities is an object (not null)', () => {
        for (const m of backendResponse.data) {
            assert.equal(typeof m.capabilities, 'object');
            assert.notEqual(m.capabilities, null);
        }
    });
});

// ── /models/enabled with persona response ────

describe('GET /models/enabled persona contract', () => {
    // ACTUAL backend UserModel for a persona (v0.22.8+: unified naming, no aliases).
    const personaModel = {
        id: 'persona-uuid-123',
        model_id: 'gpt-4o',
        display_name: 'Code Helper',
        source: 'persona',
        // Backend canonical fields (v0.22.8: all use persona_ prefix)
        provider_config_id: 'cfg1',
        is_persona: true,
        persona_id: 'persona-uuid-123',
        persona_scope: 'global',
        persona_avatar: null,
        persona_team_name: null,
        config_id: 'cfg1',
        provider_name: 'OpenAI',
        capabilities: { streaming: true },
    };

    it('persona has is_persona = true', () => {
        assert.equal(personaModel.is_persona, true);
    });

    it('persona has persona_id', () => {
        assert.ok(personaModel.persona_id);
    });

    it('persona has persona_scope', () => {
        assert.ok(['global', 'team', 'personal'].includes(personaModel.persona_scope));
    });

    it('persona ID uses persona_id not composite', () => {
        // Frontend: id = isPersona ? (m.persona_id || m.id) : composite
        const id = personaModel.is_persona
            ? (personaModel.persona_id || personaModel.id)
            : `${personaModel.config_id || personaModel.provider_config_id}:${personaModel.model_id}`;
        assert.equal(id, 'persona-uuid-123');
    });

    it('frontend works with ONLY canonical fields (no aliases)', () => {
        // v0.22.8: no more dual-key aliases — backend sends is_persona directly.
        const rawBackend = {
            id: 'persona-uuid-123',
            model_id: 'gpt-4o',
            source: 'persona',
            provider_config_id: 'cfg1',
            is_persona: true,
            persona_id: 'persona-uuid-123',
            persona_scope: 'global',
        };
        const isPersona = !!rawBackend.is_persona;
        assert.equal(isPersona, true,
            'is_persona field must be present for correct identification');
    });
});

// ── /settings/public response ────────────────
// Bug: allow_user_personas not exposed → no toggle in UI

describe('GET /settings/public response contract', () => {
    const backendResponse = {
        banner: { enabled: false },
        branding: null,
        policies: {
            allow_registration: 'true',
            allow_user_byok: 'true',
            allow_user_personas: 'false',
        },
    };

    it('response has policies object', () => {
        assert.equal(typeof backendResponse.policies, 'object');
        assert.notEqual(backendResponse.policies, null);
    });

    it('policies includes allow_user_byok', () => {
        assert.ok('allow_user_byok' in backendResponse.policies,
            'MISSING: allow_user_byok — user provider tab will break');
    });

    it('policies includes allow_user_personas', () => {
        assert.ok('allow_user_personas' in backendResponse.policies,
            'MISSING: allow_user_personas — preset creation gating will break');
    });

    it('policies includes allow_registration', () => {
        assert.ok('allow_registration' in backendResponse.policies);
    });

    it('policy values are string "true"/"false" (not boolean)', () => {
        for (const [key, val] of Object.entries(backendResponse.policies)) {
            assert.equal(typeof val, 'string',
                `policy ${key} must be string, got ${typeof val}`);
            assert.ok(['true', 'false'].includes(val),
                `policy ${key} must be "true" or "false", got "${val}"`);
        }
    });
});

// ── /admin/settings response ─────────────────

describe('GET /admin/settings response contract', () => {
    const backendResponse = {
        settings: {
            banner: { enabled: false, text: '', position: 'both', bg: '#007a33', fg: '#ffffff' },
        },
        policies: {
            allow_registration: 'true',
            default_user_active: 'false',
            allow_user_byok: 'false',
            allow_user_personas: 'false',
            allow_team_providers: 'true',
        },
    };

    it('response has settings and policies', () => {
        assert.ok(backendResponse.settings, 'must have settings');
        assert.ok(backendResponse.policies, 'must have policies');
    });

    const requiredPolicies = [
        'allow_registration',
        'default_user_active',
        'allow_user_byok',
        'allow_user_personas',
    ];

    for (const key of requiredPolicies) {
        it(`policies includes ${key}`, () => {
            assert.ok(key in backendResponse.policies,
                `MISSING policy: ${key} — admin settings UI will be incomplete`);
        });
    }
});

// ── /api-configs (user) response ─────────────
// Bug: ListConfigs returned global + personal; should be personal only

describe('GET /api-configs response contract (user scope)', () => {
    const backendResponse = [
        {
            id: 'cfg-personal-1',
            name: 'My OpenAI Key',
            provider: 'openai',
            endpoint: 'https://api.openai.com/v1',
            scope: 'personal',
            is_active: true,
        },
    ];

    it('all configs are personal scope', () => {
        for (const cfg of backendResponse) {
            assert.equal(cfg.scope, 'personal',
                `user /api-configs must only return personal scope, got: ${cfg.scope}`);
        }
    });

    it('no global configs leak to user list', () => {
        const global = backendResponse.filter(c => c.scope === 'global');
        assert.equal(global.length, 0,
            'SECURITY: global configs must NOT appear in user /api-configs');
    });

    it('each config has required fields', () => {
        for (const cfg of backendResponse) {
            assert.ok(cfg.id, 'must have id');
            assert.ok(cfg.name, 'must have name');
            assert.ok(cfg.provider, 'must have provider');
        }
    });
});

// ── /teams/:id/members response ──────────────

describe('GET /teams/:id/members response contract', () => {
    const backendResponse = {
        data: [
            {
                id: 'tm1', user_id: 'u1', role: 'admin',
                email: 'alice@test.com', display_name: 'Alice', joined_at: '2026-01-01T00:00:00Z',
            },
        ],
    };

    it('response has "data" array', () => {
        assert.ok(Array.isArray(backendResponse.data));
    });

    it('each member has user_id for exclusion filtering', () => {
        for (const m of backendResponse.data) {
            assert.ok(m.user_id, 'member must have user_id');
            assert.ok(m.role, 'member must have role');
        }
    });
});

// ── /admin/models response ──────────────────
// Returns CatalogEntry objects (not UserModel).
// All models regardless of visibility.

describe('GET /admin/models response contract', () => {
    // ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
    // Normalized: {data: [...]} sole key → _unwrap strips → frontend gets bare array.
    const backendResponse = {
        data: [
            {
                id: 'catalog-uuid-1',
                provider_config_id: 'provider-uuid',
                model_id: 'gpt-4o',
                display_name: 'GPT-4o',
                capabilities: { streaming: true, tool_calling: true },
                pricing: null,
                visibility: 'disabled',
                last_synced_at: '2026-02-22T00:00:00Z',
                created_at: '2026-02-22T00:00:00Z',
                updated_at: '2026-02-22T00:00:00Z',
            },
        ],
    };

    it('response has "data" array (not null)', () => {
        assert.ok(Array.isArray(backendResponse.data),
            'CRITICAL: data must be [] not null');
    });

    it('each model has id and model_id', () => {
        for (const m of backendResponse.data) {
            assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick');
            assert.ok(m.model_id, 'must have model_id for display');
        }
    });

    it('each model has visibility', () => {
        for (const m of backendResponse.data) {
            assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
                `visibility must be enabled/disabled/team, got: ${m.visibility}`);
        }
    });

    it('_unwrap strips sole-key {data:[]} → frontend gets bare array', () => {
        // After _unwrap, frontend receives the bare array, uses `resp || []`
        const unwrapped = backendResponse.data; // simulates _unwrap
        const list = unwrapped || [];
        assert.ok(Array.isArray(list));
        assert.equal(list.length, 1);
    });
});

// ── /admin/models/fetch response ────────────

describe('POST /admin/models/fetch response contract', () => {
    const successResponse = {
        message: 'models synced',
        added: 15,
        updated: 3,
        total: 18,
    };

    const errorResponse = {
        message: 'models synced',
        added: 0,
        updated: 0,
        total: 0,
        errors: ['venice: HTTP 401: invalid API key'],
    };

    it('success response has counts', () => {
        assert.equal(typeof successResponse.added, 'number');
        assert.equal(typeof successResponse.updated, 'number');
        assert.equal(typeof successResponse.total, 'number');
    });

    it('error response has errors array', () => {
        assert.ok(Array.isArray(errorResponse.errors));
        assert.ok(errorResponse.errors.length > 0);
    });

    it('frontend must check errors array (not just HTTP status)', () => {
        // The handler returns 200 even with errors — frontend must inspect body
        const errs = errorResponse.errors || [];
        assert.ok(errs.length > 0,
            'CRITICAL: fetch can return 200 with errors — frontend must surface them');
    });
});

// ── Cross-endpoint consistency ───────────────

describe('Response shape consistency — all endpoints use {data:[...]} envelope', () => {
    it('admin/users uses {data:[], total:N} (siblings → not unwrapped)', () => {
        const shape = { data: [], total: 0 };
        assert.ok('data' in shape);
        assert.ok('total' in shape);
    });

    it('teams/members uses {data:[]}', () => {
        const shape = { data: [] };
        assert.ok('data' in shape);
    });

    it('models/enabled uses {data:[], default_model:""} (siblings → not unwrapped)', () => {
        const shape = { data: [], default_model: '' };
        assert.ok('data' in shape);
    });

    it('admin/models uses {data:[]} (sole key → unwrapped)', () => {
        const shape = { data: [] };
        assert.ok('data' in shape);
    });

    it('personas uses {data:[]} (sole key → unwrapped)', () => {
        const shape = { data: [] };
        assert.ok('data' in shape);
    });
});
