// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

//! ACL items that are only useful inside of build script/codegen context.

use std::{
  collections::{BTreeMap, HashMap},
  env, fs,
  path::{Path, PathBuf},
};

use crate::{
  acl::{has_app_manifest, AllowedCommands, Error},
  config::Config,
  write_if_changed,
};

use super::{
  capability::{Capability, CapabilityFile},
  manifest::PermissionFile,
  ALLOWED_COMMANDS_FILE_NAME, PERMISSION_SCHEMAS_FOLDER_NAME, PERMISSION_SCHEMA_FILE_NAME,
  REMOVE_UNUSED_COMMANDS_ENV_VAR,
};

/// Known name of the folder containing autogenerated permissions.
pub const AUTOGENERATED_FOLDER_NAME: &str = "autogenerated";

/// Cargo cfg key for permissions file paths
pub const PERMISSION_FILES_PATH_KEY: &str = "PERMISSION_FILES_PATH";

/// Cargo cfg key for global scope schemas
pub const GLOBAL_SCOPE_SCHEMA_PATH_KEY: &str = "GLOBAL_SCOPE_SCHEMA_PATH";

/// Allowed permission file extensions
pub const PERMISSION_FILE_EXTENSIONS: &[&str] = &["json", "toml"];

/// Known filename of the permission documentation file
pub const PERMISSION_DOCS_FILE_NAME: &str = "reference.md";

/// Allowed capability file extensions
const CAPABILITY_FILE_EXTENSIONS: &[&str] = &[
  "json",
  #[cfg(feature = "config-json5")]
  "json5",
  "toml",
];

/// Known folder name of the capability schemas
const CAPABILITIES_SCHEMA_FOLDER_NAME: &str = "schemas";

const CORE_PLUGIN_PERMISSIONS_TOKEN: &str = "__CORE_PLUGIN__";

fn parse_permissions(paths: Vec<PathBuf>) -> Result<Vec<PermissionFile>, Error> {
  let mut permissions = Vec::new();
  for path in paths {
    let ext = path.extension().unwrap().to_string_lossy().to_string();
    let permission_file = fs::read_to_string(&path).map_err(|e| Error::ReadFile(e, path))?;
    let permission: PermissionFile = match ext.as_str() {
      "toml" => toml::from_str(&permission_file)?,
      "json" => serde_json::from_str(&permission_file)?,
      _ => return Err(Error::UnknownPermissionFormat(ext)),
    };
    permissions.push(permission);
  }
  Ok(permissions)
}

/// Write the permissions to a temporary directory and pass it to the immediate consuming crate.
pub fn define_permissions<F: Fn(&Path) -> bool>(
  pattern: &str,
  pkg_name: &str,
  out_dir: &Path,
  filter_fn: F,
) -> Result<Vec<PermissionFile>, Error> {
  let permission_files = glob::glob(pattern)?
    .flatten()
    .flat_map(|p| p.canonicalize())
    // filter extension
    .filter(|p| {
      p.extension()
        .and_then(|e| e.to_str())
        .map(|e| PERMISSION_FILE_EXTENSIONS.contains(&e))
        .unwrap_or_default()
    })
    .filter(|p| filter_fn(p))
    // filter schemas
    .filter(|p| p.parent().unwrap().file_name().unwrap() != PERMISSION_SCHEMAS_FOLDER_NAME)
    .collect::<Vec<PathBuf>>();

  let pkg_name_valid_path = pkg_name.replace(':', "-");
  let permission_files_path = out_dir.join(format!("{pkg_name_valid_path}-permission-files"));
  let permission_files_json = serde_json::to_string(&permission_files)?;

  write_if_changed(&permission_files_path, permission_files_json)
    .map_err(|e| Error::WriteFile(e, permission_files_path.clone()))?;

  if let Some(plugin_name) = pkg_name.strip_prefix("tauri:") {
    println!(
      "cargo:{plugin_name}{CORE_PLUGIN_PERMISSIONS_TOKEN}_{PERMISSION_FILES_PATH_KEY}={}",
      permission_files_path.display()
    );
  } else {
    println!(
      "cargo:{PERMISSION_FILES_PATH_KEY}={}",
      permission_files_path.display()
    );
  }

  parse_permissions(permission_files)
}

/// Read all permissions listed from the defined cargo cfg key value.
pub fn read_permissions() -> Result<HashMap<String, Vec<PermissionFile>>, Error> {
  let mut permissions_map = HashMap::new();

  for (key, value) in env::vars_os() {
    let key = key.to_string_lossy();

    if let Some(plugin_crate_name_var) = key
      .strip_prefix("DEP_")
      .and_then(|v| v.strip_suffix(&format!("_{PERMISSION_FILES_PATH_KEY}")))
      .map(|v| {
        v.strip_suffix(CORE_PLUGIN_PERMISSIONS_TOKEN)
          .and_then(|v| v.strip_prefix("TAURI_"))
          .unwrap_or(v)
      })
    {
      let permissions_path = PathBuf::from(value);
      let permissions_str =
        fs::read_to_string(&permissions_path).map_err(|e| Error::ReadFile(e, permissions_path))?;
      let permissions: Vec<PathBuf> = serde_json::from_str(&permissions_str)?;
      let permissions = parse_permissions(permissions)?;

      let plugin_crate_name = plugin_crate_name_var.to_lowercase().replace('_', "-");
      let plugin_crate_name = plugin_crate_name
        .strip_prefix("tauri-plugin-")
        .map(ToString::to_string)
        .unwrap_or(plugin_crate_name);

      permissions_map.insert(plugin_crate_name, permissions);
    }
  }

  Ok(permissions_map)
}

/// Define the global scope schema JSON file path if it exists and pass it to the immediate consuming crate.
pub fn define_global_scope_schema(
  schema: schemars::schema::RootSchema,
  pkg_name: &str,
  out_dir: &Path,
) -> Result<(), Error> {
  let path = out_dir.join("global-scope.json");
  write_if_changed(&path, serde_json::to_vec(&schema)?)
    .map_err(|e| Error::WriteFile(e, path.clone()))?;

  if let Some(plugin_name) = pkg_name.strip_prefix("tauri:") {
    println!(
      "cargo:{plugin_name}{CORE_PLUGIN_PERMISSIONS_TOKEN}_{GLOBAL_SCOPE_SCHEMA_PATH_KEY}={}",
      path.display()
    );
  } else {
    println!("cargo:{GLOBAL_SCOPE_SCHEMA_PATH_KEY}={}", path.display());
  }

  Ok(())
}

/// Read all global scope schemas listed from the defined cargo cfg key value.
pub fn read_global_scope_schemas() -> Result<HashMap<String, serde_json::Value>, Error> {
  let mut schemas_map = HashMap::new();

  for (key, value) in env::vars_os() {
    let key = key.to_string_lossy();

    if let Some(plugin_crate_name_var) = key
      .strip_prefix("DEP_")
      .and_then(|v| v.strip_suffix(&format!("_{GLOBAL_SCOPE_SCHEMA_PATH_KEY}")))
      .map(|v| {
        v.strip_suffix(CORE_PLUGIN_PERMISSIONS_TOKEN)
          .and_then(|v| v.strip_prefix("TAURI_"))
          .unwrap_or(v)
      })
    {
      let path = PathBuf::from(value);
      let json = fs::read_to_string(&path).map_err(|e| Error::ReadFile(e, path))?;
      let schema: serde_json::Value = serde_json::from_str(&json)?;

      let plugin_crate_name = plugin_crate_name_var.to_lowercase().replace('_', "-");
      let plugin_crate_name = plugin_crate_name
        .strip_prefix("tauri-plugin-")
        .map(ToString::to_string)
        .unwrap_or(plugin_crate_name);

      schemas_map.insert(plugin_crate_name, schema);
    }
  }

  Ok(schemas_map)
}

/// Parses all capability files with the given glob pattern.
pub fn parse_capabilities(pattern: &str) -> Result<BTreeMap<String, Capability>, Error> {
  let mut capabilities_map = BTreeMap::new();

  for path in glob::glob(pattern)?
    .flatten() // filter extension
    .filter(|p| {
      p.extension()
        .and_then(|e| e.to_str())
        .map(|e| CAPABILITY_FILE_EXTENSIONS.contains(&e))
        .unwrap_or_default()
    })
    // filter schema files
    // TODO: remove this before stable
    .filter(|p| p.parent().unwrap().file_name().unwrap() != CAPABILITIES_SCHEMA_FOLDER_NAME)
  {
    match CapabilityFile::load(&path)? {
      CapabilityFile::Capability(capability) => {
        if capabilities_map.contains_key(&capability.identifier) {
          return Err(Error::CapabilityAlreadyExists {
            identifier: capability.identifier,
          });
        }

        capabilities_map.insert(capability.identifier.clone(), capability);
      }
      CapabilityFile::List(capabilities) | CapabilityFile::NamedList { capabilities } => {
        for capability in capabilities {
          if capabilities_map.contains_key(&capability.identifier) {
            return Err(Error::CapabilityAlreadyExists {
              identifier: capability.identifier,
            });
          }

          capabilities_map.insert(capability.identifier.clone(), capability);
        }
      }
    }
  }

  Ok(capabilities_map)
}

/// Permissions that are generated from commands using [`autogenerate_command_permissions`].
pub struct AutogeneratedPermissions {
  /// The allow permissions generated from commands.
  pub allowed: Vec<String>,
  /// The deny permissions generated from commands.
  pub denied: Vec<String>,
}

/// Autogenerate permission files for a list of commands.
pub fn autogenerate_command_permissions(
  path: &Path,
  commands: &[&str],
  license_header: &str,
  schema_ref: bool,
) -> AutogeneratedPermissions {
  if !path.exists() {
    fs::create_dir_all(path).expect("unable to create autogenerated commands dir");
  }

  let schema_entry = if schema_ref {
    let cwd = env::current_dir().unwrap();
    let components_len = path.strip_prefix(&cwd).unwrap_or(path).components().count();
    let schema_path = (1..components_len)
      .map(|_| "..")
      .collect::<PathBuf>()
      .join(PERMISSION_SCHEMAS_FOLDER_NAME)
      .join(PERMISSION_SCHEMA_FILE_NAME);
    format!(
      "\n\"$schema\" = \"{}\"\n",
      dunce::simplified(&schema_path)
        .display()
        .to_string()
        .replace('\\', "/")
    )
  } else {
    "".to_string()
  };

  let mut autogenerated = AutogeneratedPermissions {
    allowed: Vec::new(),
    denied: Vec::new(),
  };

  for command in commands {
    let slugified_command = command.replace('_', "-");

    let toml = format!(
      r###"{license_header}# Automatically generated - DO NOT EDIT!
{schema_entry}
[[permission]]
identifier = "allow-{slugified_command}"
description = "Enables the {command} command without any pre-configured scope."
commands.allow = ["{command}"]

[[permission]]
identifier = "deny-{slugified_command}"
description = "Denies the {command} command without any pre-configured scope."
commands.deny = ["{command}"]
"###,
    );

    let out_path = path.join(format!("{command}.toml"));
    write_if_changed(&out_path, toml)
      .unwrap_or_else(|_| panic!("unable to autogenerate {out_path:?}"));

    autogenerated
      .allowed
      .push(format!("allow-{slugified_command}"));
    autogenerated
      .denied
      .push(format!("deny-{slugified_command}"));
  }

  autogenerated
}

const PERMISSION_TABLE_HEADER: &str =
  "## Permission Table\n\n<table>\n<tr>\n<th>Identifier</th>\n<th>Description</th>\n</tr>\n";

/// Generate a markdown documentation page containing the list of permissions of the plugin.
pub fn generate_docs(
  permissions: &[PermissionFile],
  out_dir: &Path,
  plugin_identifier: &str,
) -> Result<(), Error> {
  let mut default_permission = "".to_owned();
  let mut permission_table = "".to_string();

  fn docs_from(id: &str, description: Option<&str>, plugin_identifier: &str) -> String {
    let mut docs = format!("\n<tr>\n<td>\n\n`{plugin_identifier}:{id}`\n\n</td>\n");
    if let Some(d) = description {
      docs.push_str(&format!("<td>\n\n{d}\n\n</td>"));
    }
    docs.push_str("\n</tr>");
    docs
  }

  for permission in permissions {
    for set in &permission.set {
      permission_table.push_str(&docs_from(
        &set.identifier,
        Some(&set.description),
        plugin_identifier,
      ));
      permission_table.push('\n');
    }

    if let Some(default) = &permission.default {
      default_permission.push_str("## Default Permission\n\n");
      default_permission.push_str(default.description.as_deref().unwrap_or_default().trim());
      default_permission.push('\n');
      default_permission.push('\n');
      if !default.permissions.is_empty() {
        default_permission.push_str("#### This default permission set includes the following:\n\n");
        for permission in &default.permissions {
          default_permission.push_str(&format!("- `{permission}`\n"));
        }
        default_permission.push('\n');
      }
    }

    for permission in &permission.permission {
      permission_table.push_str(&docs_from(
        &permission.identifier,
        permission.description.as_deref(),
        plugin_identifier,
      ));
      permission_table.push('\n');
    }
  }

  let docs = format!("{default_permission}{PERMISSION_TABLE_HEADER}\n{permission_table}</table>\n");

  let reference_path = out_dir.join(PERMISSION_DOCS_FILE_NAME);
  write_if_changed(&reference_path, docs).map_err(|e| Error::WriteFile(e, reference_path))?;

  Ok(())
}

// TODO: We have way too many duplicated code around getting the config files, e.g.
//  - crates/tauri-codegen/src/lib.rs          (`get_config`)
//  - crates/tauri-build/src/lib.rs            (`try_build`)
//  - crates/tauri-cli/src/helpers/config.rs   (`get_internal`)
/// Generate allowed commands file for the `generate_handler` macro to remove never allowed commands
pub fn generate_allowed_commands(
  out_dir: &Path,
  capabilities_from_files: Option<BTreeMap<String, Capability>>,
  permissions_map: BTreeMap<String, Vec<PermissionFile>>,
) -> Result<(), anyhow::Error> {
  println!("cargo:rerun-if-env-changed={REMOVE_UNUSED_COMMANDS_ENV_VAR}");

  let allowed_commands_file_path = out_dir.join(ALLOWED_COMMANDS_FILE_NAME);

  let remove_unused_commands_env_var = std::env::var(REMOVE_UNUSED_COMMANDS_ENV_VAR);

  let should_generate_allowed_commands =
    remove_unused_commands_env_var.is_ok() && !permissions_map.is_empty();

  if !should_generate_allowed_commands {
    let _ = std::fs::remove_file(allowed_commands_file_path);
    return Ok(());
  }

  // It's safe to `unwrap` here since we have checked if the result is ok above
  let config_directory = PathBuf::from(remove_unused_commands_env_var.unwrap());
  let capabilities_path = config_directory.join("capabilities");
  // Cargo re-builds if the variable points to an empty path,
  // so we check for exists here
  // see https://github.com/rust-lang/cargo/issues/4213
  if capabilities_path.exists() {
    println!("cargo:rerun-if-changed={}", capabilities_path.display());
  }

  let target_triple = env::var("TARGET")?;
  let target = crate::platform::Target::from_triple(&target_triple);
  let (mut config, config_paths) = crate::config::parse::read_from(target, &config_directory)?;

  for config_file_path in config_paths {
    println!("cargo:rerun-if-changed={}", config_file_path.display());
  }

  if let Ok(env) = std::env::var("TAURI_CONFIG") {
    let merge_config: serde_json::Value = serde_json::from_str(&env)?;
    json_patch::merge(&mut config, &merge_config);
  }

  println!("cargo:rerun-if-env-changed=TAURI_CONFIG");

  // Set working directory to where `tauri.config.json` is, so that relative paths in it are parsed correctly.
  let old_cwd = std::env::current_dir()?;
  std::env::set_current_dir(config_directory)?;

  let config: Config = serde_json::from_value(config)?;

  // Reset working directory.
  std::env::set_current_dir(old_cwd)?;

  let acl: BTreeMap<String, crate::acl::manifest::Manifest> = permissions_map
    .into_iter()
    .map(|(key, permissions)| {
      let key = key
        .strip_prefix("tauri-plugin-")
        .unwrap_or(&key)
        .to_string();
      let manifest = crate::acl::manifest::Manifest::new(permissions, None);
      (key, manifest)
    })
    .collect();

  let capabilities_from_files = if let Some(capabilities) = capabilities_from_files {
    capabilities
  } else {
    crate::acl::build::parse_capabilities(&format!(
      "{}/**/*",
      glob::Pattern::escape(&capabilities_path.to_string_lossy())
    ))?
  };
  let capabilities = crate::acl::get_capabilities(&config, capabilities_from_files, None)?;

  let permission_entries = capabilities
    .into_iter()
    .flat_map(|(_, capabilities)| capabilities.permissions);
  let mut allowed_commands = AllowedCommands {
    has_app_acl: has_app_manifest(&acl),
    ..Default::default()
  };
  for permission_entry in permission_entries {
    let Ok(permissions) =
      crate::acl::resolved::get_permissions(permission_entry.identifier(), &acl)
    else {
      continue;
    };
    for permission in permissions {
      let plugin_name = permission.key;
      let allowed_command_names = &permission.permission.commands.allow;
      for allowed_command in allowed_command_names {
        let command_name = if plugin_name == crate::acl::APP_ACL_KEY {
          allowed_command.to_string()
        } else if let Some(core_plugin_name) = plugin_name.strip_prefix("core:") {
          format!("plugin:{core_plugin_name}|{allowed_command}")
        } else {
          format!("plugin:{plugin_name}|{allowed_command}")
        };
        allowed_commands.commands.insert(command_name);
      }
    }
  }

  write_if_changed(
    allowed_commands_file_path,
    serde_json::to_string(&allowed_commands)?,
  )?;

  Ok(())
}
