import {createReadStream, createWriteStream} from 'node:fs';
import {readFile, writeFile, stat, readdir} from 'node:fs/promises';
import {sha256} from 'js-sha256';
import path from 'path';
import unzipper from 'unzipper';
import os from 'os';
import {spawn} from 'child_process';
import {exec} from '../utils';
import axios from 'axios';

import {sendLog} from '../websocket';
import { getOpenApp, deleteMetroProc, getCurrentMetroProc, createMetroProc} from '../database/cliconfig';
import {DataBase, FileSystem} from '../ui/src/types';

function parseIOSDevices(text: string) {
  let lines = text.split('\n')
  let devices: FileSystem.MobileDevice[] = [];
  try {
    let currentLabel: string = "";
    for (let line of lines) {
      const trimmed = line.trim();
      if (trimmed.startsWith('==')) {
        currentLabel = trimmed;
      } else if (trimmed) {
        const udid = trimmed.slice(trimmed.lastIndexOf('(') + 1, trimmed.lastIndexOf(')'))
        devices.push({
          id: udid, 
          type: currentLabel, 
          name: trimmed.replace(`(${udid})`, '')
        });
      }
    }
  } catch (err) {
    console.error("Parse error for ios devices: ", err, text);
  }
  return devices;
}

export async function listDevices(): Promise<{ios: FileSystem.MobileDevice[], android: FileSystem.MobileDevice[]}> {
  // get ios simulators and devices
  const {stdout, stderr: xctraceerr} = await exec(`xcrun xctrace list devices`);
  if (xctraceerr) {
    sendLog(xctraceerr.toString());
  }

  const result: {ios: FileSystem.MobileDevice[], android: FileSystem.MobileDevice[]} = {
    ios: [],
    android: []
  };

  try {
    result.ios = parseIOSDevices(stdout.toString());
  } catch (err) {
    console.error(err);
    sendLog("Failed to parse ios devices" + err);
  }

  // get android simulators
  const {stdout: emulatorStdout, stderr: emulatorStderr} = await exec(`emulator -list-avds`);
  if (emulatorStderr) {
    sendLog(emulatorStderr.toString());
  }

  // get android devices
  try {
    const {stdout, stderr} = await exec(`adb devices`);
    if (stderr) {
      sendLog(stderr);
    }
    const lines = stdout.toString().split('\n');
    for (let i = 1; i < lines.length; i++) {
      const line = lines[i];
      const trimmed = line.trim();
      if (trimmed) {
        const [id, type] = trimmed.split('\t');
        result.android.push({
          id,
          type,
          name: id
        });
      }
    }
  } catch (err) {
    console.error("Failed to get android devices", err);
    sendLog("Failed to get android devices");
  }

  return result;
}

async function clearWatchmanWatches() {
  try {
    sendLog("Clearing watchman watches");
    const {stdout, stderr} = await exec('watchman watch-del-all')
    sendLog(stdout.toString());
    sendLog(stderr.toString());
  } catch (err: any) {
    console.error(err);
    sendLog("Failed to clear watchman watches: " + err?.message);
  }
}

async function ensureMetro(repoPath: string) {
  const metroProcs = getCurrentMetroProc();

  let alreadyRunning = false;

  if (metroProcs && metroProcs.length > 0) {
    sendLog("Killing existing metro process");
    // kill processes 
    for (let proc of metroProcs) {
      if (proc.repoPath !== repoPath) {
        sendLog(`Killing metro process for ${repoPath}`)
        // i don't know why i have to do this
        const pid = parseInt(proc.pid.toString());
        try {
          process.kill(pid);
        } catch(err) {
          console.error("error when sending kill signal to existing metro process", err);
        }
        // delete processes from db
        deleteMetroProc(proc.repoPath);
      } else {
        try {
          process.kill(proc.pid, 0);
          alreadyRunning = true;
        } catch (err) {
          console.error(`Health check of existing metro process failed`, err);
          deleteMetroProc(proc.repoPath);
          sendLog(`Health check of existing metro process failed`);
        }
      }
    }
  }

  if (!alreadyRunning) {
    await clearWatchmanWatches();
    await new Promise((resolve) => setTimeout(resolve, 5000));
    // create metro process and add to db
    await runMetro(repoPath);
  }
}

export async function runIOS(appId: string, deviceId: string) {
  sendLog("Attempting to start ios debugging");
  const openedApp = getOpenApp(appId);
  if (openedApp) {
    sendLog("Found an open app: " + openedApp.repoPath);
    await ensureMetro(openedApp.repoPath);
    const extraArgs = [];
    if (deviceId !== 'none') {
      extraArgs.push('--udid');
      extraArgs.push(deviceId);
    }
    sendLog("Starting react-native process");
    const installProc = spawn(`node`, ['./node_modules/.bin/react-native', 'run-ios', ...extraArgs], 
                              {cwd: openedApp.repoPath});
    installProc.stdout?.on('data', data => {
      sendLog(data.toString());
    });
    installProc.stderr?.on('data', data => {
      sendLog(data.toString());
    });

    installProc.on('close', code => {
      sendLog(`Runner exited with status ${code}`);
    });
  } else {
    sendLog("No opened app found for: " + appId);
    throw new Error("Couldn't launch an app that is not opened");
  }
}

export async function runAndroid(appId: string, deviceId: string) {
  sendLog("Attempting to start android debugging");
  const openedApp = getOpenApp(appId);
  if (openedApp) {
    sendLog("Found an open app: " + openedApp.repoPath);
    await ensureMetro(openedApp.repoPath);
    const extraArgs = [];
    if (deviceId !== 'none') {
      extraArgs.push('--deviceId');
      extraArgs.push(deviceId);
    }
    sendLog("Starting react-native process");
    const installProc = spawn(`node`, ['./node_modules/.bin/react-native', 'run-android', ...extraArgs], 
                              {cwd: openedApp.repoPath});
    installProc.stdout?.on('data', data => {
      sendLog(data.toString());
    });
    installProc.stderr?.on('data', data => {
      sendLog(data.toString());
    });

    installProc.on('close', code => {
      sendLog(`Runner exited with status ${code}`);
    });
  } else {
    sendLog("No opened app found for: " + appId);
    throw new Error("Couldn't launch an app that is not opened");
  }
}

export async function buildAndroid(appId: string, deviceId: string) {
  const openedApp = getOpenApp(appId);
  if (openedApp) {
    sendLog("Found an open app");
    await new Promise((resolve, reject) => {
      const buildProc = spawn(`node`, 
                                ['./node_modules/.bin/react-native', 
                                  'build-android', 
                                  '--tasks',
                                  'assembleRelease'
                                ], 
                                {cwd: openedApp.repoPath});
      buildProc.stdout?.on('data', data => {
        sendLog(data.toString());
      });
      buildProc.stderr?.on('data', data => {
        sendLog(data.toString());
      });

      buildProc.on('close', code => {
        sendLog(`Builder exited with status ${code}`);
        if (code === 0) {
          resolve(code);
        } else {
          reject(code);
        }
      });
    });

    const buildPath = path.resolve(openedApp.repoPath, 'android/app/build/outputs/apk/release/app-release.apk');
    if (deviceId !== 'none') {
      sendLog(`Attempting to install from ${buildPath} to ${deviceId}`);
      const {stdout, stderr} = await exec(`adb -s ${deviceId} install ${buildPath}`)
      sendLog(stdout.toString());
      sendLog(stderr.toString());
    } else {
      sendLog(`Apk should be at: ${buildPath}`)
    }
  } else {
    console.error("No opened app found for: " + appId);
    throw new Error("Couldn't build an app that is not opened");
  }
}

export async function runMetro(repoPath: string) {
  sendLog("Creating new metro process");
  const startP = new Promise((resolve, reject) => {
    const metroProcess = spawn(`node`, ['./node_modules/.bin/react-native', 'start'], 
                               {cwd: repoPath});
    const requiredLine = "Dev server ready";
    let monitor = '';
    let resolved = false;
    metroProcess.stdout?.on('data', data => {
      const newLog = data.toString();
      sendLog(newLog);
      if (!resolved) {
        monitor += newLog;
        if (newLog.includes(requiredLine)) {
          monitor = '';
          resolve(0);
        }
      }
    });

    metroProcess.stderr?.on('data', data => {
      sendLog(data.toString());
    });

    metroProcess.on('close', code => {
      if (!resolved) {
        reject(code);
      }
      sendLog(`Metro has stopped with code ${code}`);
    });

    if (metroProcess.pid) {
      sendLog("persisting metro pid: " + metroProcess.pid);
      createMetroProc(metroProcess.pid, repoPath);
    } else {
      sendLog("There is no pid received after spawning metro");
    }
  })
  return startP;
}

export async function killMetro() {
  const metroProcs = getCurrentMetroProc();

  sendLog("Killing existing metro process");
  if (metroProcs) {
    sendLog(`Found ${metroProcs.length} metro processes`)
    // kill processes 
    for (let proc of metroProcs) {
      // i don't know why i have to do this
      const pid = parseInt(proc.pid.toString());
      sendLog(`Killing process: ${pid}`)
      try {
        process.kill(pid);
      } catch (err: any) {
        console.error("Error thrown when killing process: ", err);
        sendLog(`Failed when trying to kill ${pid} ${err?.toString()}`);
      }
      // delete processes from db
      sendLog(`Deleting all persisted metro for ${proc.repoPath}`);
      deleteMetroProc(proc.repoPath);
    }
    

    // wait for all kill signals to finish
    await new Promise((resolve) => setTimeout(resolve, 3000));
    sendLog(`clear watchman caches`);
    await clearWatchmanWatches();
    sendLog("Killing metro finished");
  }
}

export function npmInstall(dirPath: string) {
  return new Promise((resolve, reject) => {
    const process = spawn(`npm`, ['i', '--verbose'], {cwd: dirPath})
    process.stdout.on('data', data => {
      sendLog(data.toString());
    });
    process.stderr.on('data', data => {
      sendLog(data.toString());
    });

    process.on('close', code => {
      if (code === 0) {
        sendLog('node_modules installed successfully!');
        resolve(code);
      } else {
        sendLog('node_modules installtion exited unsuccessfully');
        reject(code);
      }
    });
  });
}

export function podInstall(dirpath: string) {
  return new Promise((resolve, reject) => {
    const process = spawn(`pod`, ['install'], {cwd: path.resolve(dirpath, 'ios')});
    process.stderr.on('data', data => {
      sendLog(data.toString());
    });

    process.stdout.on('data', data => {
      sendLog(data.toString());
    });

    process.on('close', code => {
      if (code === 0) {
        sendLog('pods installed successfully!');
        resolve(code);
      } else {
        sendLog('pod install failed');
        reject(code);
      }
    });
  });
}

export async function installReleaseBuild(openedApp: DataBase.OpenedApp, deviceId: string) {
  sendLog(`Starting release build installation for ${deviceId}`);
  return new Promise((resolve, reject) => {
    const process = spawn(`node`, ['./node_modules/.bin/react-native', 'run-ios', '--mode', 'Release', '--verbose', '--udid', deviceId], 
                              {cwd: openedApp.repoPath, detached: true});
    process.stderr.on('data', data => {
      sendLog(data.toString());
    });

    process.stdout.on('data', data => {
      sendLog(data.toString());
    });

    process.on('close', code => {
      if (code === 0) {
        sendLog(`build finished for target ${deviceId}`);
        resolve(code);
      } else {
        sendLog('build failed with error!');
        reject(code)
      }
    });
  });
}

async function archiveIOS(openedApp: DataBase.OpenedApp, configuration: 'Debug'|'Release') {
  return new Promise((resolve, reject) => {
    const process = spawn('xcodebuild', 
                          [ 'archive', 
                            '-sdk', 'iphoneos', 
                            '-workspace', path.resolve(openedApp.repoPath, 'ios/apptileSeed.xcworkspace'),
                            '-scheme', 'apptileSeed',
                            '-configuration', configuration,
                            '-archivePath', path.resolve(openedApp.repoPath, 'ios/apptileSeed.xcarchive'),
                            '-allowProvisioningUpdates'
                          ], 
                          {cwd: openedApp.repoPath}
                         );
    process.stderr.on('data', data => {
      sendLog(data.toString());
    });

    process.stdout.on('data', data => {
      sendLog(data.toString());
    });

    process.on('close', code => {
      if (code === 0) {
        sendLog('build finished!');
        resolve(code);
      } else {
        sendLog('build failed with error!');
        reject(code)
      }
    });
  });
}

async function exportIPA(openedApp: DataBase.OpenedApp) {
  return new Promise((resolve, reject) => {
    const process = spawn('xcodebuild', 
                          [ '-exportArchive', 
                            '-archivePath', path.resolve(openedApp.repoPath, 'ios/apptileSeed.xcarchive'), 
                            '-exportPath', path.resolve(openedApp.repoPath, 'iosbuild'),
                            '-exportOptionsPlist', path.resolve(openedApp.repoPath, 'ExportOptions.plist'),
                            '-allowProvisioningUpdates',
                            '-verbose'
                          ], 
                          {cwd: openedApp.repoPath}
                         );
    process.stderr.on('data', data => {
      sendLog(data.toString());
    });

    process.stdout.on('data', data => {
      sendLog(data.toString());
    });

    process.on('close', code => {
      if (code === 0) {
        sendLog('export successful!');
        resolve(code);
      } else {
        sendLog('export failed with error!');
        reject(code)
      }
    });
  });
}

async function updateAppConfig(openedApp: DataBase.OpenedApp) {
  sendLog("Downloading latest appconfig");
  const rawConfig = await readFile(path.resolve(openedApp.repoPath, 'apptile.config.json'), {encoding: 'utf8'});
  const config = JSON.parse(rawConfig);
  const {data: manifest} = await axios.get(`${config.APPTILE_BACKEND_URL}/api/v2/app/${openedApp.appId}/manifest`);
  const publishedCommit = manifest.forks[0].publishedCommitId;
  const iosBundle = manifest.codeArtefacts.find((it: any) => it.type === 'ios-jsbundle');
  const androidBundle = manifest.codeArtefacts.find((it: any) => it.type === 'android-jsbundle');

  const appConfigUrl = `${config.APPCONFIG_SERVER_URL}/${openedApp.appId}/main/main/${publishedCommit}.json`;
  sendLog(`Published appconfig is at ${appConfigUrl}`);
  if (publishedCommit) {
    try {
      const appConfigPath = path.resolve(openedApp.repoPath, 'ios/appConfig.json');
      sendLog(`Writing published appconfig at ${appConfigPath}`);
      const writer = createWriteStream(appConfigPath);
      const response = await axios({
        method: 'get',
        url: appConfigUrl,
        responseType: 'stream'
      });
      response.data.pipe(writer);
      await new Promise((resolve, reject) => {
        writer.on('finish', resolve);
        writer.on('error', reject);
      });
      const bundleTrackerPath = path.resolve(openedApp.repoPath, 'ios/localBundleTracker.json');
      sendLog(`Writing bundleTracker at: ${bundleTrackerPath}`);
      await writeFile(bundleTrackerPath, `{"publishedCommitId": ${publishedCommit}, "iosBundleId": ${iosBundle?.id ?? "null"}, "androidBundleId": ${androidBundle?.id ?? "null"}}`)
    } catch (err: any) {
      sendLog(`Error during appconfig download: ${err.message}
${err.stack}`);
      throw(err);
    }
  } else {
    sendLog(`Publish app once before building!`);
    throw new Error("Published appconfig not found! Stopping build.")
  }
}

export type OSName = "ios"|"android";
export type BuildMode = "debug"|"release"|"distributable";

export function narrowOSName(val: string) : val is OSName {
  return (val === "ios") || (val === "android");
}

export function narrowBuildMode(val: string) : val is BuildMode {
  return (val === "debug") || (val === "release") || (val === "distributable");
}

// TODO(gaurav) update the appconfig and bundletracker. The thing is not written for android yet
export async function buildApp(appId: string, os: OSName, mode: BuildMode, deviceId?: string) {
  const openedApp = getOpenApp(appId);
  try {
    if ((os === 'ios') && (['debug', 'release'].includes(mode)) && openedApp) {
      // sendLog("updating appconfig");
      // await updateAppConfig(openedApp);
      sendLog("Starting ios build");
      if (deviceId) {
        await installReleaseBuild(openedApp, deviceId);
      } else {
        sendLog('DeviceId must be given for release build installtion');
        throw new Error("No deviceId found");
      }
      // await archiveIOS(openedApp, mode === 'debug' ? 'Debug' : 'Release')
      // sendLog("Build finished! Exporting");
      // await exportIPA(openedApp);
      // sendLog("IPA exported!");
    } else if ((os === 'android') && (['release'].includes(mode)) && openedApp) {
      // sendLog("Updating appconfig");
      // await updateAppConfig(openedApp);
      sendLog("Starting android build");
      buildAndroid(appId, deviceId || 'none')
    } else {
      sendLog(`Cannot build variant ${os}, ${mode}`);
    }
  } catch (err: any) {
    console.error("App build failed");
    sendLog(`Build Failed!! ${err?.message} ${err?.stack}`);
  }
}

export async function verifyExisting(at: string, appid: string, sdkpath: string) {
  sendLog(`Checking if the existing project in ${at} is linkable`)
  // Check that remoteCode folder and subfolders exists 
  // Check that variables file has the correct appid and sdkpath
  sendLog(`Existing project verified!`);
}

export async function getGitShas(appId: string): Promise<{err: any; gitsha?: string; sdksha?: string;}> {
  const openedApp = getOpenApp(appId);
  if (openedApp) {
    try {
      const {stdout: sdkStdOut, stderr: stderr1} = await exec('git log -n 1 --format=format:%H', 
             {cwd: path.resolve(openedApp.repoPath, '../ReactNativeTSProjeect')});
      if (stderr1) {
        throw new Error(stderr1);
      }
      const sdksha = sdkStdOut.toString().trim();

      const {stdout: appStdOut, stderr} = await exec('git log -n 1 --format=format:%H', 
             {cwd: path.resolve(openedApp.repoPath, 'remoteCode')});
      if (stderr) {
        throw new Error(stderr);
      }
      const gitsha = appStdOut.toString().trim();
      return {err: false, gitsha, sdksha};
    } catch (err) {
      return {err}
    }
  } else {
    return { err: "Link not found!" };
  }
}

export async function currentPluginsBundle(appid: string): Promise<{err: any; res?: any}> {
  const openedApp = getOpenApp(appid);
  if (!openedApp) {
    return {err: `Link not found for ${appid}`}
  } else {
    const entries = await readdir(
      path.resolve(openedApp.repoPath, 'remoteCode/plugins'), 
      {withFileTypes: true}
    )
    const plugins = entries.filter(it => it.isDirectory()).map(it => it.name);
    const hash = sha256(plugins.join('_')).toString();
    const generatedPluginBundle = path.resolve(openedApp.repoPath, 'remoteCode/generated', hash, 'dist/bundle.js')
    try {
      await stat(generatedPluginBundle);
    } catch (err: any) {
      if (err?.code === 'ENOENT') {
        return {err: false, res: ''};
      } else {
        return {err}
      }
    }
    return {err: false, res: generatedPluginBundle};
  }
}

export async function currentNavigatorsBundle(appid: string): Promise<{err: any; res?: any}> {
  const openedApp = getOpenApp(appid);
  if (!openedApp) {
    return {err: `Link not found for ${appid}`}
  } else {
    const entries = await readdir(
      path.resolve(openedApp.repoPath, 'remoteCode/navigators'), 
      {withFileTypes: true}
    )
    const navigators = entries.filter(it => it.isDirectory()).map(it => it.name);
    let hash = sha256('navigators_' + navigators.join('_')).toString();
    const generatedNavsBundle = path.resolve(openedApp.repoPath, 'remoteCode/generated', hash, 'dist/bundle.js')
    try {
      await stat(generatedNavsBundle);
    } catch (err: any) {
      if (err?.code === 'ENOENT') {
        return {err: false, res: ''};
      } else {
        return {err};
      }
    }
    return {err: false, res: generatedNavsBundle};
  }
}

export async function getMobileBundle(appId: string, bundleName: string, os: 'android'|'ios'): Promise<{err: any; jsBundle?: string; assetZip?: string;}> {
  const openedApp = getOpenApp(appId);
  if (openedApp) {
    try {
      const assetZip = path.resolve(openedApp.repoPath, `remoteCode/generated/bundles/${os}`, bundleName, 'bundle.zip');
      await stat(assetZip);
      return {err: false, assetZip};
    } catch (err) {
      return {err};
    }
  } else {
    return {err: new Error("link not found")};
  }
}

export async function extractSeed(to: string, appid: string, sdkpath: string) {
  console.log("Starting extraction");
  sendLog(`Starting project creation at ${to}`);
  const seedZip = path.resolve(__dirname, '../templates/apptileSeed.zip');
  return new Promise((resolve, reject) => {
    createReadStream(seedZip).pipe(
      unzipper.Extract({path: path.resolve(to)})
    )
    .on('close', async () => {
      sendLog("Seed unzipped to location. Setting up variables.")
      try {
        const nets = os.networkInterfaces();
        const localIpInterface = Object.values(nets).flatMap(it => it).filter((it: any) => (it.family === 'IPv4') && (it.address !== '127.0.0.1') && (it.netmask !== '255.255.0.0'))
        if (localIpInterface.length === 0) {
          throw new Error("Could not determine local ip address");
        }
        const ip: string = (localIpInterface[0] as any).address;
        const variablesFilePath = path.resolve(to, 'variables.js');
        let variables = await readFile(variablesFilePath, {encoding: 'utf8'});
        // change this to a patterned replace so that existing project can be linked
        variables = variables.replace(/__APP_ID__/g, appid);
        variables = variables.replace(/__PLUGIN_SERVER_URL__/g, `http://${ip}:3100/plugin-server`);
        variables = variables.replace(/__PLUGIN_SOCKET_URL__/g, `ws://${ip}:3100/plugin-server/healthcheck`);
        variables = variables.replace(/__SDK_PATH__/g, sdkpath);
        await writeFile(variablesFilePath, variables);

        const tsconfigPath = path.resolve(to, 'tsconfig.json');
        let tsconfig = await readFile(tsconfigPath, {encoding: 'utf8'});
        tsconfig = tsconfig.replace(/__SDK_PATH__/g, sdkpath);
        await writeFile(tsconfigPath, tsconfig);
        
        const appDelegatePath = path.resolve(to, 'ios/apptileSeed/AppDelegate.mm');
        let appDelegate = await readFile(
          appDelegatePath,
          {encoding: 'utf8'}
        );
        appDelegate = appDelegate.replace('__METRO_IP__', `http://${ip}:8081`);
        await writeFile(appDelegatePath, appDelegate)
        sendLog("Start installation");
        // Non blocking installs
        npmInstall(to)
          .then(() => podInstall(to))
          .catch(err => {
            console.error("error during pod install", err);
            sendLog(`Pod install failed: ${err.message} ${err.stack}`)
          })
        resolve({});
      } catch (err) {
        sendLog(`Failed to replace variables ${err}`);
        reject(err);
      }
    })
    .on('error', (err: any) => {
      sendLog(`Failed when trying to unzip seed ${err.message}`);
      console.error(err);
      reject(err);
    });
  });
}
