use parking_lot::Mutex;
use reqwest::header::SET_COOKIE;
use secir::transport::RequestSpec;
use std::collections::{BTreeMap, HashMap};
use url::Url;

/// Thread-safe cookie store scoped by host, path, and request template.
///
/// Cookies set by one template's responses are only replayed on requests that
/// reference the same template (and a matching host + path prefix), so unrelated
/// scan templates never cross-contaminate each other's session state.
#[derive(Debug, Default)]
pub struct CookieJar {
    /// Per-scope cookie name→value maps, guarded for concurrent request workers.
    pub cookies: Mutex<HashMap<CookieScope, BTreeMap<String, String>>>,
}

/// The scope a stored cookie belongs to: a `(host, path, template)` triple.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CookieScope {
    /// Origin in `scheme://host:port` form (see [`host_key`]).
    pub host: String,
    /// Request path the cookie applies to (prefix-matched against requests).
    pub path: String,
    /// Identifier of the template whose response set the cookie.
    pub template_id: String,
}

impl CookieJar {
    /// Build the `Cookie` request header for `spec`, merging every stored cookie
    /// whose scope matches one of the request's template refs, host, and path
    /// prefix. Returns `None` when no cookie applies.
    pub fn cookie_header(&self, spec: &RequestSpec) -> Option<String> {
        let host = host_key(&spec.url)?;
        let path = path_key(&spec.url);
        let mut merged = BTreeMap::new();

        let cookies = self.cookies.lock();

        for (template_id, _) in &spec.template_refs {
            for (scope, entries) in cookies.iter() {
                if scope.template_id == *template_id
                    && scope.host == host
                    && path.starts_with(&scope.path)
                {
                    for (name, value) in entries {
                        merged.insert(name.clone(), value.clone());
                    }
                }
            }
        }

        if merged.is_empty() {
            None
        } else {
            Some(
                merged
                    .into_iter()
                    .map(|(name, value)| format!("{name}={value}"))
                    .collect::<Vec<_>>()
                    .join("; "),
            )
        }
    }

    /// Parse the `Set-Cookie` headers from a response and store each cookie
    /// under the scope of every template `spec` references. Invalid header
    /// values are logged and skipped rather than aborting the store.
    pub fn store_response_cookies(&self, spec: &RequestSpec, headers: &reqwest::header::HeaderMap) {
        let Some(host) = host_key(&spec.url) else {
            return;
        };
        let parsed = headers
            .get_all(SET_COOKIE)
            .iter()
            .filter_map(|value| match value.to_str() {
                Ok(value) => Some(value),
                Err(error) => {
                    tracing::warn!(error = %error, "discarding invalid Set-Cookie header value");
                    None
                }
            })
            .filter_map(parse_set_cookie)
            .collect::<Vec<_>>();

        if parsed.is_empty() {
            return;
        }

        let mut cookies = self.cookies.lock();
        let default_path = path_key(&spec.url);
        for (name, value, path_opt) in parsed {
            for (template_id, _) in &spec.template_refs {
                let scope = CookieScope {
                    host: host.clone(),
                    path: path_opt.clone().unwrap_or_else(|| default_path.clone()),
                    template_id: template_id.clone(),
                };
                let entry = cookies.entry(scope).or_default();
                entry.insert(name.clone(), value.clone());
            }
        }
    }
}

/// Canonical origin key for cookie scoping: `scheme://host:port`, using the
/// scheme's default port when the URL omits one. Returns `None` for URLs that
/// fail to parse or lack a host.
pub fn host_key(url: &str) -> Option<String> {
    let parsed = match Url::parse(url) {
        Ok(parsed) => parsed,
        Err(error) => {
            tracing::warn!(url = %url, error = %error, "failed to parse cookie scope URL");
            return None;
        }
    };
    let host = parsed.host_str()?;
    let port = parsed.port_or_known_default()?;
    Some(format!("{}://{}:{}", parsed.scheme(), host, port))
}

fn path_key(url: &str) -> String {
    Url::parse(url).map_or_else(|_| "/".to_string(), |url| url.path().to_string())
}

/// Parse a single `Set-Cookie` header value into `(name, value, path)`.
///
/// Only the leading `name=value` pair and an optional `Path=` attribute are
/// extracted; other attributes are ignored. Returns `None` when the header has
/// no `name=value` pair or the name is empty.
pub fn parse_set_cookie(header: &str) -> Option<(String, String, Option<String>)> {
    let mut parts = header.split(';');
    let pair = parts.next()?.trim();
    let (name, value) = pair.split_once('=')?;
    if name.trim().is_empty() {
        return None;
    }

    let mut path = None;
    for part in parts {
        let part = part.trim();
        if part.to_lowercase().starts_with("path=") {
            path = Some(part[5..].trim().to_string());
        }
    }

    Some((name.trim().to_string(), value.trim().to_string(), path))
}
