//! Auth bridge: artifacts that let an auth-enabled service talk to Caddy+Authelia.
//!
//! Services with `--auth` need to reach Authelia for OIDC. When Caddy is
//! handling HTTPS with a self-signed cert, the service container must trust
//! that cert and be able to resolve the auth provider's hostname to Caddy's
//! (dynamic) container IP.
//!
//! This module is a pure builder: it reads probe files under `/etc/ssl/certs`
//! and constructs [`Step::WriteFile`] entries for the caller to execute. It
//! performs no writes itself, so planning remains side-effect-free and
//! respects `--dry-run`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::Step;
use crate::capability::{Capability, find_installed_provider};
use crate::config::schema::Config;
use crate::error::{Error, Result};
use crate::generate::GeneratedFile;

/// System CA bundle locations probed in order. First hit wins.
const SYSTEM_CA_PATHS: &[&str] = &[
    "/etc/ssl/certs/ca-certificates.crt",
    "/etc/pki/tls/certs/ca-bundle.crt",
];

/// Quadlet + filesystem artifacts a caller should merge into the service's
/// generated unit and execute as part of the install plan.
pub struct AuthBridge {
    /// Extra `Volume=` entries for the service's `.container` unit.
    pub volumes: Vec<String>,
    /// Extra env vars for the service's `.env` file (CA trust for Python/Node).
    pub env: BTreeMap<String, String>,
    /// Extra `ExecStartPre=` entries for the service's `.container` unit.
    pub exec_start_pre: Vec<String>,
    /// Files to write before the service starts (CA bundle + helper scripts).
    pub steps: Vec<Step>,
}

/// Inputs to [`build`].
pub struct AuthBridgeParams<'a> {
    pub service_name: &'a str,
    /// Capabilities declared by the currently-installing service. The
    /// bridge skips itself when the caller is the OIDC provider or the
    /// reverse proxy — those services don't consume themselves.
    pub service_provides: &'a [Capability],
    pub enable_auth: bool,
    pub config: &'a Config,
    /// Snapshot of installed services. Production callers pass
    /// [`crate::list_installed`]; tests construct synthetic data so
    /// they can drive the function without writing real quadlet files.
    pub installed: &'a [crate::config::schema::InstalledService],
    /// Absolute path to the service's data dir (`~/.local/share/services/<name>`).
    pub service_data: &'a Path,
}

/// Build auth-bridge artifacts for a service. Returns `Ok(None)` when the
/// bridge does not apply — the caller isn't using auth, the service is
/// authelia/caddy itself, authelia/Caddy aren't yet installed, or authelia
/// has no URL (a Loopback exposure can't host OIDC).
///
/// The bridge routes the OIDC back-channel through Caddy as the internal TLS
/// terminator regardless of how the *browser* reaches authelia: Caddy on a
/// `*.internal` host, `tailscale serve` on a `*.ts.net` host, or an external
/// proxy on a public domain. In every case the service container reaches
/// authelia at its issuer host via Caddy (network alias + dynamic
/// `/etc/hosts`) and trusts Caddy's self-signed CA through the mounted
/// bundle. Only a Loopback authelia is excluded — it has no issuer URL.
pub fn build(params: &AuthBridgeParams<'_>) -> Result<Option<AuthBridge>> {
    if !params.enable_auth {
        return Ok(None);
    }
    // The bridge wires consumers to providers — providers themselves
    // (the OIDC IdP, the reverse proxy) don't need it.
    if params.service_provides.contains(&Capability::OidcProvider)
        || params.service_provides.contains(&Capability::ReverseProxy)
    {
        return Ok(None);
    }
    let Some(authelia) = find_installed_provider(params.installed, Capability::OidcProvider) else {
        return Ok(None);
    };
    // The bridge keys the back-channel routing on authelia's issuer host
    // (network alias + dynamic `/etc/hosts`). Without a URL that parses to a
    // host there's nothing to route, so bail: a Loopback authelia has no URL,
    // and a malformed one can't be resolved. Internal / Tailscale / Public
    // all carry a host and route the same way.
    let has_auth_host = authelia
        .exposure
        .url()
        .and_then(|u| url::Url::parse(u).ok())
        .and_then(|u| u.host_str().map(str::to_string))
        .is_some();
    if !has_auth_host {
        return Ok(None);
    }
    // Caddy is the internal TLS terminator for the back-channel, so it must
    // be installed. `add_service` turns a missing reverse proxy into a loud
    // error (Error::AuthRequiresReverseProxy) before reaching this point —
    // returning None here is the belt-and-suspenders path for callers that
    // build the bridge without that pre-check.
    if find_installed_provider(params.installed, Capability::ReverseProxy).is_none() {
        return Ok(None);
    }

    let ryra_dir: PathBuf = params
        .service_data
        .parent()
        .ok_or_else(|| Error::Bundle("service data dir has no parent directory".into()))?
        .to_path_buf();

    let merged_bundle = params.service_data.join("ca-bundle.crt");
    let refresh_ca_script = params.service_data.join("refresh-ca-bundle.sh");
    let auth_host_script = params.service_data.join("resolve-auth-host.sh");
    let auth_hosts = params.service_data.join("auth-hosts.txt");

    let mut volumes = Vec::new();
    let mut env = BTreeMap::new();
    let mut exec_start_pre = Vec::new();
    let mut steps = Vec::new();

    // --- CA bundle: system CAs + caddy's self-signed CA (if already present) ---
    //
    // Reading from /etc/ssl/certs is a read-only probe. If no system bundle is
    // found, we start with an empty string — the refresh-ca-bundle.sh hook
    // rebuilds it each start. If caddy's CA isn't on disk yet (caddy is being
    // installed alongside), refresh-ca-bundle.sh will pick it up at first
    // start. Either way, the placeholder is safe.
    let ca_cert_host = ryra_dir.join("caddy-root-ca.crt");
    let mut bundle = String::new();
    for sys_path in SYSTEM_CA_PATHS {
        if let Ok(content) = std::fs::read_to_string(sys_path) {
            bundle = content;
            break;
        }
    }
    if let Ok(caddy_ca) = std::fs::read_to_string(&ca_cert_host) {
        bundle.push_str("\n# services-caddy-ca\n");
        bundle.push_str(&caddy_ca);
    }
    steps.push(Step::WriteFile(GeneratedFile {
        path: merged_bundle.clone(),
        content: bundle,
    }));
    volumes.push(format!(
        "{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
        merged_bundle.display()
    ));
    // Python (requests/certifi) and Node don't honour the system CA store —
    // they need explicit env vars.
    for var in ["REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"] {
        env.insert(var.into(), "/etc/ssl/certs/ca-certificates.crt".into());
    }

    // --- refresh-ca-bundle.sh: rebuild bundle at each service start ---
    let refresh_script = render_refresh_ca_script(&ryra_dir, params.service_data);
    steps.push(Step::WriteFile(GeneratedFile {
        path: refresh_ca_script.clone(),
        content: refresh_script,
    }));
    exec_start_pre.push(format!("-/bin/bash {}", refresh_ca_script.display()));

    // --- resolve-auth-host.sh: dynamic /etc/hosts for auth domain ---
    //
    // The auth provider's hostname (typically an ICANN `.internal` address)
    // isn't resolvable via normal DNS, so we write a small hosts file at
    // service-start time mapping it to caddy's current container IP and
    // bind-mount that over /etc/hosts.
    if let Some(auth_url) = authelia.exposure.url()
        && let Ok(parsed) = url::Url::parse(auth_url)
        && let Some(host) = parsed.host_str()
    {
        let resolve_script = render_resolve_auth_host_script(params.service_data, host);
        steps.push(Step::WriteFile(GeneratedFile {
            path: auth_host_script.clone(),
            content: resolve_script,
        }));
        steps.push(Step::WriteFile(GeneratedFile {
            path: auth_hosts.clone(),
            content: format!("127.0.0.1 {host}\n"),
        }));
        exec_start_pre.push(format!("-/bin/bash {}", auth_host_script.display()));
        volumes.push(format!("{}:/etc/hosts:z", auth_hosts.display()));
    }

    Ok(Some(AuthBridge {
        volumes,
        env,
        exec_start_pre,
        steps,
    }))
}

fn render_refresh_ca_script(ryra_dir: &Path, service_data: &Path) -> String {
    format!(
        "#!/bin/bash\n\
         CADDY_CA=\"{ryra_dir}/caddy-root-ca.crt\"\n\
         MERGED=\"{service_data}/ca-bundle.crt\"\n\
         [ -f \"$CADDY_CA\" ] || exit 0\n\
         for f in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt; do\n\
           if [ -f \"$f\" ]; then cp \"$f\" \"$MERGED\"; break; fi\n\
         done\n\
         cat \"$CADDY_CA\" >> \"$MERGED\" 2>/dev/null || true\n\
         exit 0\n",
        ryra_dir = ryra_dir.display(),
        service_data = service_data.display(),
    )
}

fn render_resolve_auth_host_script(service_data: &Path, host: &str) -> String {
    // `timeout 5` guards against a wedged podman socket: this runs in
    // ExecStartPre, and without the guard a hung podman blocks service
    // startup indefinitely. On timeout we fall through to 127.0.0.1 —
    // OIDC won't work, but the service comes up instead of hanging.
    format!(
        "#!/bin/bash\n\
         # Resolve caddy's current IP for the auth domain\n\
         HOSTS=\"{service_data}/auth-hosts.txt\"\n\
         CADDY_IP=$(timeout 5 podman inspect caddy --format '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' 2>/dev/null | awk '{{print $1}}')\n\
         if [ -n \"$CADDY_IP\" ]; then\n\
           echo \"$CADDY_IP {host}\" > \"$HOSTS\"\n\
         else\n\
           echo \"127.0.0.1 {host}\" > \"$HOSTS\"\n\
         fi\n\
         exit 0\n",
        service_data = service_data.display(),
        host = host,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::schema::{AuthCredentials, InstalledService};
    use std::collections::BTreeMap;

    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;

    /// Capability list a known-by-name service in these tests provides.
    /// Used to populate both `InstalledService::provides` and
    /// `AuthBridgeParams::service_provides` without depending on the
    /// default registry being cloned in tempdirs.
    fn provides_for(name: &str) -> &'static [Capability] {
        match name {
            "authelia" => &[Capability::OidcProvider, Capability::ForwardAuthProvider],
            "caddy" => &[Capability::ReverseProxy],
            _ => &[],
        }
    }

    fn installed(name: &str, url: Option<&str>) -> InstalledService {
        let exposure = match url {
            Some(u) => crate::Exposure::from_url(u),
            None => crate::Exposure::Loopback,
        };
        InstalledService {
            name: name.into(),
            version: "0.1.0".into(),
            repo: "default".into(),
            ports: BTreeMap::new(),
            auth_kind: None,
            exposure,
            provides: provides_for(name).to_vec(),
            installed: true,
        }
    }

    /// Build a (Config, installed-list) pair so tests can drive `build()`
    /// without writing real quadlet files. The `services` list is now
    /// passed alongside `config`, not embedded in it.
    fn fixture(
        services: Vec<InstalledService>,
        auth: Option<AuthCredentials>,
    ) -> (Config, Vec<InstalledService>) {
        let cfg = Config {
            auth,
            ..Config::default()
        };
        (cfg, services)
    }

    fn write_paths(bridge: &AuthBridge) -> Vec<&Path> {
        bridge
            .steps
            .iter()
            .filter_map(|s| match s {
                Step::WriteFile(f) => Some(f.path.as_path()),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn returns_none_when_auth_disabled() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![installed("authelia", Some("https://auth.internal"))],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: false,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn returns_none_when_authelia_not_installed() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(vec![installed("caddy", None)], None);
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn returns_none_when_caddy_not_installed() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![installed("authelia", Some("https://auth.internal"))],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn returns_none_for_authelia_itself() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![
                installed("authelia", Some("https://auth.internal")),
                installed("caddy", None),
            ],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "authelia",
            service_provides: provides_for("authelia"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn builds_for_tailscale_authelia_url() -> TestResult {
        // Authelia exposed via `tailscale serve` (`*.ts.net`) still routes
        // the OIDC back-channel through Caddy as the internal TLS
        // terminator, so the bridge applies and writes the same CA-trust +
        // host-resolve artifacts, keyed on the ts.net hostname.
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.example.ts.net"))?;

        let hosts_step = bridge
            .steps
            .iter()
            .find_map(|s| match s {
                Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
                _ => None,
            })
            .ok_or("auth-hosts.txt step missing")?;
        assert_eq!(hosts_step.content, "127.0.0.1 auth.example.ts.net\n");
        Ok(())
    }

    #[test]
    fn returns_none_for_authelia_url_without_host() -> TestResult {
        // Defensive: a URL that fails to parse or lacks a host shouldn't
        // crash the builder — it should bail cleanly.
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![
                installed("authelia", Some("not-a-url")),
                installed("caddy", None),
            ],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn returns_none_for_caddy_itself() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![
                installed("authelia", Some("https://auth.internal")),
                installed("caddy", None),
            ],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "caddy",
            service_provides: provides_for("caddy"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn build_does_not_write_to_service_data() -> TestResult {
        // The whole point of this refactor: a pure planning call must not
        // touch the service's data dir.
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        std::fs::create_dir_all(&service_data)?;

        let (cfg, installed) = fixture(
            vec![
                installed("authelia", Some("https://auth.internal")),
                installed("caddy", None),
            ],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: &service_data,
        })?;
        assert!(out.is_some());

        let entries: Vec<_> = std::fs::read_dir(&service_data)?.collect();
        assert!(
            entries.is_empty(),
            "build() must not write to service_data, found: {entries:?}"
        );
        Ok(())
    }

    fn build_forgejo_bridge(service_data: &Path, authelia_url: Option<&str>) -> Result<AuthBridge> {
        let (cfg, installed) = fixture(
            vec![
                installed("authelia", authelia_url),
                installed("caddy", None),
            ],
            None,
        );
        build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data,
        })?
        .ok_or_else(|| {
            Error::Bundle(
                "auth bridge unexpectedly returned None for forgejo + authelia + caddy".into(),
            )
        })
    }

    #[test]
    fn emits_expected_write_file_steps() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;

        let paths = write_paths(&bridge);
        assert!(paths.contains(&service_data.join("ca-bundle.crt").as_path()));
        assert!(paths.contains(&service_data.join("refresh-ca-bundle.sh").as_path()));
        assert!(paths.contains(&service_data.join("resolve-auth-host.sh").as_path()));
        assert!(paths.contains(&service_data.join("auth-hosts.txt").as_path()));
        Ok(())
    }

    #[test]
    fn returns_none_when_authelia_has_no_url() -> TestResult {
        // Bridge dispatch reads authelia's URL hostname; without one, ryra
        // can't tell if the deployment is Caddy-local or something else, so
        // it bails rather than guessing.
        let tmp = tempfile::tempdir()?;
        let (cfg, installed) = fixture(
            vec![installed("authelia", None), installed("caddy", None)],
            None,
        );
        let out = build(&AuthBridgeParams {
            service_name: "forgejo",
            service_provides: provides_for("forgejo"),
            enable_auth: true,
            config: &cfg,
            installed: &installed,
            service_data: tmp.path(),
        })?;
        assert!(out.is_none());
        Ok(())
    }

    #[test]
    fn emits_ca_trust_volume_and_env() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;

        let bundle_mount = format!(
            "{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
            service_data.join("ca-bundle.crt").display()
        );
        assert!(bridge.volumes.contains(&bundle_mount));
        assert_eq!(
            bridge.env.get("REQUESTS_CA_BUNDLE").map(String::as_str),
            Some("/etc/ssl/certs/ca-certificates.crt")
        );
        assert_eq!(
            bridge.env.get("SSL_CERT_FILE").map(String::as_str),
            Some("/etc/ssl/certs/ca-certificates.crt")
        );
        assert_eq!(
            bridge.env.get("NODE_EXTRA_CA_CERTS").map(String::as_str),
            Some("/etc/ssl/certs/ca-certificates.crt")
        );
        Ok(())
    }

    #[test]
    fn auth_hosts_contains_authelia_hostname() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        // *.internal is the bridge's domain — non-internal URLs fall
        // outside Caddy-local dispatch and don't get a bridge.
        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;

        let hosts_step = bridge
            .steps
            .iter()
            .find_map(|s| match s {
                Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
                _ => None,
            })
            .ok_or("auth-hosts.txt step missing")?;
        assert_eq!(hosts_step.content, "127.0.0.1 auth.internal\n");
        Ok(())
    }

    #[test]
    fn exec_start_pre_references_emitted_scripts() -> TestResult {
        let tmp = tempfile::tempdir()?;
        let service_data = tmp.path().join("forgejo");
        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;

        let refresh = format!(
            "-/bin/bash {}",
            service_data.join("refresh-ca-bundle.sh").display()
        );
        let resolve = format!(
            "-/bin/bash {}",
            service_data.join("resolve-auth-host.sh").display()
        );
        assert!(bridge.exec_start_pre.contains(&refresh));
        assert!(bridge.exec_start_pre.contains(&resolve));
        Ok(())
    }
}
