import * as fs from 'fs';
import * as path from 'path';
import * as _ from 'lodash';
import * as ts from 'typescript';
import { toUnix } from './helpers';
import { Checker } from './checker';
import { CompilerInfo, LoaderConfig, TsConfig } from './interfaces';
import { WatchModeSymbol } from './watch-mode';

let colors = require('colors/safe');
let pkg = require('../package.json');
let mkdirp = require('mkdirp');

export interface Instance {
    id: number;
    babelImpl?: any;
    compiledFiles: { [key: string]: boolean };
    configFilePath: string;
    compilerConfig: TsConfig;
    loaderConfig: LoaderConfig;
    checker: Checker;
    cacheIdentifier: any;
    context: string;
}

export interface Compiler {
    inputFileSystem: typeof fs;
    _tsInstances: { [key: string]: Instance };
    options: {
        watch: boolean
    };
}

export interface Loader {
    _compiler: Compiler;
    cacheable: () => void;
    query: string;
    async: () => (err: Error, source?: string, map?: string) => void;
    resourcePath: string;
    resolve: () => void;
    addDependency: (dep: string) => void;
    clearDependencies: () => void;
    emitFile: (fileName: string, text: string) => void;
    emitWarning: (msg: string) => void;
    emitError: (msg: string) => void;
    options: {
        ts?: LoaderConfig
    };
}

export type QueryOptions = LoaderConfig & ts.CompilerOptions;

export function getRootCompiler(compiler) {
    if (compiler.parentCompilation) {
        return getRootCompiler(compiler.parentCompilation.compiler);
    } else {
        return compiler;
    }
}

function resolveInstance(compiler, instanceName) {
     if (!compiler._tsInstances) {
        compiler._tsInstances = {};
    }
    return compiler._tsInstances[instanceName];
}

const COMPILER_ERROR = colors.red(`\n\nTypescript compiler cannot be found, please add it to your package.json file:
    npm install --save-dev typescript
`);

const BABEL_ERROR = colors.red(`\n\nBabel compiler cannot be found, please add it to your package.json file:
    npm install --save-dev babel-core
`);

let id = 0;
export function ensureInstance(
    webpack: Loader,
    query: QueryOptions,
    options: LoaderConfig,
    instanceName: string,
    rootCompiler: any
): Instance {
    let exInstance = resolveInstance(rootCompiler, instanceName);
    if (exInstance) {
        return exInstance;
    }

    const watching = isWatching(rootCompiler);
    const context = process.cwd();

    let compilerInfo = setupTs(query.compiler);
    let { tsImpl } = compilerInfo;

    let { configFilePath, compilerConfig, loaderConfig } = readConfigFile(
        context,
        query,
        options,
        tsImpl
    );

    applyDefaults(
        configFilePath,
        compilerConfig,
        loaderConfig,
        context
    );

    if (!loaderConfig.silent) {
        const sync = watching === WatchMode.Enabled ? ' (in a forked process)' : '';
        console.log(`\n[${instanceName}] Using typescript@${compilerInfo.compilerVersion} from ${compilerInfo.compilerPath} and `
            + `"tsconfig.json" from ${configFilePath}${sync}.\n`);
    }

    let babelImpl = setupBabel(loaderConfig, context);
    let cacheIdentifier = setupCache(
        loaderConfig,
        tsImpl,
        webpack,
        babelImpl,
        context
    );
    let compiler = (<any>webpack._compiler);

    setupWatchRun(compiler, instanceName);
    setupAfterCompile(compiler, instanceName);

    const webpackOptions = _.pick(webpack._compiler.options, 'resolve');
    const checker = new Checker(
        compilerInfo,
        loaderConfig,
        compilerConfig,
        webpackOptions,
        context,
        watching === WatchMode.Enabled
    );

    return rootCompiler._tsInstances[instanceName] = {
        id: ++id,
        babelImpl,
        compiledFiles: {},
        loaderConfig,
        configFilePath,
        compilerConfig,
        checker,
        cacheIdentifier,
        context
    };
}

function findTsImplPackage(inputPath: string) {
    let pkgDir = path.dirname(inputPath);
    if (fs.readdirSync(pkgDir).find((value) => value === 'package.json')) {
        return path.join(pkgDir, 'package.json');
    } else {
        return findTsImplPackage(pkgDir);
    }
}

export function setupTs(compiler: string): CompilerInfo {
    let compilerPath = compiler || 'typescript';

    let tsImpl: typeof ts;
    let tsImplPath: string;
    try {
        tsImplPath = require.resolve(compilerPath);
        tsImpl = require(tsImplPath);
    } catch (e) {
        console.error(e);
        console.error(COMPILER_ERROR);
        process.exit(1);
    }

    const pkgPath = findTsImplPackage(tsImplPath);
    const compilerVersion = require(pkgPath).version;

    let compilerInfo: CompilerInfo = {
        compilerPath,
        compilerVersion,
        tsImpl,
    };

    return compilerInfo;
}

function setupCache(
    loaderConfig: LoaderConfig,
    tsImpl: typeof ts,
    webpack: Loader,
    babelImpl: any,
    context: string
) {
    let cacheIdentifier = null;
    if (loaderConfig.useCache) {
        if (!loaderConfig.cacheDirectory) {
            loaderConfig.cacheDirectory = path.join(context, '.awcache');
        }

        if (!fs.existsSync(loaderConfig.cacheDirectory)) {
            mkdirp.sync(loaderConfig.cacheDirectory);
        }

        cacheIdentifier = {
            'typescript': tsImpl.version,
            'awesome-typescript-loader': pkg.version,
            'awesome-typescript-loader-query': webpack.query,
            'babel-core': babelImpl
                ? babelImpl.version
                : null
        };
    }
}

function setupBabel(loaderConfig: LoaderConfig, context: string): any {
    let babelImpl: any;
    if (loaderConfig.useBabel) {
        try {
            let babelPath = loaderConfig.babelCore || path.join(context, 'node_modules', 'babel-core');
            babelImpl = require(babelPath);
        } catch (e) {
            console.error(BABEL_ERROR);
            process.exit(1);
        }
    }

    return babelImpl;
}

function applyDefaults(
    configFilePath: string,
    compilerConfig: TsConfig,
    loaderConfig: LoaderConfig,
    context: string
) {
    _.defaults(compilerConfig.options, {
        sourceMap: true,
        verbose: false,
        skipDefaultLibCheck: true,
        suppressOutputPathCheck: true
    });

    if (loaderConfig.transpileOnly) {
        compilerConfig.options.isolatedModules = true;
    }

    _.defaults(compilerConfig.options, {
        sourceRoot: compilerConfig.options.sourceMap ? context : undefined
    });

    _.defaults(loaderConfig, {
        sourceMap: true,
        verbose: false,
    });

    delete compilerConfig.options.outDir;
    delete compilerConfig.options.outFile;
    delete compilerConfig.options.out;
    delete compilerConfig.options.noEmit;
}

export interface Configs {
    configFilePath: string;
    compilerConfig: TsConfig;
    loaderConfig: LoaderConfig;
}

function absolutize(fileName: string, context: string) {
    if (path.isAbsolute(fileName)) {
        return fileName;
    } else {
        return path.join(context, fileName);
    }
}

export function readConfigFile(
    context: string,
    query: QueryOptions,
    options: LoaderConfig,
    tsImpl: typeof ts
): Configs {
    let configFilePath: string;
    if (query.configFileName  && query.configFileName.match(/\.json$/)) {
        configFilePath = absolutize(query.configFileName, context);
    } else {
        configFilePath = tsImpl.findConfigFile(context, tsImpl.sys.fileExists);
    }

    let existingOptions = tsImpl.convertCompilerOptionsFromJson(query, context, 'atl.query');

    if (!configFilePath || query.configFileContent) {
        return {
            configFilePath: configFilePath || path.join(context, 'tsconfig.json'),
            compilerConfig: tsImpl.parseJsonConfigFileContent(
                query.configFileContent || {},
                tsImpl.sys,
                context,
                _.extend({}, tsImpl.getDefaultCompilerOptions(), existingOptions.options) as ts.CompilerOptions,
                context
            ),
            loaderConfig: query as LoaderConfig
        };
    }

    let jsonConfigFile = tsImpl.readConfigFile(configFilePath, tsImpl.sys.readFile);
    let compilerConfig = tsImpl.parseJsonConfigFileContent(
        jsonConfigFile.config,
        tsImpl.sys,
        path.dirname(configFilePath),
        existingOptions.options,
        configFilePath
    );

    return {
        configFilePath,
        compilerConfig,
        loaderConfig: _.defaults(
            query,
            jsonConfigFile.config.awesomeTypescriptLoaderOptions,
            options
        )
    };
}

let EXTENSIONS = /\.tsx?$|\.jsx?$/;

function setupWatchRun(compiler, instanceName: string) {
    compiler.plugin('watch-run', function (watching, callback) {
        const instance = resolveInstance(watching.compiler, instanceName);
        const checker = instance.checker;
        const watcher = watching.compiler.watchFileSystem.watcher
            || watching.compiler.watchFileSystem.wfs.watcher;

        const mtimes = watcher.mtimes;
        const changedFiles = Object.keys(mtimes).map(toUnix);
        const updates = changedFiles
            .filter(file => EXTENSIONS.test(file))
            .map(changedFile => {
                if (fs.existsSync(changedFile)) {
                    checker.updateFile(changedFile, fs.readFileSync(changedFile).toString());
                } else {
                    checker.removeFile(changedFile);
                }
            });

        Promise.all(updates)
            .then(() => callback())
            .catch(callback);
    });
}

enum WatchMode {
    Enabled,
    Disabled,
    Unknown
}

function isWatching(compiler: any): WatchMode {
    const value = compiler && compiler[WatchModeSymbol];
    if (value === true) {
        return WatchMode.Enabled;
    } else if (value === false) {
        return WatchMode.Disabled;
    } else {
        return WatchMode.Unknown;
    }
}

function setupAfterCompile(compiler, instanceName, forkChecker = false) {
    compiler.plugin('after-compile', function (compilation, callback) {
        // Don't add errors for child compilations
        if (compilation.compiler.isChild()) {
            callback();
            return;
        }

        const watchMode = isWatching(compilation.compiler);
        const instance: Instance = resolveInstance(compilation.compiler, instanceName);
        const silent = instance.loaderConfig.silent;
        const asyncErrors = watchMode === WatchMode.Enabled && !silent;

        let emitError = (msg) => {
            if (compilation.bail) {
                console.error('Error in bail mode:', msg);
                process.exit(1);
            }

            if (asyncErrors) {
                console.log(msg, '\n');
            } else {
                compilation.errors.push(new Error(msg));
            }
        };

        instance.compiledFiles = {};
        const files = instance.checker.getFiles()
            .then(({files}) => {
                Array.prototype.push.apply(compilation.fileDependencies, files.map(path.normalize));
            });

        const diag = instance.loaderConfig.transpileOnly
            ? Promise.resolve()
            : instance.checker.getDiagnostics()
                .then(diags => {
                    diags.forEach(diag => emitError(diag.pretty));
                });

        files
            .then(() => {
                if (asyncErrors) {
                    // Don't wait for diags in watch mode
                    return;
                } else {
                    return diag;
                }
            })
            .then(() => callback())
            .catch(callback);
    });
}
