#![cfg_attr(not(test), allow(dead_code))]

use crate::config::NetworkProxyConfig;
use crate::policy::normalize_host;
use anyhow::Context as _;
use anyhow::Result;
use anyhow::anyhow;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobBuilder;
use globset::GlobMatcher;
use rama_http::HeaderValue;
use rama_http::Request;
use rama_http::header::HeaderName;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;
use url::form_urlencoded;

const PATTERN_PREFIX: &str = "pattern:";
const LITERAL_PREFIX: &str = "literal:";

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct MitmHookConfig {
    pub host: String,
    #[serde(rename = "match", default)]
    pub matcher: MitmHookMatchConfig,
    #[serde(default)]
    pub actions: MitmHookActionsConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct MitmHookMatchConfig {
    pub methods: Vec<String>,
    pub path_prefixes: Vec<String>,
    pub query: BTreeMap<String, Vec<String>>,
    pub headers: BTreeMap<String, Vec<String>>,
    pub body: Option<MitmHookBodyConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct MitmHookActionsConfig {
    pub strip_request_headers: Vec<String>,
    pub inject_request_headers: Vec<InjectedHeaderConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct InjectedHeaderConfig {
    pub name: String,
    pub secret_env_var: Option<String>,
    pub secret_file: Option<String>,
    pub prefix: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct MitmHookBodyConfig(pub serde_json::Value);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHook {
    pub host: String,
    pub matcher: MitmHookMatcher,
    pub actions: MitmHookActions,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHookMatcher {
    pub methods: Vec<String>,
    pub path_prefixes: Vec<PathMatcher>,
    pub query: Vec<QueryConstraint>,
    pub headers: Vec<HeaderConstraint>,
    pub body: Option<MitmHookBodyMatcher>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryConstraint {
    pub name: String,
    pub allowed_values: Vec<ValueMatcher>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeaderConstraint {
    pub name: HeaderName,
    pub allowed_values: Vec<ValueMatcher>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHookActions {
    pub strip_request_headers: Vec<HeaderName>,
    pub inject_request_headers: Vec<ResolvedInjectedHeader>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedInjectedHeader {
    pub name: HeaderName,
    pub value: HeaderValue,
    pub source: SecretSource,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretSource {
    EnvVar(String),
    File(AbsolutePathBuf),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHookBodyMatcher {
    pub raw: serde_json::Value,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathMatcher {
    Prefix(String),
    Glob(CompiledGlobMatcher),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValueMatcher {
    Exact(String),
    Glob(CompiledGlobMatcher),
}

enum MatcherPattern<'a> {
    Literal(&'a str),
    Glob(&'a str),
}

#[derive(Clone)]
pub struct CompiledGlobMatcher {
    pattern: String,
    matcher: GlobMatcher,
}

impl std::fmt::Debug for CompiledGlobMatcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CompiledGlobMatcher")
            .field("pattern", &self.pattern)
            .finish()
    }
}

impl PartialEq for CompiledGlobMatcher {
    fn eq(&self, other: &Self) -> bool {
        self.pattern == other.pattern
    }
}

impl Eq for CompiledGlobMatcher {}

impl CompiledGlobMatcher {
    fn is_match(&self, candidate: &str) -> bool {
        self.matcher.is_match(candidate)
    }
}

pub type MitmHooksByHost = BTreeMap<String, Vec<MitmHook>>;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookEvaluation {
    NoHooksForHost,
    Matched { actions: MitmHookActions },
    HookedHostNoMatch,
}

pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> {
    let hooks = &config.network.mitm_hooks;
    if hooks.is_empty() {
        return Ok(());
    }

    if !config.network.mitm {
        return Err(anyhow!("network.mitm_hooks requires network.mitm = true"));
    }

    for (hook_index, hook) in hooks.iter().enumerate() {
        let host = normalize_hook_host(&hook.host)
            .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?;

        let methods = normalize_methods(&hook.matcher.methods)
            .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?;
        if methods.is_empty() {
            return Err(anyhow!(
                "network.mitm_hooks[{hook_index}].match.methods must not be empty"
            ));
        }

        let path_prefixes =
            compile_path_matchers(&hook.matcher.path_prefixes).with_context(|| {
                format!("invalid network.mitm_hooks[{hook_index}].match.path_prefixes")
            })?;
        if path_prefixes.is_empty() {
            return Err(anyhow!(
                "network.mitm_hooks[{hook_index}].match.path_prefixes must not be empty"
            ));
        }

        if let Some(body) = hook.matcher.body.as_ref() {
            let _ = body;
            return Err(anyhow!(
                "network.mitm_hooks[{hook_index}].match.body is reserved for a future release and is not yet supported"
            ));
        }

        validate_query_constraints(&hook.matcher.query)
            .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.query"))?;
        validate_header_constraints(&hook.matcher.headers)
            .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.headers"))?;
        validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| {
            format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers")
        })?;
        validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| {
            format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers")
        })?;

        if host.is_empty() {
            return Err(anyhow!(
                "network.mitm_hooks[{hook_index}].host must not be empty"
            ));
        }
    }

    Ok(())
}

pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result<MitmHooksByHost> {
    compile_mitm_hooks_with_resolvers(
        config,
        |name| env::var(name).ok(),
        |path| {
            let value = fs::read_to_string(path.as_path()).with_context(|| {
                format!("failed to read secret file {}", path.as_path().display())
            })?;
            Ok(value.trim().to_string())
        },
    )
}

pub(crate) fn evaluate_mitm_hooks(
    hooks_by_host: &MitmHooksByHost,
    host: &str,
    req: &Request,
) -> HookEvaluation {
    let normalized_host = normalize_host(host);
    let Some(hooks) = hooks_by_host.get(&normalized_host) else {
        return HookEvaluation::NoHooksForHost;
    };

    for hook in hooks {
        if hook_matches(hook, req) {
            return HookEvaluation::Matched {
                actions: hook.actions.clone(),
            };
        }
    }

    HookEvaluation::HookedHostNoMatch
}

fn compile_mitm_hooks_with_resolvers<EnvFn, FileFn>(
    config: &NetworkProxyConfig,
    resolve_env_var: EnvFn,
    read_secret_file: FileFn,
) -> Result<MitmHooksByHost>
where
    EnvFn: Fn(&str) -> Option<String>,
    FileFn: Fn(&AbsolutePathBuf) -> Result<String>,
{
    validate_mitm_hook_config(config)?;

    let mut hooks_by_host = MitmHooksByHost::new();
    for hook in &config.network.mitm_hooks {
        let host = normalize_hook_host(&hook.host)?;
        let methods = normalize_methods(&hook.matcher.methods)?;
        let path_prefixes = compile_path_matchers(&hook.matcher.path_prefixes)?;
        let query = hook
            .matcher
            .query
            .iter()
            .map(|(name, values)| {
                Ok(QueryConstraint {
                    name: normalize_query_name(name)?,
                    allowed_values: compile_value_matchers(values)?,
                })
            })
            .collect::<Result<Vec<_>>>()?;
        let headers = hook
            .matcher
            .headers
            .iter()
            .map(|(name, values)| {
                Ok(HeaderConstraint {
                    name: parse_header_name(name)?,
                    allowed_values: compile_value_matchers(values)?,
                })
            })
            .collect::<Result<Vec<_>>>()?;
        let strip_request_headers = hook
            .actions
            .strip_request_headers
            .iter()
            .map(|name| parse_header_name(name))
            .collect::<Result<Vec<_>>>()?;
        let inject_request_headers = hook
            .actions
            .inject_request_headers
            .iter()
            .map(|header| {
                compile_injected_header(header, &resolve_env_var, &read_secret_file)
                    .with_context(|| format!("failed to compile injected header {}", header.name))
            })
            .collect::<Result<Vec<_>>>()?;

        hooks_by_host
            .entry(host.clone())
            .or_default()
            .push(MitmHook {
                host,
                matcher: MitmHookMatcher {
                    methods,
                    path_prefixes,
                    query,
                    headers,
                    body: None,
                },
                actions: MitmHookActions {
                    strip_request_headers,
                    inject_request_headers,
                },
            });
    }

    Ok(hooks_by_host)
}

fn compile_injected_header<EnvFn, FileFn>(
    header: &InjectedHeaderConfig,
    resolve_env_var: &EnvFn,
    read_secret_file: &FileFn,
) -> Result<ResolvedInjectedHeader>
where
    EnvFn: Fn(&str) -> Option<String>,
    FileFn: Fn(&AbsolutePathBuf) -> Result<String>,
{
    let name = parse_header_name(&header.name)?;
    let (secret, source) = match (
        header.secret_env_var.as_deref(),
        header.secret_file.as_deref(),
    ) {
        (Some(env_var), None) => {
            let value = resolve_env_var(env_var)
                .ok_or_else(|| anyhow!("missing required environment variable {env_var}"))?;
            (value, SecretSource::EnvVar(env_var.to_string()))
        }
        (None, Some(secret_file)) => {
            let path = parse_secret_file(secret_file)?;
            let value = read_secret_file(&path)?;
            (value, SecretSource::File(path))
        }
        _ => {
            return Err(anyhow!(
                "expected exactly one of secret_env_var or secret_file"
            ));
        }
    };

    let prefix = header.prefix.clone().unwrap_or_default();
    let value = HeaderValue::from_str(&format!("{prefix}{secret}"))
        .with_context(|| format!("invalid value for injected header {}", header.name))?;

    Ok(ResolvedInjectedHeader {
        name,
        value,
        source,
    })
}

fn hook_matches(hook: &MitmHook, req: &Request) -> bool {
    let method = req.method().as_str().to_ascii_uppercase();
    if !hook
        .matcher
        .methods
        .iter()
        .any(|allowed| allowed == &method)
    {
        return false;
    }

    let path = req.uri().path();
    if !path_matches(&hook.matcher.path_prefixes, path) {
        return false;
    }

    if !query_matches(&hook.matcher.query, req) {
        return false;
    }

    headers_match(&hook.matcher.headers, req)
}

fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool {
    if query_constraints.is_empty() {
        return true;
    }

    let actual_query = req.uri().query().unwrap_or_default();
    let mut actual_values: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (name, value) in form_urlencoded::parse(actual_query.as_bytes()) {
        actual_values
            .entry(name.into_owned())
            .or_default()
            .push(value.into_owned());
    }

    query_constraints.iter().all(|constraint| {
        actual_values.get(&constraint.name).is_some_and(|actual| {
            actual.iter().any(|candidate| {
                constraint
                    .allowed_values
                    .iter()
                    .any(|allowed| allowed.matches(candidate))
            })
        })
    })
}

fn headers_match(header_constraints: &[HeaderConstraint], req: &Request) -> bool {
    header_constraints.iter().all(|constraint| {
        let actual = req.headers().get_all(&constraint.name);
        if actual.iter().next().is_none() {
            return false;
        }
        if constraint.allowed_values.is_empty() {
            return true;
        }

        actual.iter().any(|value| {
            value.to_str().ok().is_some_and(|candidate| {
                constraint
                    .allowed_values
                    .iter()
                    .any(|allowed| allowed.matches(candidate))
            })
        })
    })
}

fn path_matches(path_prefixes: &[PathMatcher], path: &str) -> bool {
    path_prefixes.iter().any(|matcher| matcher.matches(path))
}

impl PathMatcher {
    fn matches(&self, candidate: &str) -> bool {
        match self {
            Self::Prefix(prefix) => candidate.starts_with(prefix),
            Self::Glob(glob) => glob.is_match(candidate),
        }
    }
}

impl ValueMatcher {
    fn matches(&self, candidate: &str) -> bool {
        match self {
            Self::Exact(value) => value == candidate,
            Self::Glob(glob) => glob.is_match(candidate),
        }
    }
}

fn compile_path_matchers(path_prefixes: &[String]) -> Result<Vec<PathMatcher>> {
    path_prefixes
        .iter()
        .map(|prefix| {
            match parse_matcher_pattern(prefix)? {
                MatcherPattern::Literal(prefix) => {
                    if prefix.is_empty() {
                        return Err(anyhow!("path_prefixes must not contain empty entries"));
                    }
                    Ok(PathMatcher::Prefix(prefix.to_string()))
                }
                MatcherPattern::Glob(glob_pattern) => Ok(PathMatcher::Glob(compile_glob_matcher(
                    glob_pattern,
                    /*literal_separator*/ true,
                )?)),
            }
        })
        .collect()
}

fn compile_value_matchers(values: &[String]) -> Result<Vec<ValueMatcher>> {
    values
        .iter()
        .map(|value| match parse_matcher_pattern(value)? {
            MatcherPattern::Literal(value) => Ok(ValueMatcher::Exact(value.to_string())),
            MatcherPattern::Glob(glob_pattern) => Ok(ValueMatcher::Glob(compile_glob_matcher(
                glob_pattern,
                /*literal_separator*/ false,
            )?)),
        })
        .collect()
}

fn parse_matcher_pattern(pattern: &str) -> Result<MatcherPattern<'_>> {
    if let Some(literal) = pattern.strip_prefix(LITERAL_PREFIX) {
        return Ok(MatcherPattern::Literal(literal));
    }
    let Some(glob_pattern) = pattern.strip_prefix(PATTERN_PREFIX) else {
        return Ok(MatcherPattern::Literal(pattern));
    };
    if glob_pattern.is_empty() {
        return Err(anyhow!("glob pattern must not be empty"));
    }
    Ok(MatcherPattern::Glob(glob_pattern))
}

fn compile_glob_matcher(pattern: &str, literal_separator: bool) -> Result<CompiledGlobMatcher> {
    let mut builder = GlobBuilder::new(pattern);
    builder
        .backslash_escape(true)
        .literal_separator(literal_separator);
    builder
        .build()
        .map(|glob| CompiledGlobMatcher {
            pattern: pattern.to_string(),
            matcher: glob.compile_matcher(),
        })
        .map_err(|err| anyhow!("invalid glob pattern {pattern:?}: {err}"))
}

fn normalize_hook_host(host: &str) -> Result<String> {
    let normalized = normalize_host(host);
    if normalized.is_empty() {
        return Err(anyhow!("host must not be empty"));
    }
    if normalized.contains('*') {
        return Err(anyhow!(
            "MITM hook hosts must be exact hosts and cannot contain wildcards"
        ));
    }
    Ok(normalized)
}

fn normalize_methods(methods: &[String]) -> Result<Vec<String>> {
    methods
        .iter()
        .map(|method| {
            let normalized = method.trim().to_ascii_uppercase();
            if normalized.is_empty() {
                return Err(anyhow!("methods must not contain empty entries"));
            }
            Ok(normalized)
        })
        .collect()
}

fn validate_query_constraints(query: &BTreeMap<String, Vec<String>>) -> Result<()> {
    for (name, values) in query {
        let normalized = normalize_query_name(name)?;
        if normalized.is_empty() {
            return Err(anyhow!("query keys must not be empty"));
        }
        if values.is_empty() {
            return Err(anyhow!(
                "query key {name:?} must list at least one allowed value"
            ));
        }
        let _ = compile_value_matchers(values)
            .with_context(|| format!("invalid matcher for query key {name:?}"))?;
    }
    Ok(())
}

fn normalize_query_name(name: &str) -> Result<String> {
    if name.is_empty() {
        return Err(anyhow!("query keys must not be empty"));
    }
    Ok(name.to_string())
}

fn validate_header_constraints(headers: &BTreeMap<String, Vec<String>>) -> Result<()> {
    for (name, values) in headers {
        let _ = parse_header_name(name)?;
        let _ = compile_value_matchers(values)
            .with_context(|| format!("invalid matcher for header {name:?}"))?;
    }
    Ok(())
}

fn validate_strip_request_headers(header_names: &[String]) -> Result<()> {
    for name in header_names {
        let _ = parse_header_name(name)?;
    }
    Ok(())
}

fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> {
    for header in headers {
        let _ = parse_header_name(&header.name)?;
        match (
            header.secret_env_var.as_deref(),
            header.secret_file.as_deref(),
        ) {
            (Some(secret_env_var), None) => {
                if secret_env_var.trim().is_empty() {
                    return Err(anyhow!("secret_env_var must not be empty"));
                }
            }
            (None, Some(secret_file)) => {
                let _ = parse_secret_file(secret_file)?;
            }
            _ => {
                return Err(anyhow!(
                    "expected exactly one of secret_env_var or secret_file"
                ));
            }
        }
    }
    Ok(())
}

fn parse_header_name(name: &str) -> Result<HeaderName> {
    HeaderName::from_bytes(name.as_bytes())
        .map_err(|err| anyhow!("invalid header name {name:?}: {err}"))
}

fn parse_secret_file(path: &str) -> Result<AbsolutePathBuf> {
    if path.trim().is_empty() {
        return Err(anyhow!("secret_file must not be empty"));
    }
    let path = Path::new(path);
    if !path.is_absolute() {
        return Err(anyhow!("secret_file must be an absolute path: {path:?}"));
    }
    AbsolutePathBuf::from_absolute_path(path)
        .with_context(|| format!("secret_file must be an absolute path: {path:?}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::NetworkMode;
    use crate::config::NetworkProxySettings;
    use pretty_assertions::assert_eq;
    use rama_http::Body;
    use rama_http::Method;
    use tempfile::NamedTempFile;

    fn base_config() -> NetworkProxyConfig {
        NetworkProxyConfig {
            network: NetworkProxySettings {
                mitm: true,
                mode: NetworkMode::Limited,
                ..NetworkProxySettings::default()
            },
        }
    }

    fn github_hook() -> MitmHookConfig {
        MitmHookConfig {
            host: "api.github.com".to_string(),
            matcher: MitmHookMatchConfig {
                methods: vec!["POST".to_string(), "PUT".to_string()],
                path_prefixes: vec!["/repos/openai/".to_string()],
                ..MitmHookMatchConfig::default()
            },
            actions: MitmHookActionsConfig {
                strip_request_headers: vec!["authorization".to_string()],
                inject_request_headers: vec![InjectedHeaderConfig {
                    name: "authorization".to_string(),
                    secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
                    secret_file: None,
                    prefix: Some("Bearer ".to_string()),
                }],
            },
        }
    }

    #[test]
    fn validate_requires_mitm_for_hooks() {
        let mut config = base_config();
        config.network.mitm = false;
        config.network.mitm_hooks = vec![github_hook()];

        let err = validate_mitm_hook_config(&config).expect_err("hooks require mitm");
        assert!(
            err.to_string()
                .contains("network.mitm_hooks requires network.mitm = true")
        );
    }

    #[test]
    fn validate_allows_hooks_in_full_mode() {
        let mut config = base_config();
        config.network.mode = NetworkMode::Full;
        config.network.mitm_hooks = vec![github_hook()];

        validate_mitm_hook_config(&config).expect("hooks should be allowed in full mode");
    }

    #[test]
    fn validate_rejects_body_matchers_for_now() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.body = Some(MitmHookBodyConfig(serde_json::json!({
            "repository": "openai/codex"
        })));
        config.network.mitm_hooks = vec![hook];

        let err = validate_mitm_hook_config(&config).expect_err("body matchers are reserved");
        assert!(err.to_string().contains("match.body is reserved"));
    }

    #[test]
    fn validate_rejects_relative_secret_file() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.actions.inject_request_headers[0].secret_env_var = None;
        hook.actions.inject_request_headers[0].secret_file = Some("token.txt".to_string());
        config.network.mitm_hooks = vec![hook];

        let err = validate_mitm_hook_config(&config).expect_err("secret file must be absolute");
        assert!(format!("{err:#}").contains("secret_file must be an absolute path"));
    }

    #[test]
    fn validate_rejects_dual_secret_sources() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.actions.inject_request_headers[0].secret_file = Some("/tmp/github-token".to_string());
        config.network.mitm_hooks = vec![hook];

        let err = validate_mitm_hook_config(&config).expect_err("dual secret sources invalid");
        assert!(format!("{err:#}").contains("exactly one of secret_env_var or secret_file"));
    }

    #[test]
    fn compile_resolves_env_backed_injected_headers() {
        let mut config = base_config();
        config.network.mitm_hooks = vec![github_hook()];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |name| (name == "CODEX_GITHUB_TOKEN").then(|| "ghp-secret".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();

        let compiled = hooks.get("api.github.com").unwrap();
        assert_eq!(compiled.len(), 1);
        assert_eq!(
            compiled[0].actions.inject_request_headers[0].source,
            SecretSource::EnvVar("CODEX_GITHUB_TOKEN".to_string())
        );
        assert_eq!(
            compiled[0].actions.inject_request_headers[0].value,
            HeaderValue::from_static("Bearer ghp-secret")
        );
    }

    #[test]
    fn compile_resolves_file_backed_injected_headers() {
        let secret_file = NamedTempFile::new().unwrap();
        std::fs::write(secret_file.path(), "ghp-file-secret\n").unwrap();

        let mut config = base_config();
        let mut hook = github_hook();
        hook.actions.inject_request_headers[0].secret_env_var = None;
        hook.actions.inject_request_headers[0].secret_file =
            Some(secret_file.path().display().to_string());
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks(&config).unwrap();
        let compiled = hooks.get("api.github.com").unwrap();
        assert_eq!(
            compiled[0].actions.inject_request_headers[0].value,
            HeaderValue::from_static("Bearer ghp-file-secret")
        );
    }

    #[test]
    fn evaluate_returns_first_matching_hook() {
        let mut config = base_config();
        let mut first = github_hook();
        first.matcher.path_prefixes = vec!["/repos/openai/".to_string()];
        let mut second = github_hook();
        second.actions.inject_request_headers[0].prefix = Some("Token ".to_string());
        config.network.mitm_hooks = vec![first, second];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues")
            .header("x-trace", "1")
            .body(Body::empty())
            .unwrap();

        let evaluation = evaluate_mitm_hooks(&hooks, "api.github.com", &req);
        let HookEvaluation::Matched { actions } = evaluation else {
            panic!("expected a matching hook");
        };

        assert_eq!(
            actions.inject_request_headers[0].value,
            HeaderValue::from_static("Bearer abc")
        );
    }

    #[test]
    fn evaluate_matches_query_and_header_constraints() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.query = BTreeMap::from([(
            "state".to_string(),
            vec!["open".to_string(), "triage".to_string()],
        )]);
        hook.matcher.headers = BTreeMap::from([(
            "x-github-api-version".to_string(),
            vec!["2022-11-28".to_string()],
        )]);
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues?state=open&per_page=10")
            .header("x-github-api-version", "2022-11-28")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &req),
            HookEvaluation::Matched {
                actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
            }
        );
    }

    #[test]
    fn evaluate_matches_wildcard_path_query_and_header_constraints() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()];
        hook.matcher.query =
            BTreeMap::from([("state".to_string(), vec!["pattern:op*".to_string()])]);
        hook.matcher.headers = BTreeMap::from([(
            "x-github-api-version".to_string(),
            vec!["pattern:2022*preview".to_string()],
        )]);
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues?state=open")
            .header("x-github-api-version", "2022-11-28-preview")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &req),
            HookEvaluation::Matched {
                actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
            }
        );
    }

    #[test]
    fn validate_rejects_invalid_wildcard_path_pattern() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.path_prefixes = vec!["pattern:/repos/[".to_string()];
        config.network.mitm_hooks = vec![hook];

        let err = validate_mitm_hook_config(&config).expect_err("invalid glob should fail");
        assert!(format!("{err:#}").contains("invalid glob pattern"));
    }

    #[test]
    fn evaluate_path_wildcard_does_not_cross_segment_boundaries() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()];
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let nested_req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/private/codex/issues")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &nested_req),
            HookEvaluation::HookedHostNoMatch
        );
    }

    #[test]
    fn evaluate_treats_glob_metacharacters_as_literal_without_glob_prefix() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.path_prefixes = vec!["/repos/[draft]/".to_string()];
        hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["op*".to_string()])]);
        hook.matcher.headers = BTreeMap::from([(
            "x-github-api-version".to_string(),
            vec!["2022-11-28[preview]".to_string()],
        )]);
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let exact_req = Request::builder()
            .method(Method::POST)
            .uri("/repos/[draft]/codex/issues?state=op*")
            .header("x-github-api-version", "2022-11-28[preview]")
            .body(Body::empty())
            .unwrap();
        let non_literal_req = Request::builder()
            .method(Method::POST)
            .uri("/repos/draft/codex/issues?state=open")
            .header("x-github-api-version", "2022-11-28-preview")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req),
            HookEvaluation::Matched {
                actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
            }
        );
        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req),
            HookEvaluation::HookedHostNoMatch
        );
    }

    #[test]
    fn evaluate_allows_literal_values_with_reserved_prefixes() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.query =
            BTreeMap::from([("state".to_string(), vec!["literal:pattern:*".to_string()])]);
        hook.matcher.headers = BTreeMap::from([(
            "x-github-api-version".to_string(),
            vec!["literal:pattern:*".to_string()],
        )]);
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let exact_req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues?state=pattern%3A%2A")
            .header("x-github-api-version", "pattern:*")
            .body(Body::empty())
            .unwrap();
        let non_literal_req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues?state=pattern%3Aopen")
            .header("x-github-api-version", "pattern:preview")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req),
            HookEvaluation::Matched {
                actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
            }
        );
        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req),
            HookEvaluation::HookedHostNoMatch
        );
    }

    #[test]
    fn evaluate_returns_hooked_host_no_match_when_query_constraint_fails() {
        let mut config = base_config();
        let mut hook = github_hook();
        hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["open".to_string()])]);
        config.network.mitm_hooks = vec![hook];

        let hooks = compile_mitm_hooks_with_resolvers(
            &config,
            |_| Some("abc".to_string()),
            |_| Err(anyhow!("unexpected file lookup")),
        )
        .unwrap();
        let req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues?state=closed")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&hooks, "api.github.com", &req),
            HookEvaluation::HookedHostNoMatch
        );
    }

    #[test]
    fn evaluate_returns_no_hooks_for_unconfigured_host() {
        let req = Request::builder()
            .method(Method::POST)
            .uri("/repos/openai/codex/issues")
            .body(Body::empty())
            .unwrap();

        assert_eq!(
            evaluate_mitm_hooks(&MitmHooksByHost::new(), "api.github.com", &req),
            HookEvaluation::NoHooksForHost
        );
    }
}
