/**
 * Unit tests for authoredComposeFileArgs: the docker compose global-flag prefix
 * (`-f` per file, `-p <project>`, optional `--project-directory`) derived from a
 * stack's applied deploy spec.
 *
 * Contract:
 *   - no applied spec (single-file / non-git) -> [] so runtime stays plain auto-discovery
 *   - a multi-file applied spec -> ordered -f flags, then -p <stack>, then
 *     --project-directory <abs> when a context dir is set
 *   - any spec file path or context dir that is absolute / contains ".." throws
 *     before any args are returned (it is spliced straight into child-process argv)
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import path from 'path';
import fs from 'fs';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';

let tmpDir: string;
let authoredComposeFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeFileArgs;
let authoredComposeEnvFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeEnvFileArgs;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;

beforeAll(async () => {
    tmpDir = await setupTestDb();
    ({ authoredComposeFileArgs, authoredComposeEnvFileArgs } = await import('../utils/authoredComposeArgs'));
    ({ DatabaseService } = await import('../services/DatabaseService'));
    ({ NodeRegistry } = await import('../services/NodeRegistry'));
});

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

beforeEach(() => {
    const db = DatabaseService.getInstance();
    for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
});

/** Seed a git-source row (no applied spec yet) so setGitSourceAppliedSpec can target it. */
function seedSource(stackName: string, composePaths: string[]): void {
    DatabaseService.getInstance().upsertGitSource({
        stack_name: stackName,
        repo_url: 'https://github.com/example/repo.git',
        branch: 'main',
        compose_path: composePaths[0],
        compose_paths: composePaths,
        context_dir: null,
        sync_env: false,
        env_path: null,
        auth_type: 'none',
        encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
            encrypted_ca_bundle: null,
        auto_apply_on_webhook: false,
        auto_deploy_on_apply: false,
        last_applied_commit_sha: null,
        last_applied_content_hash: null,
        pending_commit_sha: null,
        pending_compose_content: null,
        pending_env_content: null,
        pending_fetched_at: null,
        last_debounce_at: null,
    });
}

describe('authoredComposeFileArgs', () => {
    it('returns [] for a stack with no git source at all', () => {
        expect(authoredComposeFileArgs('no-such-stack')).toEqual([]);
    });

    it('returns [] for a git-source stack with no applied spec (single-file)', () => {
        seedSource('single-stack', ['compose.yaml']);
        // No setGitSourceAppliedSpec call -> applied_deploy_spec stays null.
        expect(authoredComposeFileArgs('single-stack')).toEqual([]);
    });

    it('returns ordered -f / -p / --project-directory for a multi-file spec', () => {
        const stackName = 'multi-stack';
        seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: 'app',
        });

        const args = authoredComposeFileArgs(stackName);

        const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
        const expectedCtx = path.resolve(baseDir, stackName, 'app');
        expect(args).toEqual([
            '-f', 'compose.yaml',
            '-f', 'infra/prod.yml',
            '-p', stackName,
            '--project-directory', expectedCtx,
        ]);
    });

    it('omits --project-directory when the spec has no context dir', () => {
        const stackName = 'multi-no-ctx';
        seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: null,
        });

        expect(authoredComposeFileArgs(stackName)).toEqual([
            '-f', 'compose.yaml',
            '-f', 'infra/prod.yml',
            '-p', stackName,
        ]);
    });

    it('throws when a spec file path is absolute', () => {
        const stackName = 'abs-file';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', '/etc/passwd'],
            contextDir: null,
        });
        expect(() => authoredComposeFileArgs(stackName)).toThrow();
    });

    it('throws when a spec file path contains a ".." traversal', () => {
        const stackName = 'dotdot-file';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', '../escape.yml'],
            contextDir: null,
        });
        expect(() => authoredComposeFileArgs(stackName)).toThrow();
    });

    it('throws when the context dir contains a ".." traversal', () => {
        const stackName = 'dotdot-ctx';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: '../escape',
        });
        expect(() => authoredComposeFileArgs(stackName)).toThrow();
    });
});

/**
 * authoredComposeEnvFileArgs: the `--env-file <stackDir>/.env` flag a multi-file
 * Git deploy needs when a context dir is set. With `--project-directory <ctx>`,
 * Compose treats the context dir as the project directory and stops auto-finding
 * the root `.env` Sencho writes, so validation (which passes --env-file) and
 * deploy would otherwise resolve different env. Single-file / no-context stacks
 * keep Compose's default `.env` discovery from the stack dir, so they get no flag.
 */
describe('authoredComposeEnvFileArgs', () => {
    /** Create the on-disk stack directory and optionally a root .env for it. */
    function makeStackDir(stackName: string, withEnv: boolean): string {
        const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
        const stackDir = path.join(baseDir, stackName);
        fs.mkdirSync(stackDir, { recursive: true });
        if (withEnv) fs.writeFileSync(path.join(stackDir, '.env'), 'TAG=1\n', 'utf-8');
        else fs.rmSync(path.join(stackDir, '.env'), { force: true });
        return stackDir;
    }

    it('returns [] for a stack with no git source at all', async () => {
        expect(await authoredComposeEnvFileArgs('no-such-stack')).toEqual([]);
    });

    it('returns [] when the context dir is set but no .env exists', async () => {
        const stackName = 'ctx-no-env';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: 'app',
        });
        makeStackDir(stackName, false);
        expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]);
    });

    it('returns [] when a .env exists but the spec has no context dir', async () => {
        // No --project-directory, so the project dir stays the stack dir (cwd) and
        // Compose auto-discovers the root .env; an explicit flag is not needed.
        const stackName = 'env-no-ctx';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: null,
        });
        makeStackDir(stackName, true);
        expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]);
    });

    it('returns --env-file <stackDir>/.env when a context dir is set and a .env exists', async () => {
        const stackName = 'ctx-with-env';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: 'app',
        });
        const stackDir = makeStackDir(stackName, true);
        expect(await authoredComposeEnvFileArgs(stackName)).toEqual([
            '--env-file', path.join(stackDir, '.env'),
        ]);
    });

    it('returns [] for a stack name that escapes the compose base (path-injection guard)', async () => {
        // The inline barrier rejects a traversal name before the fs access, so no
        // --env-file is emitted for a path outside the compose base.
        const stackName = '../escape';
        seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: 'app',
        });
        expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]);
    });

    it('rethrows a non-ENOENT access error instead of silently dropping the env file', async () => {
        // An EACCES on an existing .env must surface, not be treated as "no env
        // file": dropping --env-file there would deploy a different effective config
        // than the one validated.
        const stackName = 'ctx-eacces';
        seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml', 'infra/prod.yml'],
            contextDir: 'app',
        });
        const spy = vi.spyOn(fs.promises, 'access').mockRejectedValueOnce(
            Object.assign(new Error('permission denied'), { code: 'EACCES' }),
        );
        await expect(authoredComposeEnvFileArgs(stackName)).rejects.toThrow(/permission denied/);
        spy.mockRestore();
    });
});

describe('candidateValidationEnvFileArgs', () => {
    let candidateValidationEnvFileArgs: typeof import('../utils/authoredComposeArgs').candidateValidationEnvFileArgs;

    beforeAll(async () => {
        ({ candidateValidationEnvFileArgs } = await import('../utils/authoredComposeArgs'));
    });

    function makeStackDir(stackName: string, withEnv: boolean): string {
        const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
        const stackDir = path.join(baseDir, stackName);
        fs.mkdirSync(stackDir, { recursive: true });
        if (withEnv) fs.writeFileSync(path.join(stackDir, '.env'), 'TAG=live\n', 'utf-8');
        else fs.rmSync(path.join(stackDir, '.env'), { force: true });
        return stackDir;
    }

    it('uses candidate .env for context-dir sync-env, not the live stack .env', async () => {
        const stackName = 'val-sync-env';
        seedSource(stackName, ['compose.yaml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml'],
            contextDir: 'app',
        });
        const liveDir = makeStackDir(stackName, true);
        const candidateAbs = path.join(tmpDir, 'candidate-sync-env');
        fs.mkdirSync(candidateAbs, { recursive: true });
        fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8');
        const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
        const args = await candidateValidationEnvFileArgs({
            stackName,
            nodeId,
            candidateAbs,
            contextDir: 'app',
            syncEnv: true,
        });
        expect(args).toEqual(['--env-file', path.resolve(candidateAbs, '.env')]);
        expect(args).not.toContain(path.resolve(liveDir, '.env'));
    });

    it('does not fall back to a live .env when sync-env omits the candidate file', async () => {
        const stackName = 'val-sync-env-removed';
        seedSource(stackName, ['compose.yaml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml'],
            contextDir: 'app',
        });
        const liveDir = makeStackDir(stackName, true);
        const candidateAbs = path.join(tmpDir, 'candidate-sync-env-removed');
        fs.mkdirSync(candidateAbs, { recursive: true });
        const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
        const args = await candidateValidationEnvFileArgs({
            stackName,
            nodeId,
            candidateAbs,
            contextDir: 'app',
            syncEnv: true,
        });
        expect(args).toEqual([]);
        expect(args).not.toContain(path.resolve(liveDir, '.env'));
        const deploy = await authoredComposeEnvFileArgs(stackName, nodeId);
        expect(deploy).toEqual(['--env-file', path.resolve(liveDir, '.env')]);
    });

    it('falls back to the live .env when an unmanaged candidate file is absent', async () => {
        const stackName = 'val-unmanaged-env-fallback';
        seedSource(stackName, ['compose.yaml']);
        DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
            files: ['compose.yaml'],
            contextDir: 'app',
        });
        const liveDir = makeStackDir(stackName, true);
        const candidateAbs = path.join(tmpDir, 'candidate-unmanaged-env');
        fs.mkdirSync(candidateAbs, { recursive: true });
        const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
        const args = await candidateValidationEnvFileArgs({
            stackName,
            nodeId,
            candidateAbs,
            contextDir: 'app',
            syncEnv: false,
        });
        expect(args).toEqual(['--env-file', path.resolve(liveDir, '.env')]);
    });

    it('uses only configured project env files even when candidate .env exists', async () => {
        const stackName = 'val-project-env';
        seedSource(stackName, ['compose.yaml']);
        const liveDir = makeStackDir(stackName, true);
        fs.writeFileSync(path.join(liveDir, 'prod.env'), 'FOO=1\n', 'utf-8');
        DatabaseService.getInstance().setStackProjectEnvFiles(
            NodeRegistry.getInstance().getDefaultNodeId(),
            stackName,
            ['prod.env'],
        );
        const candidateAbs = path.join(tmpDir, 'candidate-project-env');
        fs.mkdirSync(candidateAbs, { recursive: true });
        fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8');
        const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
        const validation = await candidateValidationEnvFileArgs({
            stackName,
            nodeId,
            candidateAbs,
            contextDir: 'app',
            syncEnv: true,
        });
        const deploy = await authoredComposeEnvFileArgs(stackName, nodeId);
        expect(validation).toEqual(deploy);
        expect(validation).toEqual(['--env-file', path.resolve(liveDir, 'prod.env')]);
        expect(validation.join(' ')).not.toContain(path.join(candidateAbs, '.env'));
    });

    it('throws when a configured project env file is missing', async () => {
        const stackName = 'val-missing-env';
        seedSource(stackName, ['compose.yaml']);
        makeStackDir(stackName, false);
        DatabaseService.getInstance().setStackProjectEnvFiles(
            NodeRegistry.getInstance().getDefaultNodeId(),
            stackName,
            ['missing.env'],
        );
        const candidateAbs = path.join(tmpDir, 'candidate-missing-env');
        fs.mkdirSync(candidateAbs, { recursive: true });
        const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
        await expect(candidateValidationEnvFileArgs({
            stackName,
            nodeId,
            candidateAbs,
            contextDir: null,
            syncEnv: false,
        })).rejects.toThrow(/missing/);
    });
});
