import * as vscode from 'vscode'
import { BuildOptionsPathKeys, ManifestSchema, Module, SdkExtension } from './flatpak.types'
import * as path from 'path'
import { getuid } from 'process'
import { cpus } from 'os'
import * as fs from 'fs/promises'
import { Command } from './command'
import { generatePathOverride, getHostEnv } from './utils'
import { getFlatpakVersion, versionCompare } from './flatpakUtils'
import { checkForMissingRuntimes } from './manifestUtils'

const DEFAULT_BUILD_SYSTEM_BUILD_DIR = '_build'

export class Manifest {
    readonly uri: vscode.Uri
    readonly manifest: ManifestSchema
    private readonly repoDir: string
    private readonly finializedRepoDir: string
    private readonly ostreeRepoPath: string
    readonly buildDir: string
    readonly workspace: string
    readonly stateDir: string
    readonly requiredVersion?: string

    constructor(
        uri: vscode.Uri,
        manifest: ManifestSchema,
    ) {
        this.uri = uri
        this.manifest = manifest
        this.workspace = vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath || ''
        this.buildDir = path.join(this.workspace, '.flatpak')
        this.repoDir = path.join(this.buildDir, 'repo')
        this.finializedRepoDir = path.join(this.buildDir, 'finalized-repo')
        this.ostreeRepoPath = path.join(this.buildDir, 'ostree-repo')
        this.stateDir = path.join(this.buildDir, 'flatpak-builder')
        this.requiredVersion = manifest['finish-args'].map((val) => val.split('=')).find((value) => {
            return value[0] === '--require-version'
        })?.[1]
    }

    async isBuildInitialized(): Promise<boolean> {
        const repoDir = vscode.Uri.file(this.repoDir)
        const metadataFile = vscode.Uri.joinPath(repoDir, 'metadata')
        const filesDir = vscode.Uri.joinPath(repoDir, 'files')
        const varDir = vscode.Uri.joinPath(repoDir, 'var')

        try {
            // From gnome-builder
            // https://gitlab.gnome.org/GNOME/gnome-builder/-/blob/8579055f5047a0af5462e8a587b0742014d71d64/src/plugins/flatpak/gbp-flatpak-pipeline-addin.c#L220
            return (await vscode.workspace.fs.stat(metadataFile)).type === vscode.FileType.File
                && (await vscode.workspace.fs.stat(filesDir)).type === vscode.FileType.Directory
                && (await vscode.workspace.fs.stat(varDir)).type === vscode.FileType.Directory
        } catch (err) {
            if (err instanceof vscode.FileSystemError && err.code === 'FileNotFound') {
                return false
            }

            throw err
        }
    }

    /**
     * Check for invalidity in the manifest
     * @returns an Error with a message if there is an error otherwise null
     */
    checkForError(): Error | null {
        if (this.requiredVersion !== undefined) {
            const flatpakVersion = getFlatpakVersion()
            if (!versionCompare(flatpakVersion, this.requiredVersion)) {
                return new Error(`Manifest requires ${this.requiredVersion} but ${flatpakVersion} is available.`)
            }
        }

        const missingRuntimes = checkForMissingRuntimes(this)
        if (missingRuntimes.length !== 0) {
            return new Error(`Manifest requires the following but are not installed: ${missingRuntimes.join(', ')}`)
        }

        return null
    }

    id(): string {
        return this.manifest['app-id'] || this.manifest.id || 'org.flatpak.Test'
    }

    sdkExtensions(): SdkExtension[] {
        const rawSdkExtensions = this.manifest['sdk-extensions']

        if (rawSdkExtensions === undefined) {
            return []
        }

        const sdkExtensions: SdkExtension[] = []
        for (const rawSdkExtension of rawSdkExtensions) {
            const suffix = rawSdkExtension.split('.').pop()

            if (suffix === undefined) {
                continue
            }

            switch (suffix) {
                case 'rust-stable':
                case 'rust-nightly':
                    sdkExtensions.push('rust')
                    break
                case 'vala':
                    sdkExtensions.push('vala')
                    break
                default:
                    console.warn(`SDK extension '${suffix}' was not handled`)
            }
        }

        return sdkExtensions
    }

    /**
     * Returns the the latest Flatpak module
     */
    module(): Module {
        return this.manifest.modules.slice(-1)[0]
    }

    /**
     * Returns the manifest path
     */
    path(): string {
        return this.uri.fsPath
    }

    finishArgs(): string[] {
        return this.manifest['finish-args']
            .filter((arg) => {
                // --metadata causes a weird issue
                // --require-version is not supported by flatpak-builder, so filter it out
                return !['--metadata', '--require-version'].includes(arg.split('=')[0])
            })
            .map((arg) => {
                if (arg.endsWith('*')) {
                    const [key, value] = arg.split('=')
                    return `${key}='${value}'`
                }
                return arg
            })
    }

    runtimeTerminal(): vscode.TerminalOptions {
        const sdkId = `${this.manifest.sdk}//${this.manifest['runtime-version']}`
        const command = new Command('flatpak', [
            'run',
            '--command=bash',
            sdkId,
        ])
        return {
            name: sdkId,
            iconPath: new vscode.ThemeIcon('package'),
            shellPath: command.program,
            shellArgs: command.args
        }
    }

    buildTerminal(): vscode.TerminalOptions {
        const command = this.runInRepo('bash', true)
        return {
            name: this.id(),
            iconPath: new vscode.ThemeIcon('package'),
            shellPath: command.program,
            shellArgs: command.args,
        }
    }

    initBuild(): Command {
        return new Command(
            'flatpak',
            [
                'build-init',
                this.repoDir,
                this.id(),
                this.manifest.sdk,
                this.manifest.runtime,
                this.manifest['runtime-version'],
            ],
            { cwd: this.workspace },
        )
    }

    updateDependencies(): Command {
        const args = [
            '--ccache',
            '--force-clean',
            '--disable-updates',
            '--download-only',
        ]
        args.push(`--state-dir=${this.stateDir}`)
        args.push(`--stop-at=${this.module().name}`)
        args.push(this.repoDir)
        args.push(this.path())

        return new Command(
            'flatpak-builder',
            args,
            { cwd: this.workspace },
        )
    }

    buildDependencies(): Command {
        const args = [
            '--ccache',
            '--force-clean',
            '--disable-updates',
            '--disable-download',
            '--build-only',
            '--keep-build-dirs',
        ]
        args.push(`--state-dir=${this.stateDir}`)
        args.push(`--stop-at=${this.module().name}`)
        args.push(this.repoDir)
        args.push(this.path())

        return new Command(
            'flatpak-builder',
            args,
            { cwd: this.workspace },
        )
    }

    /**
     * Generate a new PATH like override
     * @param envVariable the env variable name
     * @param defaultValue the default value
     * @param prependOption an array of the paths to pre-append
     * @param appendOption an array of the paths to append
     * @returns the new path
     */
    getPathOverrides(envVariable: string, defaultValue: string, prependOption: BuildOptionsPathKeys, appendOption: BuildOptionsPathKeys): string {
        const module = this.module()
        const prependPaths = [
            this.manifest['build-options']?.[prependOption],
            module['build-options']?.[prependOption]
        ]
        const appendPaths = [
            this.manifest['build-options']?.[appendOption],
            module['build-options']?.[appendOption]
        ]
        const currentValue = process.env[envVariable] || defaultValue
        const path = generatePathOverride(currentValue, prependPaths, appendPaths)
        return `--env=${envVariable}=${path}`
    }

    getPaths(): string[] {
        const paths: string[] = []
        paths.push(
            this.getPathOverrides('PATH', '', 'prepend-path', 'append-path')
        )
        paths.push(
            this.getPathOverrides('LD_LIBRARY_PATH', '/app/lib', 'prepend-ld-library-path', 'append-ld-library-path')
        )
        paths.push(
            this.getPathOverrides('PKG_CONFIG_PATH', '/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig', 'prepend-pkg-config-path', 'append-pkg-config-path')
        )
        return paths
    }

    build(rebuild: boolean): Command[] {
        const module = this.module()
        const buildEnv = {
            ...this.manifest['build-options']?.env || {},
            ...module['build-options']?.env || {},
        }
        let buildArgs = [
            '--share=network',
            '--nofilesystem=host',
            `--filesystem=${this.workspace}`,
            `--filesystem=${this.repoDir}`,
        ]

        for (const [key, value] of Object.entries(buildEnv)) {
            buildArgs.push(`--env=${key}=${value}`)
        }
        buildArgs = buildArgs.concat(this.getPaths())

        const configOpts = (
            (module['config-opts'] || []).concat(
                this.manifest['build-options']?.['config-opts'] || []
            )
        )

        let commands = []
        switch (module.buildsystem) {
            default:
            case 'autotools':
                commands = this.getAutotoolsCommands(rebuild, buildArgs, configOpts)
                break
            case 'cmake':
            case 'cmake-ninja':
                commands = this.getCmakeCommands(rebuild, buildArgs, configOpts)
                break
            case 'meson':
                commands = this.getMesonCommands(rebuild, buildArgs, configOpts)
                break
            case 'simple':
                commands = this.getSimpleCommands(module['build-commands'], buildArgs)
                break
            case 'qmake':
                throw new Error('Qmake is not implemented yet')
        }
        /// Add the post-install commands if there are any
        commands.push(
            ... this.getSimpleCommands(this.module()['post-install'] || [], buildArgs)
        )
        return commands
    }

    buildSystemBuildDir(): string | null {
        const module = this.module()
        switch (module.buildsystem) {
            case 'meson':
            case 'cmake':
            case 'cmake-ninja':
                return DEFAULT_BUILD_SYSTEM_BUILD_DIR
        }
        return null
    }

    /**
    * Gets an array of commands for a autotools build
    * - If the app is being rebuilt
    *   - Configure with `configure`
    * - Build with `make`
    * - Install with `make install`
    * @param  {string}     rebuild     Whether this is a rebuild
    * @param  {string[]}   buildArgs   The build arguments
    * @param  {string}     configOpts  The configuration options
    */
    getAutotoolsCommands(
        rebuild: boolean,
        buildArgs: string[],
        configOpts: string[]
    ): Command[] {
        const numCPUs = cpus().length
        const commands: Command[] = []
        if (!rebuild) {
            commands.push(
                new Command(
                    'flatpak',
                    [
                        'build',
                        ...buildArgs,
                        this.repoDir,
                        './configure',
                        '--prefix=/app',
                        ...configOpts,
                    ],
                    { cwd: this.workspace },
                )
            )
        }
        commands.push(
            new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, 'make', '-p', '-n', '-s'],
                { cwd: this.workspace },
            )
        )

        commands.push(
            new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, 'make', 'V=0', `-j${numCPUs}`, 'install'],
                { cwd: this.workspace },
            )
        )
        return commands
    }

    /**
     * Gets an array of commands for a cmake build
     * - If the app is being rebuilt
     *   - Ensure build dir exists
     *   - Configure with `cmake -G NINJA`
     * - Build with `ninja`
     * - Install with `ninja install`
     * @param  {string}     rebuild     Whether this is a rebuild
     * @param  {string[]}   buildArgs   The build arguments
     * @param  {string}     configOpts  The configuration options
     */
    getCmakeCommands(
        rebuild: boolean,
        buildArgs: string[],
        configOpts: string[]
    ): Command[] {
        const commands: Command[] = []
        const cmakeBuildDir = DEFAULT_BUILD_SYSTEM_BUILD_DIR
        buildArgs.push(`--filesystem=${this.workspace}/${cmakeBuildDir}`)
        if (!rebuild) {
            commands.push(
                new Command(
                    'mkdir',
                    ['-p', cmakeBuildDir],
                    { cwd: this.workspace },
                )
            )
            commands.push(
                new Command(
                    'flatpak',
                    [
                        'build',
                        ...buildArgs,
                        this.repoDir,
                        'cmake',
                        '-G',
                        'Ninja',
                        '..',
                        '.',
                        '-DCMAKE_EXPORT_COMPILE_COMMANDS=1',
                        '-DCMAKE_BUILD_TYPE=RelWithDebInfo',
                        '-DCMAKE_INSTALL_PREFIX=/app',
                        ...configOpts,
                    ],
                    { cwd: path.join(this.workspace, cmakeBuildDir) },
                )
            )
        }
        commands.push(
            new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, 'ninja'],
                { cwd: path.join(this.workspace, cmakeBuildDir) },
            )
        )

        commands.push(
            new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, 'ninja', 'install'],
                { cwd: path.join(this.workspace, cmakeBuildDir) },
            )
        )
        return commands
    }

    /**
     * Gets an array of commands for a meson build
     * - If the app is being rebuilt
     *   - Configure with `meson`
     * - Build with `ninja`
     * - Install with `meson install`
     * @param  {string}     rebuild     Whether this is a rebuild
     * @param  {string[]}   buildArgs   The build arguments
     * @param  {string}     configOpts  The configuration options
     */
    getMesonCommands(
        rebuild: boolean,
        buildArgs: string[],
        configOpts: string[]
    ): Command[] {
        const commands: Command[] = []
        const mesonBuildDir = DEFAULT_BUILD_SYSTEM_BUILD_DIR
        buildArgs.push(`--filesystem=${this.workspace}/${mesonBuildDir}`)
        if (!rebuild) {
            commands.push(
                new Command(
                    'flatpak',
                    [
                        'build',
                        ...buildArgs,
                        this.repoDir,
                        'meson',
                        '--prefix',
                        '/app',
                        mesonBuildDir,
                        ...configOpts,
                    ],
                    { cwd: this.workspace },
                )
            )
        }
        commands.push(
            new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, 'ninja', '-C', mesonBuildDir],
                { cwd: this.workspace },
            )
        )
        commands.push(
            new Command(
                'flatpak',
                [
                    'build',
                    ...buildArgs,
                    this.repoDir,
                    'meson',
                    'install',
                    '-C',
                    mesonBuildDir,
                ],
                { cwd: this.workspace },
            )
        )
        return commands
    }

    getSimpleCommands(buildCommands: string[], buildArgs: string[]): Command[] {
        return buildCommands.map((command) => {
            return new Command(
                'flatpak',
                ['build', ...buildArgs, this.repoDir, command],
                { cwd: this.workspace },
            )
        })
    }

    async bundle(): Promise<Command[]> {
        const commands = []
        await fs.rm(this.finializedRepoDir, {
            recursive: true,
            force: true,
        })

        commands.push(new Command('cp', [
            '-r',
            this.repoDir,
            this.finializedRepoDir,
        ]))

        commands.push(new Command('flatpak', [
            'build-finish',
            ...this.finishArgs(),
            `--command=${this.manifest.command}`,
            this.finializedRepoDir,
        ], { cwd: this.workspace }))

        commands.push(new Command('flatpak', [
            'build-export',
            this.ostreeRepoPath,
            this.finializedRepoDir,
        ], { cwd: this.workspace }))

        commands.push(new Command('flatpak', [
            'build-bundle',
            this.ostreeRepoPath,
            `${this.id()}.flatpak`,
            this.id(),
        ], { cwd: this.workspace }))

        return commands
    }

    run(): Command {
        return this.runInRepo([this.manifest.command, ...(this.manifest['x-run-args'] || [])].join(' '), false)
    }

    runInRepo(shellCommand: string, mountExtensions: boolean, additionalEnvVars?: Map<string, string>): Command {
        const uid = getuid()

        const appId = this.id()

        let args = [
            'build',
            '--with-appdir',
            '--allow=devel',
            `--bind-mount=/run/user/${uid}/doc=/run/user/${uid}/doc/by-app/${appId}`,
            ...this.finishArgs(),
            '--talk-name=org.freedesktop.portal.*',
            '--talk-name=org.a11y.Bus',
        ]

        const envVars = getHostEnv()

        if (additionalEnvVars !== undefined) {
            for (const [key, value] of additionalEnvVars) {
                envVars.set(key, value)
            }
        }

        for (const [key, value] of envVars) {
            args.push(`--env=${key}=${value}`)
        }

        if (mountExtensions) {
            args = args.concat(this.getPaths())

            // Assume we might need network access by the executable
            args.push('--share=network')
        }

        args.push(this.repoDir)
        args.push(shellCommand)
        return new Command('flatpak', args, { cwd: this.workspace })
    }

    async deleteRepoDir(): Promise<void> {
        await fs.rm(this.repoDir, {
            recursive: true,
            force: true,
        })
    }

    async overrideWorkspaceCommandConfig(
        section: string,
        configName: string,
        program: string,
        binaryPath?: string,
        additionalEnvVars?: Map<string, string>,
    ): Promise<void> {
        const commandPath = path.join(this.buildDir, `${program}.sh`)
        await this.runInRepo(`${binaryPath || ''}${program}`, true, additionalEnvVars).saveAsScript(commandPath)
        await this.overrideWorkspaceConfig(section, configName, commandPath)
    }

    async overrideWorkspaceConfig(
        section: string,
        configName: string,
        value?: string | string[] | boolean
    ): Promise<void> {
        const config = vscode.workspace.getConfiguration(section)
        await config.update(configName, value)
    }

    async restoreWorkspaceConfig(
        section: string,
        configName: string,
    ): Promise<void> {
        await this.overrideWorkspaceConfig(section, configName, undefined)
    }
}