#![deny(clippy::undocumented_unsafe_blocks)]

use cosimo::SyncServerLimits;
use cosimo::sync_http::{
    Request, Response, ServerState, Status, SyncServerPrivacyConfig, SyncServerServiceMode,
    handle_request,
};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};

const DEFAULT_STORAGE_DIR_NAME: &str = "cosimo-sync-storage";
const STORAGE_ROOT_ENV: &str = "COSIMO_SYNC_STORAGE_ROOT";

fn main() {
    let args = env::args().skip(1).collect::<Vec<_>>();
    if args.first().map(String::as_str) == Some("config") {
        if let Err(error) = run_config_command(args.into_iter().skip(1)) {
            eprintln!("cosimo-sync-cgi config: {error}");
            std::process::exit(1);
        }
        return;
    }

    let trace = CgiTrace::new();
    let started = Instant::now();
    let response = match run(&trace, args) {
        Ok(response) => {
            trace.response_finished(response.status, started);
            response
        }
        Err(error) => {
            let response = error.into_response();
            trace.front_door_error(response.status, started);
            response
        }
    };
    if let Err(error) = io::stdout().write_all(&response.to_cgi_bytes()) {
        trace.stdout_write_failed(&error);
    }
}

fn run(trace: &CgiTrace, args: impl IntoIterator<Item = String>) -> Result<Response, CgiError> {
    let options = Options::parse(args)?;
    if options.help {
        return Ok(Response::text(Status::Ok, help_text()));
    }

    let environment = env::vars().collect::<BTreeMap<_, _>>();
    trace.request_started(&environment);
    let script_config = match options.config_script_path.as_deref() {
        Some(path) => read_cgi_script_config(path)?,
        None => CgiScriptConfig::default(),
    };
    let storage_root = resolve_storage_root(
        options.storage_root,
        script_config.storage_root,
        &environment,
    )?;
    trace.storage_root_resolved(&storage_root);
    let limits = sync_limits_from_sources(&environment, &script_config.limits)?;
    let privacy_config = sync_privacy_config_from_sources(&environment, &script_config.privacy)?;
    let body = read_cgi_body(&environment, trace)?;
    let request = cgi_request_from_environment(&environment, body)
        .map_err(|error| CgiError::bad_request(format!("could not read CGI request: {error}")))?;
    let state =
        ServerState::sqlite_with_limits(storage_root, limits).with_privacy_config(privacy_config);
    Ok(handle_request(&state, request))
}

struct CgiTrace {
    request_id: String,
}

impl CgiTrace {
    fn new() -> Self {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or(0);
        Self {
            request_id: format!("{}-{nanos}", std::process::id()),
        }
    }

    fn request_started(&self, environment: &BTreeMap<String, String>) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=request-start {}",
            self.request_id,
            Self::request_summary(environment)
        );
    }

    fn storage_root_resolved(&self, storage_root: &Path) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=storage-root storage_root={}",
            self.request_id,
            sanitize_log_value(&storage_root.display().to_string())
        );
    }

    fn body_read_started(&self, content_length: usize) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=body-read-start content_length={content_length}",
            self.request_id
        );
    }

    fn body_read_finished(&self, content_length: usize, started: Instant) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=body-read-finish content_length={content_length} elapsed_ms={}",
            self.request_id,
            started.elapsed().as_millis()
        );
    }

    fn body_read_rejected(&self, content_length: usize, limit: usize) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=body-read-reject content_length={content_length} limit={limit}",
            self.request_id
        );
    }

    fn body_read_failed(&self, content_length: usize, started: Instant, error: &io::Error) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=body-read-fail content_length={content_length} elapsed_ms={} error={}",
            self.request_id,
            started.elapsed().as_millis(),
            sanitize_log_value(&error.to_string())
        );
    }

    fn front_door_error(&self, status: Status, started: Instant) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=response status={} elapsed_ms={}",
            self.request_id,
            status.code(),
            started.elapsed().as_millis()
        );
    }

    fn response_finished(&self, status: Status, started: Instant) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=response status={} elapsed_ms={}",
            self.request_id,
            status.code(),
            started.elapsed().as_millis()
        );
    }

    fn stdout_write_failed(&self, error: &io::Error) {
        eprintln!(
            "cosimo-sync-cgi request_id={} phase=stdout-write-fail error={}",
            self.request_id,
            sanitize_log_value(&error.to_string())
        );
    }

    fn request_summary(environment: &BTreeMap<String, String>) -> String {
        let method = environment
            .get("REQUEST_METHOD")
            .map(String::as_str)
            .unwrap_or("(missing)");
        let path = environment
            .get("PATH_INFO")
            .map(String::as_str)
            .unwrap_or("/");
        let content_length = environment
            .get("CONTENT_LENGTH")
            .map(String::as_str)
            .unwrap_or("(missing)");
        format!(
            "method={} path={} content_length={}",
            sanitize_log_value(method),
            sanitize_log_value(path),
            sanitize_log_value(content_length)
        )
    }
}

fn sanitize_log_value(value: &str) -> String {
    const MAX_LOG_VALUE_LENGTH: usize = 240;
    let mut output = String::new();
    for character in value.chars().take(MAX_LOG_VALUE_LENGTH) {
        match character {
            '\n' | '\r' | '\t' => output.push(' '),
            character if character.is_control() => output.push('?'),
            character => output.push(character),
        }
    }
    if value.chars().count() > MAX_LOG_VALUE_LENGTH {
        output.push_str("...");
    }
    output
}

#[derive(Debug)]
enum CgiError {
    BadRequest(String),
    PayloadTooLarge(String),
    Server(String),
}

impl CgiError {
    fn bad_request(message: impl Into<String>) -> Self {
        Self::BadRequest(message.into())
    }

    fn payload_too_large(message: impl Into<String>) -> Self {
        Self::PayloadTooLarge(message.into())
    }

    fn server(message: impl Into<String>) -> Self {
        Self::Server(message.into())
    }

    fn into_response(self) -> Response {
        match self {
            Self::BadRequest(message) => Response::text(Status::BadRequest, &message),
            Self::PayloadTooLarge(message) => Response::text(Status::PayloadTooLarge, &message),
            Self::Server(message) => Response::text(Status::InternalServerError, &message),
        }
    }
}

impl From<String> for CgiError {
    fn from(message: String) -> Self {
        Self::server(message)
    }
}

#[derive(Debug, PartialEq, Eq)]
struct Options {
    storage_root: Option<PathBuf>,
    config_script_path: Option<PathBuf>,
    help: bool,
}

impl Options {
    fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
        let mut options = Self {
            storage_root: None,
            config_script_path: None,
            help: false,
        };
        let mut args = args.into_iter();
        while let Some(arg) = args.next() {
            match arg.as_str() {
                "-h" | "--help" => options.help = true,
                "--storage-root" => {
                    options.storage_root =
                        Some(PathBuf::from(args.next().ok_or_else(|| {
                            "--storage-root requires a path".to_string()
                        })?));
                }
                _ if arg.starts_with('-') => {
                    return Err(format!("unknown argument `{arg}`. Use --help for usage."));
                }
                _ => {
                    if options.config_script_path.is_some() {
                        return Err(format!(
                            "unexpected argument `{arg}`. Use --help for usage."
                        ));
                    }
                    options.config_script_path = Some(PathBuf::from(arg));
                }
            }
        }
        Ok(options)
    }
}

fn help_text() -> &'static str {
    "Usage: cosimo-sync-cgi [CONFIG_SCRIPT] [--storage-root PATH]\n\
     Usage: cosimo-sync-cgi config [OUTPUT_SCRIPT] [--force]\n\n\
     This CGI entry point serves the Cosimo sync API over durable SQLite/file\n\
     storage. It can be used directly, or as the interpreter for a small\n\
     Fossil-style CGI config script. Storage is resolved from --storage-root,\n\
     then storage-root in the config script, then COSIMO_SYNC_STORAGE_ROOT,\n\
     then a cosimo-sync-storage directory beside the executable. The storage\n\
     root must not be web-readable. The endpoint supports Mastodon-compatible\n\
     browser OAuth and bearer-token owner routes.\n\n\
     The `config` subcommand asks deployment questions and writes a complete\n\
     executable CGI config script in the supported Fossil-style format.\n\n\
     Config script keys:\n\
     storage-root\n\
     max-bundle-bytes\n\
     max-bundle-expanded-bytes\n\
     max-bundle-entries\n\
     max-deck-file-bytes\n\
     max-decks-per-owner\n\
     max-storage-bytes-per-owner\n\
     max-storage-bytes-total\n\
     max-oauth-starts-per-client-per-hour\n\
     max-oauth-starts-per-instance-per-hour\n\
     max-pending-oauth-logins-per-instance\n\
     service-mode\n\
     controller-name\n\
     controller-contact\n\
     controller-fedi\n\
     dpo-contact\n\
     rights-contact\n\
     privacy-notice-version\n\
     data-categories\n\
     processing-purposes\n\
     lawful-basis\n\
     recipients\n\
     international-transfers\n\
     retention-policy\n\
     complaint-contact\n\
     domestic-registration-allowlist\n\n\
     Optional limit environment variables:\n\
     COSIMO_SYNC_MAX_BUNDLE_BYTES\n\
     COSIMO_SYNC_MAX_BUNDLE_EXPANDED_BYTES\n\
     COSIMO_SYNC_MAX_BUNDLE_ENTRIES\n\
     COSIMO_SYNC_MAX_DECK_FILE_BYTES\n\
     COSIMO_SYNC_MAX_DECKS_PER_OWNER\n\
     COSIMO_SYNC_MAX_STORAGE_BYTES_PER_OWNER\n\
     COSIMO_SYNC_MAX_STORAGE_BYTES_TOTAL\n\
     COSIMO_SYNC_MAX_OAUTH_STARTS_PER_CLIENT_PER_HOUR\n\
     COSIMO_SYNC_MAX_OAUTH_STARTS_PER_INSTANCE_PER_HOUR\n\
     COSIMO_SYNC_MAX_PENDING_OAUTH_LOGINS_PER_INSTANCE\n"
}

#[derive(Debug, PartialEq, Eq)]
struct ConfigWizardOptions {
    output_script: Option<PathBuf>,
    force: bool,
    help: bool,
}

impl ConfigWizardOptions {
    fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
        let mut options = Self {
            output_script: None,
            force: false,
            help: false,
        };
        for arg in args {
            match arg.as_str() {
                "-h" | "--help" => options.help = true,
                "--force" => options.force = true,
                _ if arg.starts_with('-') => {
                    return Err(format!("unknown argument `{arg}`. Use `config --help`."));
                }
                _ => {
                    if options.output_script.is_some() {
                        return Err(format!("unexpected argument `{arg}`. Use `config --help`."));
                    }
                    options.output_script = Some(PathBuf::from(arg));
                }
            }
        }
        Ok(options)
    }
}

fn config_help_text() -> &'static str {
    "Usage: cosimo-sync-cgi config [OUTPUT_SCRIPT] [--force]\n\n\
     Ask deployment questions and write an executable sync.cgi config script.\n\
     If OUTPUT_SCRIPT is omitted, the wizard asks for it and defaults to\n\
     sync.cgi in the current directory. Existing files are not overwritten\n\
     unless --force is supplied.\n"
}

fn run_config_command(args: impl IntoIterator<Item = String>) -> Result<(), String> {
    let options = ConfigWizardOptions::parse(args)?;
    let stdin = io::stdin();
    let mut input = stdin.lock();
    let stdout = io::stdout();
    let mut output = stdout.lock();
    if options.help {
        output
            .write_all(config_help_text().as_bytes())
            .map_err(|error| format!("could not write help text: {error}"))?;
        return Ok(());
    }
    run_config_wizard(options, &mut input, &mut output)
}

const DEFAULT_DATA_CATEGORIES: &str = "Fediverse account identity, bearer-session metadata, deck bundles, review-history bundles, and operational logs";
const DEFAULT_PROCESSING_PURPOSES: &str = "Cosimo deck synchronisation and account security";
const DEFAULT_LAWFUL_BASIS: &str = "Explicit consent";
const DEFAULT_RECIPIENTS: &str = "Server operator and hosting provider";
const DEFAULT_INTERNATIONAL_TRANSFERS: &str =
    "No international transfers beyond the configured hosting location";
const DEFAULT_RETENTION_POLICY: &str = "Account data is retained until deletion; logs are retained for up to one week unless a security hold applies";
const DEFAULT_PRIVACY_NOTICE_VERSION: &str = "1";

fn run_config_wizard(
    options: ConfigWizardOptions,
    input: &mut impl BufRead,
    output: &mut impl Write,
) -> Result<(), String> {
    let output_script = match options.output_script {
        Some(path) => path,
        None => PathBuf::from(
            prompt_value(
                output,
                input,
                "Output CGI script path",
                Some("sync.cgi"),
                true,
            )?
            .expect("required prompt returns a value"),
        ),
    };

    writeln!(
        output,
        "This writes a Cosimo sync CGI config script. Put it where your web server executes CGI, not where it serves plain text."
    )
    .map_err(|error| format!("could not write prompt: {error}"))?;

    let binary_default = env::current_exe()
        .ok()
        .map(|path| path.display().to_string())
        .unwrap_or_else(|| "/path/to/cosimo-sync-cgi".to_string());
    let binary_path = prompt_value(
        output,
        input,
        "Path to cosimo-sync-cgi executable",
        Some(&binary_default),
        true,
    )?
    .expect("required prompt returns a value");
    reject_multiline("cosimo-sync-cgi executable path", &binary_path)?;

    let storage_root = prompt_value(
        output,
        input,
        "Private storage root, not web-readable",
        None,
        true,
    )?
    .expect("required prompt returns a value");
    reject_multiline("storage root", &storage_root)?;

    let service_mode = prompt_service_mode(output, input)?;
    let mut entries = Vec::<(String, String)>::new();
    entries.push(("storage-root".to_string(), storage_root));
    entries.push(("service-mode".to_string(), service_mode.code().to_string()));

    let controller_required = service_mode == SyncServerServiceMode::Public;
    let controller_name = prompt_value(
        output,
        input,
        "Data controller name",
        None,
        controller_required,
    )?;
    let controller_contact = prompt_value(
        output,
        input,
        "Data controller contact, preferably email or web URL",
        None,
        controller_required,
    )?;
    push_optional_entry(&mut entries, "controller-name", controller_name)?;
    push_optional_entry(&mut entries, "controller-contact", controller_contact)?;
    push_optional_entry(
        &mut entries,
        "controller-fedi",
        prompt_value(
            output,
            input,
            "Optional controller Fediverse account",
            None,
            false,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "dpo-contact",
        prompt_value(output, input, "Optional DPO contact", None, false)?,
    )?;
    push_optional_entry(
        &mut entries,
        "rights-contact",
        prompt_value(
            output,
            input,
            "Optional data-rights request contact",
            None,
            false,
        )?,
    )?;

    push_optional_entry(
        &mut entries,
        "privacy-notice-version",
        prompt_value(
            output,
            input,
            "Privacy notice version label",
            Some(DEFAULT_PRIVACY_NOTICE_VERSION),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "data-categories",
        prompt_value(
            output,
            input,
            "Processed data categories",
            Some(DEFAULT_DATA_CATEGORIES),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "processing-purposes",
        prompt_value(
            output,
            input,
            "Processing purposes",
            Some(DEFAULT_PROCESSING_PURPOSES),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "lawful-basis",
        prompt_value(
            output,
            input,
            "Lawful basis",
            Some(DEFAULT_LAWFUL_BASIS),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "recipients",
        prompt_value(output, input, "Recipients", Some(DEFAULT_RECIPIENTS), true)?,
    )?;
    push_optional_entry(
        &mut entries,
        "international-transfers",
        prompt_value(
            output,
            input,
            "International transfers",
            Some(DEFAULT_INTERNATIONAL_TRANSFERS),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "retention-policy",
        prompt_value(
            output,
            input,
            "Retention policy",
            Some(DEFAULT_RETENTION_POLICY),
            true,
        )?,
    )?;
    push_optional_entry(
        &mut entries,
        "complaint-contact",
        prompt_value(
            output,
            input,
            "Optional complaint or supervisory-authority contact",
            None,
            false,
        )?,
    )?;

    if service_mode == SyncServerServiceMode::Domestic {
        push_optional_entry(
            &mut entries,
            "domestic-registration-allowlist",
            prompt_value(
                output,
                input,
                "Optional domestic registration allowlist, comma-separated Fediverse owners",
                None,
                false,
            )?,
        )?;
    }

    write_config_wizard_limit_prompts(output, input, &mut entries)?;
    let script = format_generated_cgi_script(&binary_path, &entries)?;
    write_config_script_file(&output_script, script.as_bytes(), options.force)?;
    writeln!(
        output,
        "Wrote `{}`. Review it, keep the storage root private, and configure the web server to execute it as CGI.",
        output_script.display()
    )
    .map_err(|error| format!("could not write completion message: {error}"))?;
    Ok(())
}

fn prompt_service_mode(
    output: &mut impl Write,
    input: &mut impl BufRead,
) -> Result<SyncServerServiceMode, String> {
    loop {
        let value = prompt_value(
            output,
            input,
            "Service mode (public/domestic)",
            Some("public"),
            true,
        )?
        .expect("required prompt returns a value");
        if let Some(mode) = SyncServerServiceMode::from_code(&value) {
            return Ok(mode);
        }
        writeln!(output, "Enter `public` or `domestic`.")
            .map_err(|error| format!("could not write prompt: {error}"))?;
    }
}

fn write_config_wizard_limit_prompts(
    output: &mut impl Write,
    input: &mut impl BufRead,
    entries: &mut Vec<(String, String)>,
) -> Result<(), String> {
    writeln!(
        output,
        "Optional limits and quotas. Leave a value blank to use the server default."
    )
    .map_err(|error| format!("could not write prompt: {error}"))?;

    for key in [
        "max-bundle-bytes",
        "max-bundle-expanded-bytes",
        "max-bundle-entries",
        "max-deck-file-bytes",
        "max-decks-per-owner",
        "max-storage-bytes-per-owner",
        "max-storage-bytes-total",
        "max-oauth-starts-per-client-per-hour",
        "max-oauth-starts-per-instance-per-hour",
        "max-pending-oauth-logins-per-instance",
    ] {
        loop {
            let value = prompt_value(output, input, key, None, false)?;
            let Some(value) = value else {
                break;
            };
            if value.parse::<u64>().is_ok() {
                entries.push((key.to_string(), value));
                break;
            }
            writeln!(output, "`{key}` must be an unsigned integer.")
                .map_err(|error| format!("could not write prompt: {error}"))?;
        }
    }
    Ok(())
}

fn prompt_value(
    output: &mut impl Write,
    input: &mut impl BufRead,
    label: &str,
    default: Option<&str>,
    required: bool,
) -> Result<Option<String>, String> {
    loop {
        match default {
            Some(default) => write!(output, "{label} [{default}]: "),
            None => write!(output, "{label}: "),
        }
        .map_err(|error| format!("could not write prompt: {error}"))?;
        output
            .flush()
            .map_err(|error| format!("could not flush prompt: {error}"))?;

        let mut line = String::new();
        let bytes = input
            .read_line(&mut line)
            .map_err(|error| format!("could not read answer: {error}"))?;
        let value = line.trim();
        if value.is_empty() {
            if let Some(default) = default {
                return Ok(Some(default.to_string()));
            }
            if required {
                if bytes == 0 {
                    return Err(format!("`{label}` is required"));
                }
                writeln!(output, "This value is required.")
                    .map_err(|error| format!("could not write prompt: {error}"))?;
                continue;
            }
            return Ok(None);
        }
        return Ok(Some(value.to_string()));
    }
}

fn push_optional_entry(
    entries: &mut Vec<(String, String)>,
    key: &str,
    value: Option<String>,
) -> Result<(), String> {
    let Some(value) = value else {
        return Ok(());
    };
    reject_multiline(key, &value)?;
    entries.push((key.to_string(), value));
    Ok(())
}

fn reject_multiline(label: &str, value: &str) -> Result<(), String> {
    if value.contains('\r') || value.contains('\n') {
        return Err(format!("`{label}` must fit on one line"));
    }
    Ok(())
}

fn format_generated_cgi_script(
    binary_path: &str,
    entries: &[(String, String)],
) -> Result<String, String> {
    reject_multiline("cosimo-sync-cgi executable path", binary_path)?;
    let mut script = String::new();
    script.push_str("#!");
    script.push_str(binary_path);
    script.push('\n');
    script.push_str("# Generated by `cosimo-sync-cgi config`.\n");
    script.push_str("# Keep this script executable and do not serve it as static text.\n");
    for (key, value) in entries {
        reject_multiline(key, value)?;
        script.push_str(key);
        script.push_str(": ");
        script.push_str(value);
        script.push('\n');
    }
    Ok(script)
}

fn write_config_script_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
    if force {
        replace_config_script_file(path, contents)
    } else {
        create_config_script_file(path, contents)
    }
}

fn create_config_script_file(path: &Path, contents: &[u8]) -> Result<(), String> {
    let file = open_new_config_script_file(path).map_err(|error| {
        if error.kind() == io::ErrorKind::AlreadyExists {
            format!(
                "`{}` already exists; pass --force to replace it",
                path.display()
            )
        } else {
            format!("could not create `{}`: {error}", path.display())
        }
    })?;
    if let Err(error) = write_open_config_script_file(file, contents, path) {
        let _ = fs::remove_file(path);
        return Err(error);
    }
    sync_config_script_parent(path)?;
    Ok(())
}

fn replace_config_script_file(path: &Path, contents: &[u8]) -> Result<(), String> {
    let mut last_error = None;
    for attempt in 0..100 {
        let temp_path = temporary_config_script_path(path, attempt)?;
        let file = match open_new_config_script_file(&temp_path) {
            Ok(file) => file,
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
                last_error = Some(error);
                continue;
            }
            Err(error) => {
                return Err(format!(
                    "could not create temporary config script `{}`: {error}",
                    temp_path.display()
                ));
            }
        };
        if let Err(error) = write_open_config_script_file(file, contents, &temp_path) {
            let _ = fs::remove_file(&temp_path);
            return Err(error);
        }
        if let Err(error) = replace_file(&temp_path, path) {
            let _ = fs::remove_file(&temp_path);
            return Err(format!(
                "could not replace `{}` with `{}`: {error}",
                path.display(),
                temp_path.display()
            ));
        }
        sync_config_script_parent(path)?;
        return Ok(());
    }
    Err(format!(
        "could not allocate a temporary config script path near `{}`{}",
        path.display(),
        last_error
            .map(|error| format!(": {error}"))
            .unwrap_or_default()
    ))
}

fn temporary_config_script_path(path: &Path, attempt: u32) -> Result<PathBuf, String> {
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .ok_or_else(|| format!("`{}` does not name a config script file", path.display()))?;
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0);
    let mut temp_name = OsString::from(".");
    temp_name.push(file_name);
    temp_name.push(format!(".tmp.{}.{nanos}.{attempt}", std::process::id()));
    Ok(parent.join(temp_name))
}

fn open_new_config_script_file(path: &Path) -> io::Result<File> {
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o700);
        #[cfg(any(target_os = "android", target_os = "linux"))]
        {
            const O_NOFOLLOW: i32 = 0o400000;
            options.custom_flags(O_NOFOLLOW);
        }
    }
    options.open(path)
}

fn write_open_config_script_file(
    mut file: File,
    contents: &[u8],
    path: &Path,
) -> Result<(), String> {
    set_config_script_permissions(&file, path)?;
    file.write_all(contents)
        .map_err(|error| format!("could not write `{}`: {error}", path.display()))?;
    file.sync_all()
        .map_err(|error| format!("could not sync `{}`: {error}", path.display()))
}

#[cfg(unix)]
fn set_config_script_permissions(file: &File, path: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;
    file.set_permissions(fs::Permissions::from_mode(0o755))
        .map_err(|error| format!("could not mark `{}` executable: {error}", path.display()))
}

#[cfg(not(unix))]
fn set_config_script_permissions(_file: &File, _path: &Path) -> Result<(), String> {
    Ok(())
}

#[cfg(unix)]
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
    fs::rename(source, destination)
}

#[cfg(windows)]
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
    match fs::remove_file(destination) {
        Ok(()) => {}
        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
        Err(error) => return Err(error),
    }
    fs::rename(source, destination)
}

#[cfg(not(any(unix, windows)))]
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
    fs::rename(source, destination)
}

#[cfg(unix)]
fn sync_config_script_parent(path: &Path) -> Result<(), String> {
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let directory = File::open(parent).map_err(|error| {
        format!(
            "could not open `{}` for directory sync: {error}",
            parent.display()
        )
    })?;
    directory
        .sync_all()
        .map_err(|error| format!("could not sync `{}`: {error}", parent.display()))
}

#[cfg(not(unix))]
fn sync_config_script_parent(_path: &Path) -> Result<(), String> {
    Ok(())
}

fn resolve_storage_root(
    option: Option<PathBuf>,
    script_option: Option<PathBuf>,
    environment: &BTreeMap<String, String>,
) -> Result<PathBuf, String> {
    if let Some(root) = option {
        return Ok(root);
    }
    if let Some(root) = script_option {
        return Ok(root);
    }
    if let Some(root) = optional_env_value(environment, STORAGE_ROOT_ENV)? {
        return Ok(PathBuf::from(root));
    }
    let executable = env::current_exe()
        .map_err(|error| format!("could not find CGI executable path: {error}"))?;
    let Some(parent) = executable.parent() else {
        return Err("could not find CGI executable directory".to_string());
    };
    Ok(parent.join(DEFAULT_STORAGE_DIR_NAME))
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct CgiScriptConfig {
    storage_root: Option<PathBuf>,
    limits: SyncLimitOverrides,
    privacy: SyncPrivacyOverrides,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct SyncLimitOverrides {
    max_bundle_bytes: Option<u64>,
    max_bundle_expanded_bytes: Option<u64>,
    max_bundle_entries: Option<usize>,
    max_deck_file_bytes: Option<u64>,
    max_decks_per_owner: Option<usize>,
    max_storage_bytes_per_owner: Option<u64>,
    max_storage_bytes_total: Option<u64>,
    max_oauth_starts_per_client_per_hour: Option<usize>,
    max_oauth_starts_per_instance_per_hour: Option<usize>,
    max_pending_oauth_logins_per_instance: Option<usize>,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct SyncPrivacyOverrides {
    mode: Option<SyncServerServiceMode>,
    controller_name: Option<String>,
    controller_contact: Option<String>,
    controller_fedi: Option<String>,
    dpo_contact: Option<String>,
    rights_contact: Option<String>,
    notice_version: Option<String>,
    data_categories: Option<String>,
    purposes: Option<String>,
    lawful_basis: Option<String>,
    recipients: Option<String>,
    international_transfers: Option<String>,
    retention: Option<String>,
    complaint_contact: Option<String>,
    domestic_registration_allowlist: Option<std::collections::BTreeSet<String>>,
}

fn read_cgi_script_config(path: &Path) -> Result<CgiScriptConfig, String> {
    let contents = fs::read_to_string(path).map_err(|error| {
        format!(
            "could not read CGI config script `{}`: {error}",
            path.display()
        )
    })?;
    parse_cgi_script_config(&contents).map_err(|error| format!("{}: {error}", path.display()))
}

fn parse_cgi_script_config(contents: &str) -> Result<CgiScriptConfig, String> {
    let mut config = CgiScriptConfig::default();
    let mut seen = BTreeSet::new();
    for (line_index, raw_line) in contents.lines().enumerate() {
        let line_number = line_index + 1;
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((key, value)) = line.split_once(':') else {
            return Err(format!("line {line_number}: expected `key: value`"));
        };
        let key = key.trim();
        let value = value.trim();
        if key.is_empty() {
            return Err(format!("line {line_number}: config key is empty"));
        }
        if value.is_empty() {
            return Err(format!("line {line_number}: `{key}` value is empty"));
        }
        if !seen.insert(key.to_string()) {
            return Err(format!("line {line_number}: duplicate `{key}` setting"));
        }

        match key {
            "storage-root" => config.storage_root = Some(PathBuf::from(value)),
            "max-bundle-bytes" => {
                config.limits.max_bundle_bytes =
                    Some(parse_u64_config_value(key, value, line_number)?);
            }
            "max-bundle-expanded-bytes" => {
                config.limits.max_bundle_expanded_bytes =
                    Some(parse_u64_config_value(key, value, line_number)?);
            }
            "max-bundle-entries" => {
                config.limits.max_bundle_entries =
                    Some(parse_usize_config_value(key, value, line_number)?);
            }
            "max-deck-file-bytes" => {
                config.limits.max_deck_file_bytes =
                    Some(parse_u64_config_value(key, value, line_number)?);
            }
            "max-decks-per-owner" => {
                config.limits.max_decks_per_owner =
                    Some(parse_usize_config_value(key, value, line_number)?);
            }
            "max-storage-bytes-per-owner" => {
                config.limits.max_storage_bytes_per_owner =
                    Some(parse_u64_config_value(key, value, line_number)?);
            }
            "max-storage-bytes-total" => {
                config.limits.max_storage_bytes_total =
                    Some(parse_u64_config_value(key, value, line_number)?);
            }
            "max-oauth-starts-per-client-per-hour" => {
                config.limits.max_oauth_starts_per_client_per_hour =
                    Some(parse_usize_config_value(key, value, line_number)?);
            }
            "max-oauth-starts-per-instance-per-hour" => {
                config.limits.max_oauth_starts_per_instance_per_hour =
                    Some(parse_usize_config_value(key, value, line_number)?);
            }
            "max-pending-oauth-logins-per-instance" => {
                config.limits.max_pending_oauth_logins_per_instance =
                    Some(parse_usize_config_value(key, value, line_number)?);
            }
            "service-mode" => {
                config.privacy.mode =
                    Some(SyncServerServiceMode::from_code(value).ok_or_else(|| {
                        format!("line {line_number}: `service-mode` must be public or domestic")
                    })?);
            }
            "controller-name" => {
                config.privacy.controller_name =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "controller-contact" => {
                config.privacy.controller_contact =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "controller-fedi" => {
                config.privacy.controller_fedi =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "dpo-contact" => {
                config.privacy.dpo_contact =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "rights-contact" => {
                config.privacy.rights_contact =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "privacy-notice-version" => {
                config.privacy.notice_version =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "data-categories" => {
                config.privacy.data_categories =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "processing-purposes" => {
                config.privacy.purposes =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "lawful-basis" => {
                config.privacy.lawful_basis =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "recipients" => {
                config.privacy.recipients =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "international-transfers" => {
                config.privacy.international_transfers =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "retention-policy" => {
                config.privacy.retention =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "complaint-contact" => {
                config.privacy.complaint_contact =
                    Some(parse_single_line_config_value(key, value, line_number)?);
            }
            "domestic-registration-allowlist" => {
                config.privacy.domestic_registration_allowlist = Some(split_allowlist(
                    parse_single_line_config_value(key, value, line_number)?,
                ));
            }
            _ => return Err(format!("line {line_number}: unknown `{key}` setting")),
        }
    }
    Ok(config)
}

fn parse_u64_config_value(key: &str, value: &str, line_number: usize) -> Result<u64, String> {
    value
        .parse::<u64>()
        .map_err(|_| format!("line {line_number}: `{key}` must be an unsigned integer"))
}

fn parse_usize_config_value(key: &str, value: &str, line_number: usize) -> Result<usize, String> {
    value
        .parse::<usize>()
        .map_err(|_| format!("line {line_number}: `{key}` must be an unsigned integer"))
}

fn parse_single_line_config_value(
    key: &str,
    value: &str,
    line_number: usize,
) -> Result<String, String> {
    if value.contains('\r') || value.contains('\n') {
        return Err(format!("line {line_number}: `{key}` must fit on one line"));
    }
    Ok(value.to_string())
}

fn read_cgi_body(
    environment: &BTreeMap<String, String>,
    trace: &CgiTrace,
) -> Result<Vec<u8>, CgiError> {
    let content_length = environment
        .get("CONTENT_LENGTH")
        .filter(|value| !value.trim().is_empty())
        .map(|value| {
            value
                .parse::<usize>()
                .map_err(|_| CgiError::bad_request("CONTENT_LENGTH must be an unsigned integer"))
        })
        .transpose()?
        .unwrap_or(0);
    trace.body_read_started(content_length);
    if content_length > cosimo::sync_http::MAX_BODY_BYTES {
        trace.body_read_rejected(content_length, cosimo::sync_http::MAX_BODY_BYTES);
        return Err(CgiError::payload_too_large("CGI request body is too large"));
    }

    let mut body = vec![0; content_length];
    let started = Instant::now();
    io::stdin().read_exact(&mut body).map_err(|error| {
        trace.body_read_failed(content_length, started, &error);
        CgiError::bad_request(format!("could not read CGI request body: {error}"))
    })?;
    trace.body_read_finished(content_length, started);
    Ok(body)
}

#[cfg(test)]
fn sync_limits_from_environment(
    environment: &BTreeMap<String, String>,
) -> Result<SyncServerLimits, String> {
    sync_limits_from_sources(environment, &SyncLimitOverrides::default())
}

fn sync_limits_from_sources(
    environment: &BTreeMap<String, String>,
    script: &SyncLimitOverrides,
) -> Result<SyncServerLimits, String> {
    let mut limits = SyncServerLimits::default();
    limits.max_bundle_bytes = script.max_bundle_bytes.or(optional_u64_env(
        environment,
        "COSIMO_SYNC_MAX_BUNDLE_BYTES",
    )?
    .or(limits.max_bundle_bytes));
    limits.max_bundle_expanded_bytes = script.max_bundle_expanded_bytes.or(optional_u64_env(
        environment,
        "COSIMO_SYNC_MAX_BUNDLE_EXPANDED_BYTES",
    )?
    .or(limits.max_bundle_expanded_bytes));
    limits.max_bundle_entries = script.max_bundle_entries.or(optional_usize_env(
        environment,
        "COSIMO_SYNC_MAX_BUNDLE_ENTRIES",
    )?
    .or(limits.max_bundle_entries));
    limits.max_deck_file_bytes = script.max_deck_file_bytes.or(optional_u64_env(
        environment,
        "COSIMO_SYNC_MAX_DECK_FILE_BYTES",
    )?
    .or(limits.max_deck_file_bytes));
    limits.max_decks_per_owner = script.max_decks_per_owner.or(optional_usize_env(
        environment,
        "COSIMO_SYNC_MAX_DECKS_PER_OWNER",
    )?);
    limits.max_storage_bytes_per_owner = script.max_storage_bytes_per_owner.or(optional_u64_env(
        environment,
        "COSIMO_SYNC_MAX_STORAGE_BYTES_PER_OWNER",
    )?);
    limits.max_storage_bytes_total = script.max_storage_bytes_total.or(optional_u64_env(
        environment,
        "COSIMO_SYNC_MAX_STORAGE_BYTES_TOTAL",
    )?);
    limits.max_oauth_starts_per_client_per_hour =
        script
            .max_oauth_starts_per_client_per_hour
            .or(optional_usize_env(
                environment,
                "COSIMO_SYNC_MAX_OAUTH_STARTS_PER_CLIENT_PER_HOUR",
            )?
            .or(limits.max_oauth_starts_per_client_per_hour));
    limits.max_oauth_starts_per_instance_per_hour = script
        .max_oauth_starts_per_instance_per_hour
        .or(optional_usize_env(
            environment,
            "COSIMO_SYNC_MAX_OAUTH_STARTS_PER_INSTANCE_PER_HOUR",
        )?
        .or(limits.max_oauth_starts_per_instance_per_hour));
    limits.max_pending_oauth_logins_per_instance =
        script
            .max_pending_oauth_logins_per_instance
            .or(optional_usize_env(
                environment,
                "COSIMO_SYNC_MAX_PENDING_OAUTH_LOGINS_PER_INSTANCE",
            )?
            .or(limits.max_pending_oauth_logins_per_instance));
    Ok(limits)
}

fn sync_privacy_config_from_sources(
    environment: &BTreeMap<String, String>,
    script: &SyncPrivacyOverrides,
) -> Result<SyncServerPrivacyConfig, String> {
    let mut config = SyncServerPrivacyConfig::default();
    if let Some(mode) = optional_env_value(environment, "COSIMO_SYNC_SERVICE_MODE")? {
        config.mode = SyncServerServiceMode::from_code(mode)
            .ok_or_else(|| "COSIMO_SYNC_SERVICE_MODE must be public or domestic".to_string())?;
    }
    config.controller_name = optional_single_line_env(environment, "COSIMO_SYNC_CONTROLLER_NAME")?;
    config.controller_contact =
        optional_single_line_env(environment, "COSIMO_SYNC_CONTROLLER_CONTACT")?;
    config.controller_fedi = optional_single_line_env(environment, "COSIMO_SYNC_CONTROLLER_FEDI")?;
    config.dpo_contact = optional_single_line_env(environment, "COSIMO_SYNC_DPO_CONTACT")?;
    config.rights_contact = optional_single_line_env(environment, "COSIMO_SYNC_RIGHTS_CONTACT")?;
    config.notice_version =
        optional_single_line_env(environment, "COSIMO_SYNC_PRIVACY_NOTICE_VERSION")?;
    config.data_categories = optional_single_line_env(environment, "COSIMO_SYNC_DATA_CATEGORIES")?;
    config.purposes = optional_single_line_env(environment, "COSIMO_SYNC_PROCESSING_PURPOSES")?;
    config.lawful_basis = optional_single_line_env(environment, "COSIMO_SYNC_LAWFUL_BASIS")?;
    config.recipients = optional_single_line_env(environment, "COSIMO_SYNC_RECIPIENTS")?;
    config.international_transfers =
        optional_single_line_env(environment, "COSIMO_SYNC_INTERNATIONAL_TRANSFERS")?;
    config.retention = optional_single_line_env(environment, "COSIMO_SYNC_RETENTION_POLICY")?;
    config.complaint_contact =
        optional_single_line_env(environment, "COSIMO_SYNC_COMPLAINT_CONTACT")?;
    if let Some(allowlist) =
        optional_single_line_env(environment, "COSIMO_SYNC_DOMESTIC_REGISTRATION_ALLOWLIST")?
    {
        config.domestic_registration_allowlist = split_allowlist(allowlist);
    }
    if let Some(mode) = script.mode {
        config.mode = mode;
    }
    override_string(&mut config.controller_name, &script.controller_name);
    override_string(&mut config.controller_contact, &script.controller_contact);
    override_string(&mut config.controller_fedi, &script.controller_fedi);
    override_string(&mut config.dpo_contact, &script.dpo_contact);
    override_string(&mut config.rights_contact, &script.rights_contact);
    override_string(&mut config.notice_version, &script.notice_version);
    override_string(&mut config.data_categories, &script.data_categories);
    override_string(&mut config.purposes, &script.purposes);
    override_string(&mut config.lawful_basis, &script.lawful_basis);
    override_string(&mut config.recipients, &script.recipients);
    override_string(
        &mut config.international_transfers,
        &script.international_transfers,
    );
    override_string(&mut config.retention, &script.retention);
    override_string(&mut config.complaint_contact, &script.complaint_contact);
    if let Some(allowlist) = &script.domestic_registration_allowlist {
        config.domestic_registration_allowlist = allowlist.clone();
    }
    Ok(config)
}

fn override_string(target: &mut Option<String>, source: &Option<String>) {
    if let Some(source) = source {
        *target = Some(source.clone());
    }
}

fn optional_single_line_env(
    environment: &BTreeMap<String, String>,
    name: &'static str,
) -> Result<Option<String>, String> {
    let Some(value) = optional_env_value(environment, name)? else {
        return Ok(None);
    };
    if value.contains('\r') || value.contains('\n') {
        return Err(format!("{name} must fit on one line"));
    }
    Ok(Some(value.to_string()))
}

fn split_allowlist(value: String) -> std::collections::BTreeSet<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .collect()
}

fn optional_u64_env(
    environment: &BTreeMap<String, String>,
    name: &'static str,
) -> Result<Option<u64>, String> {
    optional_env_value(environment, name)?
        .map(|value| {
            value
                .parse::<u64>()
                .map_err(|_| format!("{name} must be an unsigned integer"))
        })
        .transpose()
}

fn optional_usize_env(
    environment: &BTreeMap<String, String>,
    name: &'static str,
) -> Result<Option<usize>, String> {
    optional_env_value(environment, name)?
        .map(|value| {
            value
                .parse::<usize>()
                .map_err(|_| format!("{name} must be an unsigned integer"))
        })
        .transpose()
}

fn optional_env_value<'a>(
    environment: &'a BTreeMap<String, String>,
    name: &'static str,
) -> Result<Option<&'a str>, String> {
    let Some(value) = environment.get(name) else {
        return Ok(None);
    };
    let value = value.trim();
    if value.is_empty() {
        return Ok(None);
    }
    Ok(Some(value))
}

fn cgi_request_from_environment(
    environment: &BTreeMap<String, String>,
    body: Vec<u8>,
) -> Result<Request, String> {
    let method = environment
        .get("REQUEST_METHOD")
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| "REQUEST_METHOD is missing".to_string())?
        .to_string();
    let path = environment
        .get("PATH_INFO")
        .filter(|value| !value.trim().is_empty())
        .map(String::as_str)
        .unwrap_or("/");
    let target = match environment
        .get("QUERY_STRING")
        .filter(|value| !value.is_empty())
    {
        Some(query) => format!("{path}?{query}"),
        None => path.to_string(),
    };

    Ok(Request {
        method,
        target,
        headers: cgi_headers(environment),
        body,
    })
}

fn cgi_headers(environment: &BTreeMap<String, String>) -> BTreeMap<String, String> {
    let mut headers = BTreeMap::new();
    for (key, value) in environment {
        if let Some(name) = key.strip_prefix("HTTP_") {
            headers.insert(cgi_header_name(name), value.to_string());
        }
    }
    if let Some(value) = environment.get("CONTENT_TYPE") {
        headers.insert("content-type".to_string(), value.to_string());
    }
    if let Some(value) = environment.get("CONTENT_LENGTH") {
        headers.insert("content-length".to_string(), value.to_string());
    }
    if let Some(value) = environment.get("REMOTE_ADDR") {
        headers.insert("x-cosimo-client-address".to_string(), value.to_string());
    }
    if let Some(value) = environment.get("SCRIPT_NAME") {
        headers.insert("x-cosimo-script-name".to_string(), value.to_string());
    }
    if environment
        .get("HTTPS")
        .is_some_and(|value| value.eq_ignore_ascii_case("on") || value == "1")
    {
        headers.insert("x-cosimo-request-scheme".to_string(), "https".to_string());
    } else if let Some(value) = environment.get("REQUEST_SCHEME") {
        headers.insert("x-cosimo-request-scheme".to_string(), value.to_string());
    }
    headers
}

fn cgi_header_name(name: &str) -> String {
    name.to_ascii_lowercase().replace('_', "-")
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_path(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or(0);
        env::temp_dir().join(format!("cosimo-sync-cgi-test-{}-{name}", nanos))
    }

    #[test]
    fn cgi_environment_maps_method_target_query_and_headers() {
        let mut environment = BTreeMap::new();
        environment.insert("REQUEST_METHOD".to_string(), "POST".to_string());
        environment.insert(
            "PATH_INFO".to_string(),
            "/api/v1/decks/550e8400-e29b-41d4-a716-446655440000/upload".to_string(),
        );
        environment.insert("QUERY_STRING".to_string(), "kind=shared".to_string());
        environment.insert(
            "HTTP_AUTHORIZATION".to_string(),
            "Bearer cosimo_test_token".to_string(),
        );
        environment.insert("CONTENT_LENGTH".to_string(), "7".to_string());
        environment.insert("REMOTE_ADDR".to_string(), "203.0.113.10".to_string());
        environment.insert("SCRIPT_NAME".to_string(), "/test.cgi".to_string());
        environment.insert("HTTPS".to_string(), "on".to_string());

        let request = cgi_request_from_environment(&environment, b"payload".to_vec()).unwrap();

        assert_eq!(request.method, "POST");
        assert_eq!(
            request.target,
            "/api/v1/decks/550e8400-e29b-41d4-a716-446655440000/upload?kind=shared"
        );
        assert_eq!(
            request.header("authorization"),
            Some("Bearer cosimo_test_token")
        );
        assert_eq!(request.header("content-length"), Some("7"));
        assert_eq!(
            request.header("x-cosimo-client-address"),
            Some("203.0.113.10")
        );
        assert_eq!(request.header("x-cosimo-script-name"), Some("/test.cgi"));
        assert_eq!(request.header("x-cosimo-request-scheme"), Some("https"));
        assert_eq!(request.body, b"payload");
    }

    #[test]
    fn cgi_response_uses_status_header_not_http_status_line() {
        let response = Response::text(Status::Forbidden, "no");
        let bytes = response.to_cgi_bytes();
        let text = std::str::from_utf8(&bytes).unwrap();

        assert!(text.starts_with("Status: 403 Forbidden\r\n"));
        assert!(!text.starts_with("HTTP/1.1"));
        assert!(text.ends_with("\r\n\r\nno"));
    }

    #[test]
    fn cgi_front_door_errors_use_specific_http_statuses() {
        assert_eq!(
            CgiError::bad_request("bad request").into_response().status,
            Status::BadRequest
        );
        assert_eq!(
            CgiError::payload_too_large("too large")
                .into_response()
                .status,
            Status::PayloadTooLarge
        );
        assert_eq!(
            CgiError::server("server error").into_response().status,
            Status::InternalServerError
        );
    }

    #[test]
    fn config_options_parse_accepts_output_and_force() {
        let options =
            ConfigWizardOptions::parse(["sync.cgi", "--force"].into_iter().map(str::to_string))
                .unwrap();

        assert_eq!(
            options,
            ConfigWizardOptions {
                output_script: Some(PathBuf::from("sync.cgi")),
                force: true,
                help: false
            }
        );
    }

    #[test]
    fn config_wizard_writes_public_cgi_script_with_privacy_and_limits() {
        let path = temp_path("cosimo-sync-generated-public.cgi");
        let _ = fs::remove_file(&path);
        let answers = [
            "/opt/cosimo/cosimo-sync-cgi",
            "/var/lib/cosimo-sync",
            "public",
            "Example Sync Operator",
            "privacy@example.invalid",
            "",
            "",
            "rights@example.invalid",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "52428800",
            "104857600",
            "128",
            "2097152",
            "20",
            "104857600",
            "1073741824",
            "12",
            "50",
            "4",
        ]
        .join("\n")
            + "\n";
        let mut input = std::io::Cursor::new(answers.into_bytes());
        let mut output = Vec::new();

        run_config_wizard(
            ConfigWizardOptions {
                output_script: Some(path.clone()),
                force: false,
                help: false,
            },
            &mut input,
            &mut output,
        )
        .unwrap();

        let script = fs::read_to_string(&path).unwrap();
        let parsed = parse_cgi_script_config(&script).unwrap();
        assert!(script.starts_with("#!/opt/cosimo/cosimo-sync-cgi\n"));
        assert_eq!(
            parsed.storage_root,
            Some(PathBuf::from("/var/lib/cosimo-sync"))
        );
        assert_eq!(parsed.privacy.mode, Some(SyncServerServiceMode::Public));
        assert_eq!(
            parsed.privacy.controller_name,
            Some("Example Sync Operator".to_string())
        );
        assert_eq!(
            parsed.privacy.controller_contact,
            Some("privacy@example.invalid".to_string())
        );
        assert_eq!(
            parsed.privacy.rights_contact,
            Some("rights@example.invalid".to_string())
        );
        assert_eq!(parsed.privacy.notice_version, Some("1".to_string()));
        assert_eq!(parsed.limits.max_bundle_bytes, Some(52428800));
        assert_eq!(parsed.limits.max_bundle_expanded_bytes, Some(104857600));
        assert_eq!(parsed.limits.max_bundle_entries, Some(128));
        assert_eq!(parsed.limits.max_decks_per_owner, Some(20));
        let output = String::from_utf8(output).unwrap();
        assert!(output.contains("Wrote"));
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn config_writer_refuses_existing_output_without_force() {
        let path = temp_path("cosimo-sync-existing-output.cgi");
        let _ = fs::remove_file(&path);
        fs::write(&path, "old").unwrap();

        let error = write_config_script_file(&path, b"new", false).unwrap_err();

        assert!(error.contains("already exists"));
        assert_eq!(fs::read_to_string(&path).unwrap(), "old");
        let _ = fs::remove_file(&path);
    }

    #[cfg(unix)]
    #[test]
    fn config_writer_force_replaces_symlink_without_writing_through_it() {
        let link = temp_path("cosimo-sync-symlink-output.cgi");
        let target = temp_path("cosimo-sync-symlink-target.txt");
        let _ = fs::remove_file(&link);
        let _ = fs::remove_file(&target);
        fs::write(&target, "target").unwrap();
        std::os::unix::fs::symlink(&target, &link).unwrap();

        write_config_script_file(&link, b"#!/bin/sh\n", true).unwrap();

        assert_eq!(fs::read_to_string(&target).unwrap(), "target");
        assert_eq!(fs::read_to_string(&link).unwrap(), "#!/bin/sh\n");
        assert!(
            !fs::symlink_metadata(&link)
                .unwrap()
                .file_type()
                .is_symlink()
        );
        let _ = fs::remove_file(&link);
        let _ = fs::remove_file(&target);
    }

    #[test]
    fn config_wizard_writes_domestic_allowlist() {
        let path = temp_path("cosimo-sync-generated-domestic.cgi");
        let _ = fs::remove_file(&path);
        let answers = [
            "/opt/cosimo/cosimo-sync-cgi",
            "/var/lib/cosimo-sync",
            "domestic",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "@one@example.invalid, @two@example.invalid",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
            "",
        ]
        .join("\n")
            + "\n";
        let mut input = std::io::Cursor::new(answers.into_bytes());
        let mut output = Vec::new();

        run_config_wizard(
            ConfigWizardOptions {
                output_script: Some(path.clone()),
                force: false,
                help: false,
            },
            &mut input,
            &mut output,
        )
        .unwrap();

        let parsed = parse_cgi_script_config(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(parsed.privacy.mode, Some(SyncServerServiceMode::Domestic));
        assert_eq!(
            parsed.privacy.domestic_registration_allowlist,
            Some(BTreeSet::from([
                "@one@example.invalid".to_string(),
                "@two@example.invalid".to_string()
            ]))
        );
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn cgi_trace_summary_omits_sensitive_environment_values() {
        let mut environment = BTreeMap::new();
        environment.insert("REQUEST_METHOD".to_string(), "POST".to_string());
        environment.insert(
            "PATH_INFO".to_string(),
            "/api/v1/decks/deck-id/upload".to_string(),
        );
        environment.insert(
            "QUERY_STRING".to_string(),
            "access_token=query-secret".to_string(),
        );
        environment.insert(
            "HTTP_AUTHORIZATION".to_string(),
            "Bearer header-secret".to_string(),
        );
        environment.insert(
            "HTTP_COOKIE".to_string(),
            "session=cookie-secret".to_string(),
        );
        environment.insert("REMOTE_ADDR".to_string(), "203.0.113.10".to_string());
        environment.insert("CONTENT_LENGTH".to_string(), "123\n456".to_string());

        let summary = CgiTrace::request_summary(&environment);

        assert!(summary.contains("method=POST"));
        assert!(summary.contains("path=/api/v1/decks/deck-id/upload"));
        assert!(summary.contains("content_length=123 456"));
        assert!(!summary.contains("query-secret"));
        assert!(!summary.contains("header-secret"));
        assert!(!summary.contains("cookie-secret"));
        assert!(!summary.contains("203.0.113.10"));
    }

    #[test]
    fn cgi_sync_limits_are_read_from_environment() {
        let mut environment = BTreeMap::new();
        environment.insert(
            "COSIMO_SYNC_MAX_BUNDLE_BYTES".to_string(),
            "1024".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_BUNDLE_EXPANDED_BYTES".to_string(),
            "4096".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_BUNDLE_ENTRIES".to_string(),
            "12".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_DECK_FILE_BYTES".to_string(),
            "2048".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_DECKS_PER_OWNER".to_string(),
            "3".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_STORAGE_BYTES_PER_OWNER".to_string(),
            "8192".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_STORAGE_BYTES_TOTAL".to_string(),
            "16384".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_OAUTH_STARTS_PER_CLIENT_PER_HOUR".to_string(),
            "5".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_OAUTH_STARTS_PER_INSTANCE_PER_HOUR".to_string(),
            "9".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_PENDING_OAUTH_LOGINS_PER_INSTANCE".to_string(),
            "4".to_string(),
        );

        let limits = sync_limits_from_environment(&environment).unwrap();

        assert_eq!(limits.max_bundle_bytes, Some(1024));
        assert_eq!(limits.max_bundle_expanded_bytes, Some(4096));
        assert_eq!(limits.max_bundle_entries, Some(12));
        assert_eq!(limits.max_deck_file_bytes, Some(2048));
        assert_eq!(limits.max_decks_per_owner, Some(3));
        assert_eq!(limits.max_storage_bytes_per_owner, Some(8192));
        assert_eq!(limits.max_storage_bytes_total, Some(16384));
        assert_eq!(limits.max_oauth_starts_per_client_per_hour, Some(5));
        assert_eq!(limits.max_oauth_starts_per_instance_per_hour, Some(9));
        assert_eq!(limits.max_pending_oauth_logins_per_instance, Some(4));
    }

    #[test]
    fn options_parse_accepts_fossil_style_config_script_path() {
        let options = Options::parse(
            [
                "/home/user/public_html/cosimo-sync.cgi",
                "--storage-root",
                "/home/user/private/cosimo-sync",
            ]
            .into_iter()
            .map(str::to_string),
        )
        .unwrap();

        assert_eq!(
            options.config_script_path,
            Some(PathBuf::from("/home/user/public_html/cosimo-sync.cgi"))
        );
        assert_eq!(
            options.storage_root,
            Some(PathBuf::from("/home/user/private/cosimo-sync"))
        );
    }

    #[test]
    fn cgi_script_config_reads_storage_and_limits() {
        let path = unique_temp_path("config-script.cgi");
        fs::write(
            &path,
            "#!/home/user/bin/cosimo-sync-cgi\n\
             storage-root: /home/user/private/cosimo-sync\n\
             max-bundle-bytes: 1024\n\
             max-bundle-expanded-bytes: 2048\n\
             max-bundle-entries: 64\n\
             max-deck-file-bytes: 512\n\
             max-decks-per-owner: 12\n\
             max-storage-bytes-per-owner: 8192\n\
             max-storage-bytes-total: 32768\n\
             max-oauth-starts-per-client-per-hour: 5\n\
             max-oauth-starts-per-instance-per-hour: 9\n\
             max-pending-oauth-logins-per-instance: 4\n",
        )
        .unwrap();

        let config = read_cgi_script_config(&path).unwrap();
        let _ = fs::remove_file(&path);

        assert_eq!(
            config.storage_root,
            Some(PathBuf::from("/home/user/private/cosimo-sync"))
        );
        assert_eq!(config.limits.max_bundle_bytes, Some(1024));
        assert_eq!(config.limits.max_bundle_expanded_bytes, Some(2048));
        assert_eq!(config.limits.max_bundle_entries, Some(64));
        assert_eq!(config.limits.max_deck_file_bytes, Some(512));
        assert_eq!(config.limits.max_decks_per_owner, Some(12));
        assert_eq!(config.limits.max_storage_bytes_per_owner, Some(8192));
        assert_eq!(config.limits.max_storage_bytes_total, Some(32768));
        assert_eq!(config.limits.max_oauth_starts_per_client_per_hour, Some(5));
        assert_eq!(
            config.limits.max_oauth_starts_per_instance_per_hour,
            Some(9)
        );
        assert_eq!(config.limits.max_pending_oauth_logins_per_instance, Some(4));
    }

    #[test]
    fn cgi_script_config_ignores_shebang_comments_and_blank_lines() {
        let config = parse_cgi_script_config(
            "#!/home/user/bin/cosimo-sync-cgi\n\
             \n\
             # private storage is outside the web root\n\
             storage-root: /home/user/private/cosimo-sync\n",
        )
        .unwrap();

        assert_eq!(
            config.storage_root,
            Some(PathBuf::from("/home/user/private/cosimo-sync"))
        );
    }

    #[test]
    fn cgi_script_config_rejects_unknown_or_malformed_lines() {
        assert!(
            parse_cgi_script_config("storage-root /tmp/cosimo-sync\n")
                .unwrap_err()
                .contains("expected `key: value`")
        );
        assert!(
            parse_cgi_script_config("storage-root:\n")
                .unwrap_err()
                .contains("value is empty")
        );
        assert!(
            parse_cgi_script_config("unknown-key: value\n")
                .unwrap_err()
                .contains("unknown `unknown-key` setting")
        );
        assert!(
            parse_cgi_script_config("max-bundle-bytes: not-a-number\n")
                .unwrap_err()
                .contains("must be an unsigned integer")
        );
    }

    #[test]
    fn cgi_script_config_rejects_duplicate_keys() {
        assert!(
            parse_cgi_script_config(
                "storage-root: /home/user/one\n\
                 storage-root: /home/user/two\n",
            )
            .unwrap_err()
            .contains("duplicate `storage-root` setting")
        );
    }

    #[test]
    fn storage_root_precedence_is_cli_script_env_default() {
        let mut environment = BTreeMap::new();
        environment.insert(STORAGE_ROOT_ENV.to_string(), "env-root".to_string());

        assert_eq!(
            resolve_storage_root(
                Some(PathBuf::from("cli-root")),
                Some(PathBuf::from("script-root")),
                &environment,
            )
            .unwrap(),
            PathBuf::from("cli-root")
        );
        assert_eq!(
            resolve_storage_root(None, Some(PathBuf::from("script-root")), &environment).unwrap(),
            PathBuf::from("script-root")
        );
        assert_eq!(
            resolve_storage_root(None, None, &environment).unwrap(),
            PathBuf::from("env-root")
        );
    }

    #[test]
    fn cgi_limits_prefer_script_over_environment() {
        let mut environment = BTreeMap::new();
        environment.insert(
            "COSIMO_SYNC_MAX_BUNDLE_BYTES".to_string(),
            "1024".to_string(),
        );
        environment.insert(
            "COSIMO_SYNC_MAX_DECKS_PER_OWNER".to_string(),
            "3".to_string(),
        );
        let script = SyncLimitOverrides {
            max_bundle_bytes: Some(2048),
            ..SyncLimitOverrides::default()
        };

        let limits = sync_limits_from_sources(&environment, &script).unwrap();

        assert_eq!(limits.max_bundle_bytes, Some(2048));
        assert_eq!(limits.max_decks_per_owner, Some(3));
    }

    fn unique_temp_path(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("cosimo-cgi-{name}-{}-{nanos}", std::process::id()))
    }

    fn cgi_token() -> impl Strategy<Value = String> {
        "[A-Z][A-Z0-9_]{0,20}".prop_map(|value| value)
    }

    fn cgi_value() -> impl Strategy<Value = String> {
        "[A-Za-z0-9 _./:@=-]{0,40}".prop_map(|value| value)
    }

    fn cgi_path() -> impl Strategy<Value = String> {
        "(/[A-Za-z0-9._~!$&'()*+,;=:@%-]{0,20}){1,4}".prop_map(|value| value)
    }

    fn cgi_query() -> impl Strategy<Value = String> {
        "[A-Za-z0-9._~!$&'()*+,;=:@/?%-]{0,40}".prop_map(|value| value)
    }

    proptest! {
        #![proptest_config(ProptestConfig {
            cases: 96,
            failure_persistence: None,
            ..ProptestConfig::default()
        })]

        #[test]
        fn cgi_environment_request_mapping_preserves_surface_fields(
            method in "[A-Z]{1,8}",
            path in cgi_path(),
            query in cgi_query(),
            token in cgi_value(),
            headers in prop::collection::vec((cgi_token(), cgi_value()), 0..8),
            body in prop::collection::vec(any::<u8>(), 0..128),
        ) {
            let mut environment = BTreeMap::new();
            environment.insert("REQUEST_METHOD".to_string(), method.clone());
            environment.insert("PATH_INFO".to_string(), path.clone());
            environment.insert("QUERY_STRING".to_string(), query.clone());
            environment.insert("HTTP_AUTHORIZATION".to_string(), format!("Bearer {token}"));
            environment.insert("CONTENT_LENGTH".to_string(), body.len().to_string());
            for (name, value) in headers {
                if name != "AUTHORIZATION" {
                    environment.insert(format!("HTTP_{name}"), value);
                }
            }

            let request = cgi_request_from_environment(&environment, body.clone()).unwrap();
            let expected_target = if query.is_empty() {
                path
            } else {
                format!("{path}?{query}")
            };
            let content_length = body.len().to_string();
            let expected_authorization = format!("Bearer {token}");

            prop_assert_eq!(&request.method, &method);
            prop_assert_eq!(&request.target, &expected_target);
            prop_assert_eq!(&request.body, &body);
            prop_assert_eq!(
                request.header("authorization"),
                Some(expected_authorization.as_str())
            );
            prop_assert_eq!(request.header("content-length"), Some(content_length.as_str()));
            for (key, value) in &environment {
                if let Some(name) = key.strip_prefix("HTTP_") {
                    prop_assert_eq!(request.header(&cgi_header_name(name)), Some(value.as_str()));
                }
            }
        }

        #[test]
        fn cgi_header_names_are_lowercase_hyphenated(name in cgi_token()) {
            let header = cgi_header_name(&name);

            prop_assert_eq!(&header, &name.to_ascii_lowercase().replace('_', "-"));
            prop_assert!(!header.contains('_'));
            prop_assert!(header.chars().all(|character| !character.is_ascii_uppercase()));
        }
    }
}
