//! Shared helpers for the integration tests that spawn the real `frogue`
//! binary.
//!
//! They live in a `support` directory rather than next to the tests so that
//! Cargo does not try to compile this module as a test binary of its own.

#![allow(dead_code)]

pub mod ap;
use std::collections::HashSet;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::{fs, thread};

use frogue::config::*;
use miette::IntoDiagnostic;

const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);

/// The ports this process has already handed out.
///
/// A port can be handed to two tests in a row: the kernel is free to reassign
/// an ephemeral port the moment the listener that probed it is dropped. When
/// that happens the second server cannot bind and the test that asked for it
/// ends up talking to the first server instead - a different database, killed
/// while its requests are in flight. Remembering what was handed out makes
/// each call a port no other call in this process will get.
fn claimed_ports() -> &'static Mutex<HashSet<u16>> {
    static PORTS: OnceLock<Mutex<HashSet<u16>>> = OnceLock::new();
    PORTS.get_or_init(|| Mutex::new(HashSet::new()))
}

/// A port nothing is listening on and no other call in this process has been
/// given. Bound and released immediately, so the window for another process
/// stealing it before the server starts is tiny. It's a pirate life for me
pub fn free_port() -> u16 {
    loop {
        let port = TcpListener::bind("127.0.0.1:0")
            .expect("loopback should be available to pick a free port")
            .local_addr()
            .expect("bound address should parse")
            .port();

        if claimed_ports()
            .lock()
            .expect("the claimed-port set should not be poisoned")
            .insert(port)
        {
            return port;
        }
    }
}

pub struct Server {
    child: Child,
    db: PathBuf,
    database_url: String,
    /// An empty directory the binary was started in and that [`Server`] owns,
    /// when the test asked for one. Removed on drop.
    workdir: Option<PathBuf>,
    /// The port the SSH server was told to listen on.
    ssh_port: u16,
    /// The directory bare repositories were told to live under. Removed on
    /// drop, together with the host key the server generates beside it.
    repo_root: PathBuf,
    /// The public URL for federation, if set.
    public_url: Option<String>,
}

impl Server {
    /// Spawns the binary with `FROGUE_BIND_ADDR` set to `bind_addr`, and its
    /// stderr captured to a file so failures can be reported.
    pub fn spawn(bind_addr: &str) -> Self {
        Self::spawn_in(bind_addr, None, None, &[])
    }

    /// Like [`Server::spawn`], but sets additional environment variables for
    /// the spawned process.
    pub fn spawn_with_env(bind_addr: &str, env: &[(&str, &str)]) -> Self {
        Self::spawn_in(bind_addr, None, None, env)
    }

    /// Like [`Server::spawn`], but starts the binary in an empty temporary
    /// directory of its own and removes it on drop. Tests that assert the
    /// server carries what it needs inside the binary - rather than reading a
    /// directory next to wherever it happens to be started - run the process
    /// somewhere that directory does not exist.
    pub fn spawn_elsewhere(bind_addr: &str) -> Self {
        let workdir = std::env::temp_dir().join(format!("frogue-test-cwd-{}", free_port()));
        fs::create_dir_all(&workdir).expect("temporary working directory should be creatable");

        Self::spawn_in(bind_addr, Some(workdir), None, &[])
    }

    /// Spawns the server with federation enabled, setting `FROGUE_PUBLIC_URL`
    /// to `http://{bind_addr}`.
    pub fn spawn_federated(bind_addr: &str) -> Self {
        let public_url = format!("http://{bind_addr}");
        Self::spawn_in(
            bind_addr,
            None,
            Some(public_url),
            &[(FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY, "1")],
        )
    }

    fn spawn_in(
        bind_addr: &str,
        workdir: Option<PathBuf>,
        public_url: Option<String>,
        env: &[(&str, &str)],
    ) -> Self {
        // A database of its own, so the tests never touch a running dev
        // server's database.
        let db = std::env::temp_dir().join(format!("frogue-test-{}.sqlite", free_port()));
        let database_url = format!("sqlite://{}?mode=rwc", db.display());

        // A port and a data directory of its own: tests run in parallel, and
        // the SSH server's default port would otherwise be claimed twice. The
        // repositories go in a subdirectory so the host key the server
        // generates beside them is also per-test.
        let ssh_port = free_port();
        let data = std::env::temp_dir().join(format!("frogue-test-data-{}", free_port()));
        let repo_root = data.join("repos");
        fs::create_dir_all(&repo_root).expect("temporary repository root should be creatable");

        let mut command = Command::new(env!("CARGO_BIN_EXE_frogue"));
        command
            .env(HTTP_BIND_ADDR_VAR_KEY, bind_addr)
            .env(DATABASE_URL_VAR_KEY, &database_url)
            .env(SSH_BIND_ADDR_VAR_KEY, format!("127.0.0.1:{ssh_port}"))
            .env(REPO_ROOT_VAR_KEY, &repo_root)
            .env("RUST_LOG", "frogue=debug,actix-web=info,sqlx=info,warn");
        // .stdout(Stdio::inherit())
        // .stderr(Stdio::inherit());
        if let Some(public_url) = &public_url {
            command.env(FROGUE_PUBLIC_URL_VAR_KEY, public_url);
        }
        for (key, value) in env {
            command.env(key, value);
        }
        if let Some(workdir) = &workdir {
            command.current_dir(workdir);
        }

        let child = command.spawn().expect("frogue binary should be spawnable");

        Server {
            child,
            db,
            database_url,
            workdir,
            ssh_port,
            repo_root,
            public_url,
        }
    }

    /// The `DATABASE_URL` the server was started with.
    pub fn database_url(&self) -> &str {
        &self.database_url
    }

    /// The port the SSH server listens on, for `git` to be pointed at.
    pub fn ssh_port(&self) -> u16 {
        self.ssh_port
    }

    /// The directory bare repositories live under.
    pub fn repo_root(&self) -> &Path {
        &self.repo_root
    }

    /// The public URL for federation, if set.
    pub fn public_url(&self) -> Option<&str> {
        self.public_url.as_deref()
    }

    /// Waits until the server accepts a TCP connection on `port`.
    pub fn wait_for_connection(&mut self, port: u16) {
        let deadline = Instant::now() + STARTUP_TIMEOUT;
        loop {
            if TcpStream::connect(("127.0.0.1", port)).is_ok() {
                return;
            }
            if let Some(status) = self.exited() {
                panic!("frogue exited before listening on port {port}: {status}");
            }
            if Instant::now() >= deadline {
                panic!("frogue never listened on port {port} within {STARTUP_TIMEOUT:?}");
            }
            thread::sleep(Duration::from_millis(50));
        }
    }

    /// Waits for the process to exit on its own and returns its status.
    pub fn wait_for_exit(&mut self) -> ExitStatus {
        let deadline = Instant::now() + STARTUP_TIMEOUT;
        loop {
            if let Some(status) = self.exited() {
                return status;
            }
            if Instant::now() >= deadline {
                panic!(
                    "frogue was still running after {STARTUP_TIMEOUT:?} instead of failing fast"
                );
            }
            thread::sleep(Duration::from_millis(50));
        }
    }

    fn exited(&mut self) -> Option<ExitStatus> {
        self.child.try_wait().expect("wait on frogue child")
    }
}

impl Drop for Server {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        remove_database(&self.db);
        if let Some(workdir) = &self.workdir {
            let _ = fs::remove_dir_all(workdir);
        }
        // The whole data directory, not just the repositories: the host key
        // that was generated beside them goes with it.
        if let Some(data) = self.repo_root.parent() {
            let _ = fs::remove_dir_all(data);
        }
    }
}

/// Removes a SQLite database and the auxiliary files it may have created.
fn remove_database(db: &Path) {
    let _ = fs::remove_file(db);
    for suffix in ["-wal", "-shm", "-journal"] {
        let mut aux = db.as_os_str().to_owned();
        aux.push(suffix);
        let _ = fs::remove_file(PathBuf::from(aux));
    }
}

/// What a single request to the server answered with.
pub struct Response {
    pub status: u16,
    pub content_type: Option<String>,
    pub location: Option<String>,
    pub body: String,
    set_cookies: Vec<String>,
    headers: Vec<(String, String)>,
}

impl Response {
    /// The `Set-Cookie` headers the server sent, for the tests that care why
    /// no session appeared.
    pub fn set_cookies(&self) -> &[String] {
        &self.set_cookies
    }

    /// The value of `name`, looked up the way HTTP field names compare -
    /// case insensitively - for the tests that care about a header the
    /// struct does not name on its own, a `Cache-Control` or an `ETag`.
    /// The first of any repeated headers wins, as the first is the one a
    /// client acts on.
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(header, _)| header.eq_ignore_ascii_case(name))
            .map(|(_, value)| value.as_str())
    }

    /// Whether the response set `name` to a non-empty value, which is how the
    /// server signals a session was established.
    pub fn sets_cookie(&self, name: &str) -> bool {
        self.set_cookies.iter().any(|cookie| {
            cookie
                .split_once('=')
                .is_some_and(|(key, value)| key.trim() == name && !value.trim().is_empty())
        })
    }
}

/// A connection to the server that keeps the cookies it is given, the way a
/// browser would. Auth is carried in a cookie, so the tests need one.
pub struct Client {
    port: u16,
    cookies: Vec<(String, String)>,
}

impl Client {
    pub fn new(port: u16) -> Self {
        Client {
            port,
            cookies: Vec::new(),
        }
    }

    pub fn get(&mut self, path: &str) -> Response {
        self.send(&format!("GET {path} HTTP/1.1"), None, &[])
    }

    /// `GET`s `path` with extra request headers, for the tests that need to
    /// send one the default set does not carry - an `Accept-Language`, say.
    pub fn get_with(&mut self, path: &str, headers: &[(&str, &str)]) -> Response {
        self.send(&format!("GET {path} HTTP/1.1"), None, headers)
    }

    /// `GET`s `path` with `Accept: application/activity+json` for ActivityPub
    /// content negotiation.
    pub fn get_with_accept(&mut self, path: &str) -> Response {
        self.get_with(path, &[("Accept", "application/activity+json")])
    }

    /// `GET`s `path` with `Accept: application/activity+json` and parses the
    /// JSON response.
    pub fn get_ap(&mut self, path: &str) -> miette::Result<serde_json::Value> {
        let response = self.get_with_accept(path);
        let body = &response.body;
        serde_json::from_str(body).into_diagnostic()
    }

    /// `POST`s `fields` as an `application/x-www-form-urlencoded` body.
    pub fn post_form(&mut self, path: &str, fields: &[(&str, &str)]) -> Response {
        let body = fields
            .iter()
            .map(|(name, value)| format!("{}={}", urlencode(name), urlencode(value)))
            .collect::<Vec<_>>()
            .join("&");

        self.send(
            &format!("POST {path} HTTP/1.1"),
            Some(body),
            &[("Content-Type", "application/x-www-form-urlencoded")],
        )
    }

    /// `POST`s `body` as an `application/json` body with extra headers.
    pub fn post_json(&mut self, path: &str, body: &str, headers: &[(&str, &str)]) -> Response {
        let mut all_headers = vec![("Content-Type", "application/json")];
        all_headers.extend_from_slice(headers);
        self.send_with_bytes(
            &format!("POST {path} HTTP/1.1"),
            Some(body.as_bytes().to_vec()),
            &all_headers,
        )
    }

    /// `POST`s `body` as JSON with fully custom headers (no default
    /// Content-Type).
    pub fn post_json_with_headers(
        &mut self,
        path: &str,
        body: &str,
        headers: &[(&str, &str)],
    ) -> Response {
        self.send_with_bytes(
            &format!("POST {path} HTTP/1.1"),
            Some(body.as_bytes().to_vec()),
            headers,
        )
    }

    /// Posts a multipart form with both text fields and file attachments.
    pub fn post_multipart(
        &mut self,
        path: &str,
        fields: &[(&str, &str)],
        files: &[(&str, &str, &str, Vec<u8>)],
    ) -> Response {
        let boundary = "----TestBoundary7MA4YWxkTrZu0gW";
        let mut body = Vec::new();

        // Write text fields
        for (name, value) in fields {
            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
            body.extend_from_slice(
                format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
            );
            body.extend_from_slice(value.as_bytes());
            body.extend_from_slice("\r\n".as_bytes());
        }

        // Write file fields
        for (name, filename, content_type, data) in files {
            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
            body.extend_from_slice(
                format!(
                    "Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n"
                )
                .as_bytes(),
            );
            body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
            body.extend_from_slice(data);
            body.extend_from_slice("\r\n".as_bytes());
        }

        // Final boundary
        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());

        self.send_with_bytes(
            &format!("POST {path} HTTP/1.1"),
            Some(body),
            &[(
                "Content-Type",
                &format!("multipart/form-data; boundary={boundary}"),
            )],
        )
    }

    /// Posts a multipart form with a file field.
    pub fn post_multipart_file(
        &mut self,
        path: &str,
        field_name: &str,
        filename: &str,
        content_type: &str,
        data: &[u8],
    ) -> Response {
        let boundary = "----TestBoundary7MA4YWxkTrZu0gW";
        let mut body = Vec::new();

        // Write the file field
        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
        body.extend_from_slice(
            format!(
                "Content-Disposition: form-data; name=\"{field_name}\"; filename=\"{filename}\"\r\n"
            )
            .as_bytes(),
        );
        body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
        body.extend_from_slice(data);
        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());

        self.send_with_bytes(
            &format!("POST {path} HTTP/1.1"),
            Some(body), // once told me
            &[(
                "Content-Type",
                &format!("multipart/form-data; boundary={boundary}"),
            )],
        )
    }

    fn send(
        &mut self,
        request_line: &str,
        body: Option<String>,
        headers: &[(&str, &str)],
    ) -> Response {
        let mut stream =
            TcpStream::connect(("127.0.0.1", self.port)).expect("server should accept the request");

        let mut request = format!(
            "{request_line}\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\nAccept-Encoding: identity\r\n",
            self.port
        );
        for (name, value) in headers {
            request.push_str(&format!("{name}: {value}\r\n"));
        }
        if !self.cookies.is_empty() {
            let jar = self
                .cookies
                .iter()
                .map(|(name, value)| format!("{name}={value}"))
                .collect::<Vec<_>>()
                .join("; ");
            request.push_str(&format!("Cookie: {jar}\r\n"));
        }
        if let Some(body) = &body {
            request.push_str(&format!(
                "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n",
                body.len()
            ));
        }
        request.push_str("\r\n");
        if let Some(body) = &body {
            request.push_str(body);
        }

        stream
            .write_all(request.as_bytes())
            .expect("request should be sent to the server");

        let mut raw = String::new();
        stream
            .read_to_string(&mut raw)
            .expect("server should answer with UTF-8");

        let Some((head, body)) = raw.split_once("\r\n\r\n") else {
            // No separator means the server sent nothing, or closed before it
            // had: name the bytes so a failure says which.
            panic!("response should separate headers from the body, but the server sent:\n{raw:?}");
        };

        let mut lines = head.lines();
        let status = lines
            .next()
            .expect("response should start with a status line")
            .split_whitespace()
            .nth(1)
            .expect("status line should hold a status code")
            .parse()
            .expect("status code should be a number");

        let mut content_type = None;
        let mut location = None;
        let mut set_cookies = Vec::new();
        let mut headers = Vec::new();
        for line in lines {
            let Some((name, value)) = line.split_once(':') else {
                continue;
            };
            let value = value.trim().to_string();
            headers.push((name.trim().to_string(), value.clone()));
            match name.to_ascii_lowercase().as_str() {
                "content-type" => content_type = Some(value),
                "location" => location = Some(value),
                "set-cookie" => set_cookies.push(value),
                _ => {}
            }
        }

        self.absorb_cookies(&set_cookies);

        Response {
            status,
            content_type,
            location,
            body: body.to_string(),
            set_cookies,
            headers,
        }
    }

    fn send_with_bytes(
        &mut self,
        request_line: &str,
        body: Option<Vec<u8>>,
        headers: &[(&str, &str)],
    ) -> Response {
        let mut stream =
            TcpStream::connect(("127.0.0.1", self.port)).expect("server should accept the request");

        let mut request = format!(
            "{request_line}\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\nAccept-Encoding: identity\r\n",
            self.port
        );
        for (name, value) in headers {
            request.push_str(&format!("{name}: {value}\r\n"));
        }
        if !self.cookies.is_empty() {
            let jar = self
                .cookies
                .iter()
                .map(|(name, value)| format!("{name}={value}"))
                .collect::<Vec<_>>()
                .join("; ");
            request.push_str(&format!("Cookie: {jar}\r\n"));
        }
        if let Some(body) = &body {
            request.push_str(&format!("Content-Length: {}\r\n", body.len()));
        }
        request.push_str("\r\n");

        // Write the header as bytes
        stream
            .write_all(request.as_bytes())
            .expect("request headers should be sent to the server");

        // Write the body as bytes (if present)
        if let Some(body) = &body {
            stream
                .write_all(body)
                .expect("request body should be sent to the server");
        }

        let mut raw = Vec::new();
        stream.read_to_end(&mut raw).expect("server should answer");

        let raw_str = String::from_utf8_lossy(&raw).to_string();
        let Some((head, body)) = raw_str.split_once("\r\n\r\n") else {
            // No separator means the server sent nothing, or closed before it
            // had: name the bytes so a failure says which.
            panic!(
                "response should separate headers from the body, but the server sent:\n{raw_str:?}"
            );
        };

        let mut lines = head.lines();
        let status = lines
            .next()
            .expect("response should start with a status line")
            .split_whitespace()
            .nth(1)
            .expect("status line should hold a status code")
            .parse()
            .expect("status code should be a number");

        let mut content_type = None;
        let mut location = None;
        let mut set_cookies = Vec::new();
        let mut headers = Vec::new();
        for line in lines {
            let Some((name, value)) = line.split_once(':') else {
                continue;
            };
            let value = value.trim().to_string();
            headers.push((name.trim().to_string(), value.clone()));
            match name.to_ascii_lowercase().as_str() {
                "content-type" => content_type = Some(value),
                "location" => location = Some(value),
                "set-cookie" => set_cookies.push(value),
                _ => {}
            }
        }

        self.absorb_cookies(&set_cookies);

        Response {
            status,
            content_type,
            location,
            body: body.to_string(),
            set_cookies,
            headers,
        }
    }

    /// Keeps the cookies the server sent, replacing any of the same name, the
    /// way a browser's jar does. Only the `name=value` part is kept; the
    /// attributes after the first `;` say nothing about identity.
    fn absorb_cookies(&mut self, set_cookies: &[String]) {
        for cookie in set_cookies {
            let Some((name, rest)) = cookie.split_once('=') else {
                continue;
            };
            let name = name.trim().to_string();
            let value = rest.split(';').next().unwrap_or("").trim().to_string();

            self.cookies.retain(|(existing, _)| existing != &name);
            if !value.is_empty() {
                self.cookies.push((name, value));
            }
        }
    }
}

/// Sends a lone `GET` and returns the answer.
///
/// A hand-rolled request rather than an HTTP client dependency: the tests
/// only ever need plain requests, and `Connection: close` plus
/// `Accept-Encoding: identity` keeps the answer simple enough to read
/// straight off the socket - a header block, then a body delimited by the
/// server closing the connection.
pub fn get(port: u16, path: &str) -> Response {
    Client::new(port).get(path)
}

/// Percent-encodes a form field, keeping the unreserved characters plain.
fn urlencode(value: &str) -> String {
    let mut encoded = String::new();
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(byte as char);
            }
            b' ' => encoded.push('+'),
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    encoded
}
