import * as childProcess from 'child_process';
import * as path from 'path';
import _ from 'lodash';
import fse from 'fs-extra';
import { deepMap } from '@stackbit/utils';
import * as StackbitTypes from '@stackbit/types';

export async function fetchSchemaLegacy({ studioPath }: { studioPath: string }) {
    console.log('fetch sanity schema legacy');
    const getSanitySchema = require('@sanity/core/lib/actions/graphql/getSanitySchema');
    const internalSchema = getSanitySchema(studioPath);
    const models = _.get(internalSchema, '_source.types', []);
    return {
        models: serializeValidation(models)
    };
}

export async function fetchSchemaV3({ studioPath }: { studioPath: string }) {
    console.log('fetch sanity schema v3');
    // use internal Sanity API to get the schema
    const internalApiFileName = './node_modules/sanity/lib/_internal/cli/threads/getGraphQLAPIs.js';
    const internalApiPath = path.resolve(studioPath, internalApiFileName);
    if (!(await fse.pathExists(internalApiPath))) {
        throw new Error('Could not find Sanity file: ' + internalApiPath);
    }
    const internalApiPatchedFileName = path.join(path.dirname(internalApiFileName), 'getGraphQLAPIs.patched.js');
    const internalApiPatchedPath = path.resolve(studioPath, internalApiPatchedFileName);

    // if file doesn't exist, copy it and patch it
    let data;
    if (!(await fse.pathExists(internalApiPatchedPath))) {
        console.log('patching sanity studio file at path: ' + internalApiPath);
        await fse.copyFile(internalApiPath, internalApiPatchedPath);
        data = await fse.readFile(internalApiPatchedPath, 'utf8');
        // monkey patch the Sanity internal API to expose getStudioConfig
        // and to not throw an error or run code on import
        data = data.replace(/throw new Error\(".*?"\);/g, 'void(0);');
        data = data.replace(/\ngetGraphQLAPIsForked\(.*?\);/g, '\nvoid(0);');
        if (data.includes('getStudioConfig')) {
            data += `\n\nexports.getStudioConfig = getStudioConfig;`;
        } else if (data.includes('getStudioWorkspaces.getStudioWorkspaces')) {
            data += `\n\nexports.getStudioWorkspaces = getStudioWorkspaces.getStudioWorkspaces;`;
        }
        await fse.writeFile(internalApiPatchedPath, data);
    } else {
        console.log('sanity studio file already patched at path: ' + internalApiPath);
        data = await fse.readFile(internalApiPatchedPath, 'utf8');
    }

    let methodName;
    if (data.includes('getStudioConfig')) {
        methodName = 'getStudioConfig';
    } else if (data.includes('getStudioWorkspaces.getStudioWorkspaces')) {
        methodName = 'getStudioWorkspaces';
    } else {
        throw new Error('Could not patch Sanity file: ' + internalApiPath);
    }

    // load the patched file and use `getStudioConfig` to get the schema
    const getStudioConfig = require(internalApiPatchedPath)[methodName];
    const internalConfig = await getStudioConfig({ basePath: studioPath });
    const internalSource = internalConfig?.[0]?.unstable_sources?.[0] ?? internalConfig?.[0];
    const types = internalSource?.schema?._original?.types;

    return {
        projectId: internalSource.projectId,
        dataset: internalSource.dataset,
        title: internalSource.title,
        models: serializeValidation(types)
    };
}

export async function fetchSchema({ studioPath }: { studioPath: string }) {
    console.log('sanity studio path: ' + studioPath);
    const candidates = ['sanity.config.js', 'sanity.config.jsx', 'sanity.config.ts', 'sanity.config.tsx'];
    for (const sanityConfigFileName of candidates) {
        const sanityConfigPath = path.resolve(studioPath, sanityConfigFileName);
        if (await fse.pathExists(sanityConfigPath)) {
            return fetchSchemaV3({ studioPath });
        }
    }
    return fetchSchemaLegacy({ studioPath });
}

/**
 * Serializes Sanity validation functions into JSON
 * https://www.sanity.io/docs/validation
 *
 * Supported validation functions:
 * Rule.required()
 * Rule.integer()
 * Rule.min(n)
 * Rule.max(n)
 * Rule.length(n)
 *
 * Rule => Rule.required()
 * => { isRequired: true }
 *
 * // Also supports array of rules as defined in Sanity docs:
 * Rule => [
 *   Rule.required(),
 *   Rule.integer()
 * ]
 * => { isRequired: true, isInteger: true }

 * Rule => Rule.required().min(10).max(80)
 * => { isRequired: true, min: 10, max: 80 }
 *
 * @param models
 * @return {*}
 */
function serializeValidation(models: any) {
    const Rule = require('@sanity/validation').Rule;
    return deepMap(models, (value, keyPath, objectStack) => {
        if (_.isFunction(value) && _.last(keyPath) === 'validation') {
            const field = _.last(objectStack);
            const rules = _.castArray(field.validation(new Rule()));
            const isRequired = _.some(rules, (rule) => rule.isRequired());
            const _rules = _.flatten(_.map(rules, '_rules'));
            const isInteger = _.some(_rules, (rule) => rule.flag === 'integer') || undefined;
            const min = _.get(
                _.find(_rules, (rule) => rule.flag === 'min'),
                'constraint'
            );
            const max = _.get(
                _.find(_rules, (rule) => rule.flag === 'max'),
                'constraint'
            );
            const length = _.get(
                _.find(_rules, (rule) => rule.flag === 'length'),
                'constraint'
            );
            return _.omitBy(
                {
                    isRequired,
                    isInteger,
                    min,
                    max,
                    length
                },
                _.isNil
            );
        }
        return value;
    });
}

/*
async function fetchSchema({studioPath}) {
    const sanityJsonPath = path.resolve(studioPath, 'sanity.json');
    console.log('sanity studio config path: ' + sanityJsonPath);
    const exists = await fse.pathExists(sanityJsonPath);
    if (!exists) {
        return Promise.reject('could not find sanity.json file');
    }

    const sanityJson = await parseFile(sanityJsonPath);
    const parts = _.get(sanityJson, 'parts');
    const schemasPart = _.find(parts, {name: 'part:@sanity/base/schema'});
    let schemaPath = './schemas/schema.js';
    if (schemasPart) {
        schemaPath = schemasPart.path;
    }
    const absSchemaPath = path.join(studioPath, schemaPath);
    console.log('sanity studio schema path: ' + absSchemaPath);
    require = require('esm')(module/!*, options*!/);
    const registerLoader = require('@sanity/plugin-loader');
    registerLoader({basePath: studioPath});
    const internalSchema = require(absSchemaPath);

    return {
        models: _.get(internalSchema, 'default._source.types', [])
    };
}
*/

export interface FetchSchemaOptions {
    studioPath: string;
    repoPath: string;
    nodePath?: string;
    spawnRunner?: StackbitTypes.UserCommandSpawner;
    logger?: StackbitTypes.Logger;
}

export function spawnFetchSchema({ studioPath, nodePath, repoPath, spawnRunner, logger }: FetchSchemaOptions): Promise<any> {
    logger?.debug(`[sanity-schema-fetcher] spawn fetch schema, studioPath: ${studioPath}, repoPath: ${repoPath} using spawnRunner: ${!!spawnRunner}`);
    return new Promise((resolve, reject) => {
        let done = false;
        let errOutput = '';
        const buffers = {
            stdout: '',
            stderr: ''
        };
        let schema: any = null;
        const finish = (code: number | null) => {
            if (done) {
                return;
            }
            done = true;
            flush();
            if (code === 0) {
                resolve(schema);
            } else {
                reject(errOutput);
            }
        };
        const writeLine = (type: string, line: string) => {
            if (_.isEmpty(_.trim(line))) {
                return;
            }
            if (type === 'stderr') {
                logger?.error(`[sanity-schema-fetcher] stderr: ${line}`);
            } else if (line.indexOf('SCHEMA_OUTPUT') !== -1) {
                logger?.debug(`[sanity-schema-fetcher] got schema`);
                schema = JSON.parse(line.match(/SCHEMA_OUTPUT:(.*)/)![1]!);
            } else {
                logger?.debug(`[sanity-schema-fetcher] stdout: ${line}`);
            }
        };
        const handler = (type: keyof typeof buffers, data: string) => {
            const lines = _.split(data, '\n');
            if (buffers[type]) {
                lines[0] = buffers[type] + lines[0];
                buffers[type] = '';
            }
            if (_.last(lines) !== '') {
                buffers[type] = lines.pop() || '';
            }
            if (type === 'stderr') {
                errOutput += data;
            }
            _.forEach(lines, (line) => {
                writeLine(type, line);
            });
        };
        const flush = () => {
            _.forEach(buffers, (line, key) => {
                writeLine(key, line);
            });
        };
        const proc =
            spawnRunner?.({
                command: 'node',
                args: [__filename, studioPath],
                cwd: studioPath,
                env: {
                    NODE_PATH: nodePath || `${path.join(studioPath, 'node_modules')}:${path.join(repoPath, 'node_modules')}`
                }
            }) ??
            // To debug the subprocess add --inspect=9229
            // args: ['--inspect=9229', __filename, studioPath]
            childProcess.spawn('node', [__filename, studioPath], {
                cwd: studioPath,
                env: _.assign({}, process.env, { NODE_PATH: nodePath || `${path.join(studioPath, 'node_modules')}:${path.join(repoPath, 'node_modules')}` })
            });
        proc.stdout.on('data', _.partial(handler, 'stdout'));
        proc.stderr.on('data', _.partial(handler, 'stderr'));
        proc.on('error', (error) => {
            logger?.debug(`[sanity-schema-fetcher] error: ${error}`);
        });
        proc.on('close', (code) => {
            if (code != 0) {
                logger?.debug(`[sanity-schema-fetcher] closed with code: ${code}`);
            }
            finish(code);
        });
        proc.on('exit', (code) => {
            if (code != 0) {
                logger?.debug(`[sanity-schema-fetcher] exited with code: ${code}`);
            }
            finish(code);
        });
    });
}

if (require.main === module) {
    const studioPath = process.argv[2];
    if (!studioPath) {
        console.error('[sanity-schema-fetcher] missing studio path');
        process.exit(1);
    }
    console.log(`[sanity-schema-fetcher] fetch schema, studioPath: ${studioPath}`);
    fetchSchema({ studioPath })
        .then((schema) => {
            console.log(`[sanity-schema-fetcher] done fetching`);
            process.stdout.write(`SCHEMA_OUTPUT:${JSON.stringify(schema)}`, () => {
                process.exit(0);
            });
        })
        .catch((err) => {
            console.error(`[sanity-schema-fetcher] error fetching, error: ${err.message}`, err.stack);
            process.exit(1);
        });
}
