use anyhow::{anyhow, Context, Result};
use clap::Parser;
use cli::{CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME};
use core_foundation::{
    array::{CFArray, CFIndex},
    string::kCFStringEncodingUTF8,
    url::{CFURLCreateWithBytes, CFURL},
};
use core_services::{kLSLaunchDefaults, LSLaunchURLSpec, LSOpenFromURLSpec, TCFType};
use ipc_channel::ipc::{IpcOneShotServer, IpcReceiver, IpcSender};
use serde::Deserialize;
use std::{
    ffi::OsStr,
    fs::{self, OpenOptions},
    io,
    path::{Path, PathBuf},
    ptr,
};
use util::paths::PathLikeWithPosition;

#[derive(Parser)]
#[clap(name = "zed", global_setting(clap::AppSettings::NoAutoVersion))]
struct Args {
    /// Wait for all of the given paths to be opened/closed before exiting.
    #[clap(short, long)]
    wait: bool,
    /// A sequence of space-separated paths that you want to open.
    ///
    /// Use `path:line:row` syntax to open a file at a specific location.
    /// Non-existing paths and directories will ignore `:line:row` suffix.
    #[clap(value_parser = parse_path_with_position)]
    paths_with_position: Vec<PathLikeWithPosition<PathBuf>>,
    /// Print Zed's version and the app path.
    #[clap(short, long)]
    version: bool,
    /// Custom Zed.app path
    #[clap(short, long)]
    bundle_path: Option<PathBuf>,
}

fn parse_path_with_position(
    argument_str: &str,
) -> Result<PathLikeWithPosition<PathBuf>, std::convert::Infallible> {
    PathLikeWithPosition::parse_str(argument_str, |path_str| {
        Ok(Path::new(path_str).to_path_buf())
    })
}

#[derive(Debug, Deserialize)]
struct InfoPlist {
    #[serde(rename = "CFBundleShortVersionString")]
    bundle_short_version_string: String,
}

fn main() -> Result<()> {
    let args = Args::parse();

    let bundle = Bundle::detect(args.bundle_path.as_deref()).context("Bundle detection")?;

    if args.version {
        println!("{}", bundle.zed_version_string());
        return Ok(());
    }

    for path in args
        .paths_with_position
        .iter()
        .map(|path_with_position| &path_with_position.path_like)
    {
        if !path.exists() {
            touch(path.as_path())?;
        }
    }

    let (tx, rx) = bundle.launch()?;

    tx.send(CliRequest::Open {
        paths: args
            .paths_with_position
            .into_iter()
            .map(|path_with_position| {
                let path_with_position = path_with_position.map_path_like(|path| {
                    fs::canonicalize(&path)
                        .with_context(|| format!("path {path:?} canonicalization"))
                })?;
                Ok(path_with_position.to_string(|path| path.display().to_string()))
            })
            .collect::<Result<_>>()?,
        wait: args.wait,
    })?;

    while let Ok(response) = rx.recv() {
        match response {
            CliResponse::Ping => {}
            CliResponse::Stdout { message } => println!("{message}"),
            CliResponse::Stderr { message } => eprintln!("{message}"),
            CliResponse::Exit { status } => std::process::exit(status),
        }
    }

    Ok(())
}

enum Bundle {
    App {
        app_bundle: PathBuf,
        plist: InfoPlist,
    },
    LocalPath {
        executable: PathBuf,
        plist: InfoPlist,
    },
}

impl Bundle {
    fn detect(args_bundle_path: Option<&Path>) -> anyhow::Result<Self> {
        let bundle_path = if let Some(bundle_path) = args_bundle_path {
            bundle_path
                .canonicalize()
                .with_context(|| format!("Args bundle path {bundle_path:?} canonicalization"))?
        } else {
            locate_bundle().context("bundle autodiscovery")?
        };

        match bundle_path.extension().and_then(|ext| ext.to_str()) {
            Some("app") => {
                let plist_path = bundle_path.join("Contents/Info.plist");
                let plist = plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| {
                    format!("Reading *.app bundle plist file at {plist_path:?}")
                })?;
                Ok(Self::App {
                    app_bundle: bundle_path,
                    plist,
                })
            }
            _ => {
                println!("Bundle path {bundle_path:?} has no *.app extension, attempting to locate a dev build");
                let plist_path = bundle_path
                    .parent()
                    .with_context(|| format!("Bundle path {bundle_path:?} has no parent"))?
                    .join("WebRTC.framework/Resources/Info.plist");
                let plist = plist::from_file::<_, InfoPlist>(&plist_path)
                    .with_context(|| format!("Reading dev bundle plist file at {plist_path:?}"))?;
                Ok(Self::LocalPath {
                    executable: bundle_path,
                    plist,
                })
            }
        }
    }

    fn plist(&self) -> &InfoPlist {
        match self {
            Self::App { plist, .. } => plist,
            Self::LocalPath { plist, .. } => plist,
        }
    }

    fn path(&self) -> &Path {
        match self {
            Self::App { app_bundle, .. } => app_bundle,
            Self::LocalPath { executable, .. } => executable,
        }
    }

    fn launch(&self) -> anyhow::Result<(IpcSender<CliRequest>, IpcReceiver<CliResponse>)> {
        let (server, server_name) =
            IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
        let url = format!("zed-cli://{server_name}");

        match self {
            Self::App { app_bundle, .. } => {
                let app_path = app_bundle;

                let status = unsafe {
                    let app_url = CFURL::from_path(app_path, true)
                        .with_context(|| format!("invalid app path {app_path:?}"))?;
                    let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes(
                        ptr::null(),
                        url.as_ptr(),
                        url.len() as CFIndex,
                        kCFStringEncodingUTF8,
                        ptr::null(),
                    ));
                    let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
                    LSOpenFromURLSpec(
                        &LSLaunchURLSpec {
                            appURL: app_url.as_concrete_TypeRef(),
                            itemURLs: urls_to_open.as_concrete_TypeRef(),
                            passThruParams: ptr::null(),
                            launchFlags: kLSLaunchDefaults,
                            asyncRefCon: ptr::null_mut(),
                        },
                        ptr::null_mut(),
                    )
                };

                anyhow::ensure!(
                    status == 0,
                    "cannot start app bundle {}",
                    self.zed_version_string()
                );
            }

            Self::LocalPath { executable, .. } => {
                let executable_parent = executable
                    .parent()
                    .with_context(|| format!("Executable {executable:?} path has no parent"))?;
                let subprocess_stdout_file =
                    fs::File::create(executable_parent.join("zed_dev.log"))
                        .with_context(|| format!("Log file creation in {executable_parent:?}"))?;
                let subprocess_stdin_file =
                    subprocess_stdout_file.try_clone().with_context(|| {
                        format!("Cloning descriptor for file {subprocess_stdout_file:?}")
                    })?;
                let mut command = std::process::Command::new(executable);
                let command = command
                    .env(FORCE_CLI_MODE_ENV_VAR_NAME, "")
                    .stderr(subprocess_stdout_file)
                    .stdout(subprocess_stdin_file)
                    .arg(url);

                command
                    .spawn()
                    .with_context(|| format!("Spawning {command:?}"))?;
            }
        }

        let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
        Ok((handshake.requests, handshake.responses))
    }

    fn zed_version_string(&self) -> String {
        let is_dev = matches!(self, Self::LocalPath { .. });
        format!(
            "Zed {}{} – {}",
            self.plist().bundle_short_version_string,
            if is_dev { " (dev)" } else { "" },
            self.path().display(),
        )
    }
}

fn touch(path: &Path) -> io::Result<()> {
    match OpenOptions::new().create(true).write(true).open(path) {
        Ok(_) => Ok(()),
        Err(e) => Err(e),
    }
}

fn locate_bundle() -> Result<PathBuf> {
    let cli_path = std::env::current_exe()?.canonicalize()?;
    let mut app_path = cli_path.clone();
    while app_path.extension() != Some(OsStr::new("app")) {
        if !app_path.pop() {
            return Err(anyhow!("cannot find app bundle containing {:?}", cli_path));
        }
    }
    Ok(app_path)
}
