//! Runtime route matching.
//!
//! Given a `(host, method, path)` triple and a `RouteTable`, returns
//! the single best matching route plus extracted parameters, or `None`.
//!
//! Rules (decided in design discussion, see chat history):
//!
//!   1. Host dispatch: collect candidate routes whose host pattern
//!      matches the request Host. `strict > wildcard (longer suffix
//!      wins) > any`. Within the most-specific matching host bucket,
//!      try path matching; if nothing matches, fall through to less
//!      specific buckets.
//!   2. Within a host bucket, method must overlap (request method
//!      equals route method, or route method is `any`).
//!   3. Path dispatch by kind: exact wins absolute; among non-exact
//!      matches, more leading literal segments wins; tie → `param >
//!      prefix`. Within prefix, longest matching prefix wins.

use std::collections::BTreeMap;

use super::pattern::{HostPattern, PathPattern, PathSegment};

#[derive(Debug, Clone)]
pub struct MatchResult {
    pub matched: Matched,
    pub params: BTreeMap<String, String>,
    /// `Some(rest)` when the matched route is a prefix; the remainder
    /// of the URL path after the matched prefix, with no leading slash.
    pub rest: Option<String>,
    /// Captured host parameter (future `{tenant}.example.com`); always
    /// `None` for the v1.1 SDK since the brace syntax is reserved.
    pub host_param: Option<(String, String)>,
}

/// Reference back into the caller's route storage. Generic so callers
/// can carry their own `Route` shape; only the patterns are required.
#[derive(Debug, Clone)]
pub struct Matched {
    pub route_id: uuid::Uuid,
    pub script_id: picloud_shared::ScriptId,
    /// Per-route dispatch mode (v1.1.1). Forwarded to the
    /// orchestrator's HTTP handler so it can pick the sync or async
    /// path. Defaults to `Sync` for older routes that predate the
    /// column.
    pub dispatch_mode: picloud_shared::DispatchMode,
}

/// A single route ready for matching. `app_id` is carried so the
/// caller (the orchestrator's `AppRouteTables`) can partition the
/// table; the matcher itself doesn't read it.
#[derive(Debug, Clone)]
pub struct CompiledRoute {
    pub route_id: uuid::Uuid,
    pub app_id: picloud_shared::AppId,
    pub script_id: picloud_shared::ScriptId,
    pub host: HostPattern,
    pub path: PathPattern,
    pub method: Option<String>,
    pub dispatch_mode: picloud_shared::DispatchMode,
}

/// Find the best matching route for the request. Returns `None` if no
/// route is eligible at any host-specificity level.
#[must_use]
pub fn r#match<'a, I>(
    routes: I,
    request_host: &str,
    request_method: &str,
    request_path: &str,
) -> Option<MatchResult>
where
    I: IntoIterator<Item = &'a CompiledRoute>,
{
    let candidates: Vec<&CompiledRoute> = routes.into_iter().collect();

    // Group by host-specificity, descending. We try each bucket in
    // turn — the moment we find a path match within a bucket, return.
    let buckets = bucket_by_host(&candidates, request_host);

    for bucket in buckets {
        if let Some(result) = match_within_bucket(&bucket, request_method, request_path) {
            return Some(result);
        }
    }
    None
}

/// Optional host capture (param-name + extracted label) when a wildcard
/// match has the `{name}.example.com` capture syntax (deferred SDK
/// feature; always `None` today but the type carries through so we
/// don't have to thread it later).
type HostCapture = Option<(String, String)>;

type RouteHit<'a> = (&'a CompiledRoute, HostCapture);

/// Returns the routes whose host pattern accepts `request_host`,
/// grouped by host-specificity in descending order (most-specific
/// first). Higher-priority buckets are tried first by the matcher.
fn bucket_by_host<'a>(
    candidates: &[&'a CompiledRoute],
    request_host: &str,
) -> Vec<Vec<RouteHit<'a>>> {
    let host_stripped = strip_port(request_host);

    let mut hits: Vec<(HostBucket, &CompiledRoute, HostCapture)> = Vec::new();
    for r in candidates {
        if let Some(capture) = host_matches(&r.host, host_stripped) {
            hits.push((host_bucket(&r.host), r, capture));
        }
    }
    // Sort descending by bucket; group runs of equal buckets.
    hits.sort_by(|a, b| b.0.cmp(&a.0));

    let mut out: Vec<Vec<RouteHit<'a>>> = Vec::new();
    let mut current_bucket: Option<HostBucket> = None;
    for (bucket, route, capture) in hits {
        if Some(bucket) == current_bucket {
            out.last_mut().unwrap().push((route, capture));
        } else {
            current_bucket = Some(bucket);
            out.push(vec![(route, capture)]);
        }
    }
    out
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum HostBucket {
    Any,
    Wildcard(usize), // suffix length
    Strict,
}

fn host_bucket(p: &HostPattern) -> HostBucket {
    match p {
        HostPattern::Any => HostBucket::Any,
        HostPattern::Wildcard { suffix, .. } => HostBucket::Wildcard(suffix.len()),
        HostPattern::Strict(_) => HostBucket::Strict,
    }
}

#[allow(clippy::option_option)]
fn host_matches(pattern: &HostPattern, request_host: &str) -> Option<HostCapture> {
    match pattern {
        HostPattern::Any => Some(None),
        HostPattern::Strict(value) => {
            if value.eq_ignore_ascii_case(request_host) {
                Some(None)
            } else {
                None
            }
        }
        HostPattern::Wildcard { suffix, capture } => {
            let host = request_host.to_ascii_lowercase();
            let suffix = suffix.to_ascii_lowercase();
            // Require: host ends with ".suffix" AND has at least one
            // non-empty label before. Multi-level subdomains (a.b.suffix)
            // do match — common platform convention for wildcard certs
            // is one-level only, but for HTTP routing users almost
            // always want "anything under this domain".
            let dotted = format!(".{suffix}");
            host.strip_suffix(&dotted)
                .filter(|p| !p.is_empty())
                .map(|label| capture.as_ref().map(|c| (c.clone(), label.to_string())))
        }
    }
}

fn strip_port(host: &str) -> &str {
    host.split(':').next().unwrap_or(host)
}

fn match_within_bucket(
    bucket: &[RouteHit<'_>],
    request_method: &str,
    request_path: &str,
) -> Option<MatchResult> {
    // 1. Exact wins absolute.
    for (route, host_param) in bucket {
        if !method_matches(route.method.as_deref(), request_method) {
            continue;
        }
        if let PathPattern::Exact(p) = &route.path {
            if p == request_path {
                return Some(MatchResult {
                    matched: Matched {
                        route_id: route.route_id,
                        script_id: route.script_id,
                        dispatch_mode: route.dispatch_mode,
                    },
                    params: BTreeMap::new(),
                    rest: None,
                    host_param: host_param.clone(),
                });
            }
        }
    }

    // 2. Among non-exact matches, score by leading-literal count;
    //    tie → param > prefix; tie among prefixes → longest wins.
    let mut best: Option<(MatchScore, MatchResult)> = None;
    for (route, host_param) in bucket {
        if !method_matches(route.method.as_deref(), request_method) {
            continue;
        }
        let (params, rest) = match &route.path {
            PathPattern::Exact(_) => continue, // handled above
            PathPattern::Prefix(prefix) => {
                if let Some(rest) = request_path.strip_prefix(prefix.as_str()) {
                    // Successful prefix match. Empty rest is fine for
                    // "/*" matching "/", but for non-root the rest is
                    // typically a real path segment.
                    (BTreeMap::new(), Some(rest.to_string()))
                } else {
                    continue;
                }
            }
            PathPattern::Param(segs) => {
                if let Some(captures) = match_param(segs, request_path) {
                    (captures, None)
                } else {
                    continue;
                }
            }
        };

        let score = MatchScore {
            leading_literals: route.path.leading_literal_count(),
            kind_rank: kind_rank(&route.path),
            prefix_len: match &route.path {
                PathPattern::Prefix(p) => p.len(),
                _ => 0,
            },
        };

        let result = MatchResult {
            matched: Matched {
                route_id: route.route_id,
                script_id: route.script_id,
                dispatch_mode: route.dispatch_mode,
            },
            params,
            rest,
            host_param: host_param.clone(),
        };

        match &best {
            None => best = Some((score, result)),
            Some((bs, _)) if score > *bs => best = Some((score, result)),
            _ => {}
        }
    }

    best.map(|(_, r)| r)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct MatchScore {
    leading_literals: usize,
    kind_rank: u8, // higher = preferred at equal leading_literals
    prefix_len: usize,
}

fn kind_rank(p: &PathPattern) -> u8 {
    match p {
        PathPattern::Exact(_) => 3, // handled separately but kept for completeness
        PathPattern::Param(_) => 2,
        PathPattern::Prefix(_) => 1,
    }
}

fn method_matches(route_method: Option<&str>, request_method: &str) -> bool {
    match route_method {
        None => true,
        Some(m) => m.eq_ignore_ascii_case(request_method),
    }
}

fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<String, String>> {
    let req_segs: Vec<&str> = request_path
        .trim_start_matches('/')
        .split('/')
        .filter(|s| !s.is_empty())
        .collect();
    if req_segs.len() != segs.len() {
        return None;
    }
    let mut captures = BTreeMap::new();
    for (pattern_seg, req_seg) in segs.iter().zip(req_segs.iter()) {
        match pattern_seg {
            PathSegment::Literal(lit) => {
                if lit != req_seg {
                    return None;
                }
            }
            PathSegment::Param(name) => {
                captures.insert(name.clone(), (*req_seg).to_string());
            }
        }
    }
    Some(captures)
}

// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::super::pattern::parse_path;
    use super::*;
    use picloud_shared::{AppId, PathKind, ScriptId};
    use uuid::Uuid;

    fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
        CompiledRoute {
            route_id: Uuid::new_v4(),
            app_id: AppId::new(),
            script_id: ScriptId::new(),
            host,
            path: parse_path(path_kind, raw).unwrap(),
            method: None,
            dispatch_mode: picloud_shared::DispatchMode::Sync,
        }
    }

    #[test]
    fn exact_wins_over_param_and_prefix() {
        let routes = vec![
            route(HostPattern::Any, PathKind::Exact, "/greet"),
            route(HostPattern::Any, PathKind::Param, "/:anything"),
            route(HostPattern::Any, PathKind::Prefix, "/*"),
        ];
        let r = r#match(&routes, "host", "GET", "/greet").unwrap();
        assert_eq!(r.matched.route_id, routes[0].route_id);
    }

    #[test]
    fn param_beats_prefix_at_equal_leading_literals() {
        let routes = vec![
            route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
            route(HostPattern::Any, PathKind::Param, "/foo/users/:id"),
        ];
        let r = r#match(&routes, "host", "GET", "/foo/users/foo").unwrap();
        assert_eq!(r.matched.route_id, routes[1].route_id);
        assert_eq!(r.params.get("id").map(String::as_str), Some("foo"));
    }

    #[test]
    fn longer_prefix_wins_over_shorter_prefix() {
        let routes = vec![
            route(HostPattern::Any, PathKind::Prefix, "/foo/*"),
            route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
        ];
        let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
        assert_eq!(r.matched.route_id, routes[1].route_id);
        assert_eq!(r.rest.as_deref(), Some("list"));
    }

    #[test]
    fn more_leading_literals_wins_across_kinds() {
        // prefix has more leading literals than param → prefix wins.
        let routes = vec![
            route(HostPattern::Any, PathKind::Param, "/foo/:section"),
            route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
        ];
        let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
        assert_eq!(r.matched.route_id, routes[1].route_id);
    }

    #[test]
    fn prefix_does_not_match_bare_root() {
        let routes = vec![route(HostPattern::Any, PathKind::Prefix, "/greet/*")];
        assert!(r#match(&routes, "host", "GET", "/greet").is_none());
        assert!(r#match(&routes, "host", "GET", "/greet/x").is_some());
    }

    #[test]
    fn host_strict_beats_wildcard() {
        let routes = vec![
            route(
                HostPattern::Wildcard {
                    suffix: "example.com".into(),
                    capture: None,
                },
                PathKind::Exact,
                "/greet",
            ),
            route(
                HostPattern::Strict("sub.example.com".into()),
                PathKind::Exact,
                "/greet",
            ),
        ];
        // sub.example.com matches both; strict wins.
        let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
        assert_eq!(r.matched.route_id, routes[1].route_id);
        // other.example.com only matches wildcard.
        let r2 = r#match(&routes, "other.example.com", "GET", "/greet").unwrap();
        assert_eq!(r2.matched.route_id, routes[0].route_id);
    }

    #[test]
    fn wildcard_requires_subdomain_label() {
        let routes = vec![route(
            HostPattern::Wildcard {
                suffix: "example.com".into(),
                capture: None,
            },
            PathKind::Exact,
            "/x",
        )];
        // Bare apex shouldn't match a *.example.com wildcard.
        assert!(r#match(&routes, "example.com", "GET", "/x").is_none());
        // Two-level subdomains DO match (`.label.example.com` ends with `.example.com`).
        assert!(r#match(&routes, "a.b.example.com", "GET", "/x").is_some());
    }

    #[test]
    fn fallback_to_less_specific_host_when_path_misses() {
        // Strict bucket has no path that matches; should fall through
        // to the wildcard bucket.
        let routes = vec![
            route(
                HostPattern::Strict("sub.example.com".into()),
                PathKind::Exact,
                "/admin-only",
            ),
            route(
                HostPattern::Wildcard {
                    suffix: "example.com".into(),
                    capture: None,
                },
                PathKind::Exact,
                "/greet",
            ),
        ];
        let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
        assert_eq!(r.matched.route_id, routes[1].route_id);
    }

    #[test]
    fn method_filters_apply() {
        let mut get_route = route(HostPattern::Any, PathKind::Exact, "/echo");
        get_route.method = Some("GET".into());
        let mut post_route = route(HostPattern::Any, PathKind::Exact, "/echo");
        post_route.method = Some("POST".into());

        let routes = vec![get_route.clone(), post_route.clone()];
        let r1 = r#match(&routes, "host", "GET", "/echo").unwrap();
        assert_eq!(r1.matched.route_id, get_route.route_id);
        let r2 = r#match(&routes, "host", "POST", "/echo").unwrap();
        assert_eq!(r2.matched.route_id, post_route.route_id);
        assert!(r#match(&routes, "host", "DELETE", "/echo").is_none());
    }

    #[test]
    fn host_with_port_strips_port() {
        let routes = vec![route(
            HostPattern::Strict("sub.example.com".into()),
            PathKind::Exact,
            "/x",
        )];
        let r = r#match(&routes, "sub.example.com:8443", "GET", "/x");
        assert!(r.is_some());
    }
}
