//! FastAPI / Flask HTTP route detection — extends the Python extractor.
//!
//! Pattern: `@app.get("/path")`, `@router.post(...)`, `@app.route("/path", methods=...)`
//! decorators on a function definition. `lang::python` calls
//! `detect_python_route` with the `decorated_definition` node when it sees a
//! function wrapped in decorators.
//!
//! ## Limitations
//!
//! **FastAPI APIRouter `prefix=` is NOT tracked across files.** A route
//! defined as `@router.get("/users")` and registered via
//! `app.include_router(router, prefix="/api")` in a different file emits
//! `GET /users`, missing the `/api` prefix. Per-file extract API can't
//! see across files; cross-file `include_router` resolution would need
//! a per-repo post-pass similar to `prune_implicit_route_duplicates`.
//! Documented limitation; affected users should query routes by handler
//! name rather than path until this lands.

use ckg_core::{Kind, Symbol, node_id};
use tree_sitter::Node;

use crate::lang::python::{Ctx, text};

const VERBS: &[&str] = &[
    "get", "post", "put", "delete", "patch", "head", "options", "route",
];

pub(crate) fn detect_python_route(
    ctx: &mut Ctx<'_>,
    decorated: Node<'_>,
    src: &str,
    handler: &str,
) {
    let mut cursor = decorated.walk();
    for child in decorated.children(&mut cursor) {
        if child.kind() != "decorator" {
            continue;
        }
        let mut dc = child.walk();
        let call = child.children(&mut dc).find(|c| c.kind() == "call");
        let Some(call) = call else { continue };
        let Some(func) = call.child_by_field_name("function") else {
            continue;
        };
        if func.kind() != "attribute" {
            continue;
        }
        let Some(verb_node) = func.child_by_field_name("attribute") else {
            continue;
        };
        let verb = text(verb_node, src);
        if !VERBS.contains(&verb) {
            continue;
        }
        // Capture the owner variable (`app` or `router` in
        // `@app.get(...)` / `@router.post(...)`) so the per-repo
        // include_router post-pass can join routes against
        // `app.include_router(router_var, prefix="/api")`
        // registrations and prepend the prefix.
        let owner = func
            .child_by_field_name("object")
            .map(|o| text(o, src).to_string())
            .unwrap_or_default();
        let Some(args) = call.child_by_field_name("arguments") else {
            continue;
        };
        let mut ac = args.walk();
        let mut path: Option<String> = None;
        let mut methods_kw: Vec<String> = Vec::new();
        for a in args.children(&mut ac) {
            match a.kind() {
                "string" if path.is_none() => {
                    path = Some(strip_python_string_literal(text(a, src)));
                }
                // Flask `@app.route("/x", methods=["GET","POST"])`. The
                // tree-sitter node kind is `keyword_argument` whose
                // child is an identifier `methods` followed by a
                // `list` of strings. Issue #5 in the remaining-issues
                // inventory: previously `methods=…` was ignored and
                // every Flask `@app.route` collapsed to `ANY /x`,
                // hiding two routes as one.
                "keyword_argument" => {
                    let Some(kw_name) = a.child_by_field_name("name") else {
                        continue;
                    };
                    if text(kw_name, src) != "methods" {
                        continue;
                    }
                    let Some(value) = a.child_by_field_name("value") else {
                        continue;
                    };
                    let mut vc = value.walk();
                    for elem in value.children(&mut vc) {
                        if elem.kind() == "string" {
                            let m = strip_python_string_literal(text(elem, src));
                            if !m.is_empty() {
                                methods_kw.push(m.to_uppercase());
                            }
                        }
                    }
                }
                _ => {}
            }
        }
        let Some(path) = path else { continue };
        // Determine which HTTP method(s) this route emits. For
        // `@app.route(..., methods=[...])` we expand into one Symbol
        // per method instead of collapsing to `ANY`. For `@app.get`
        // etc the verb is the method.
        let methods: Vec<String> = if verb == "route" {
            if methods_kw.is_empty() {
                vec!["ANY".to_string()]
            } else {
                methods_kw
            }
        } else {
            vec![verb.to_uppercase()]
        };
        let pos = call.start_position();
        for method in methods {
            let qname = format!("{method} {path}");
            // Item #25 in the inventory: route `Symbol.id` no longer
            // collides on duplicate `METHOD path` across files.
            // `node_id` includes `lang`, `relpath`, `qname` so the
            // same `GET /healthz` defined in `main.py` and `admin.py`
            // produces distinct ids.
            let id = node_id(ctx.repo_id, "python", &ctx.relpath, &qname, Kind::Route);
            ctx.out.symbols.push(Symbol {
                id,
                qname: qname.clone(),
                name: qname,
                kind: Kind::Route,
                file: ctx.relpath.clone(),
                line: pos.row as u32 + 1,
                col: pos.column as u32,
                is_public: true,
                // `@owner=<var>` lets the post-pass
                // `resolve_include_router_prefixes` join this route
                // against `app.include_router(<var>, prefix="/api")`
                // registrations from another file. `app`-owned routes
                // are emitted with `@owner=app` but never get a
                // prefix prepended (no registration site exists for
                // the app itself).
                doc: format!("handler={handler} @owner={owner}"),
                hash: blake3::hash(format!("{method}{path}{handler}").as_bytes()).to_hex()[..16]
                    .to_string(),
            });
        }
    }
    // L7: detect_include_router stub removed — module-level registration
    // detection is handled in lang::python::walk (walk_for_router_registration),
    // not at the decorated_definition level.
}

/// Detect `app.include_router(<router_var>, prefix="/api")` at module
/// level. Called from `lang::python::walk` when traversing the
/// `module` node's children. Emits a registration Symbol if the call
/// matches the pattern.
pub(crate) fn detect_router_registration(ctx: &mut Ctx<'_>, n: Node<'_>, src: &str) {
    if n.kind() != "expression_statement" {
        return;
    }
    // Walk into the call expression.
    let mut c = n.walk();
    for child in n.children(&mut c) {
        if child.kind() != "call" {
            continue;
        }
        let Some(func) = child.child_by_field_name("function") else {
            continue;
        };
        if func.kind() != "attribute" {
            continue;
        }
        let Some(method) = func.child_by_field_name("attribute") else {
            continue;
        };
        if text(method, src) != "include_router" {
            continue;
        }
        let Some(args) = child.child_by_field_name("arguments") else {
            continue;
        };
        // First positional arg: the router variable name.
        // Subsequent keyword args: prefix=, tags=, etc.
        let mut router_var: Option<String> = None;
        let mut prefix: Option<String> = None;
        let mut ac = args.walk();
        for a in args.children(&mut ac) {
            match a.kind() {
                "identifier" if router_var.is_none() => {
                    router_var = Some(text(a, src).to_string());
                }
                "keyword_argument" => {
                    let Some(kw_name) = a.child_by_field_name("name") else {
                        continue;
                    };
                    if text(kw_name, src) != "prefix" {
                        continue;
                    }
                    let Some(value) = a.child_by_field_name("value") else {
                        continue;
                    };
                    if value.kind() == "string" {
                        prefix = Some(strip_python_string_literal(text(value, src)));
                    }
                }
                _ => {}
            }
        }
        let (Some(router_var), Some(prefix)) = (router_var, prefix) else {
            return;
        };
        let pos = n.start_position();
        let qname = format!("@router_register {router_var}");
        // `Kind::Module` is reused as a generic "structural marker"
        // kind. Routes are `Kind::Route`; this isn't one — it's a
        // registration record consumed by the post-pass and then
        // ignored by route queries.
        let id = node_id(ctx.repo_id, "python", &ctx.relpath, &qname, Kind::Module);
        ctx.out.symbols.push(Symbol {
            id,
            qname: qname.clone(),
            name: qname,
            kind: Kind::Module,
            file: ctx.relpath.clone(),
            line: pos.row as u32 + 1,
            col: pos.column as u32,
            is_public: false,
            doc: format!("@router_register router={router_var} prefix={prefix}"),
            hash: blake3::hash(format!("{router_var}{prefix}{}",ctx.relpath).as_bytes()).to_hex()[..16].to_string(),
        });
    }
}

/// Peel optional Python string-literal prefix (`b`, `r`, `f`, `u`, or
/// any combo like `rb`/`br`) ONCE, then strip exactly one leading +
/// trailing quote. Wave-7 CR1 fix: the previous greedy
/// `trim_start_matches(predicate)` stripped multiple chars (e.g.
/// `"books"` → `ooks`); this peels the prefix in one pass.
/// Strip Python string prefixes (`f`, `b`, `r`, `u`, `rb`, `br`, etc.) and
/// surrounding quote characters from a Python string literal token.
///
/// M4: only consume a prefix character if the next character is a quote (`"` or `'`).
/// This prevents accidentally eating the first content character when the token
/// is already unquoted or has an unexpected shape.
fn strip_python_string_literal(raw: &str) -> String {
    // Helper: try stripping a specific prefix, but only if what follows is a quote.
    fn strip_if_quoted<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
        let rest = s.strip_prefix(prefix)?;
        if rest.starts_with('"') || rest.starts_with('\'') {
            Some(rest)
        } else {
            None
        }
    }
    let stripped_prefix = strip_if_quoted(raw, "rb")
        .or_else(|| strip_if_quoted(raw, "br"))
        .or_else(|| strip_if_quoted(raw, "Rb"))
        .or_else(|| strip_if_quoted(raw, "bR"))
        .or_else(|| strip_if_quoted(raw, "BR"))
        .or_else(|| strip_if_quoted(raw, "rB"))
        .or_else(|| {
            // Single-char prefix: only strip if the next char is a quote.
            let mut chars = raw.chars();
            let first = chars.next()?;
            if matches!(first, 'b' | 'B' | 'r' | 'R' | 'f' | 'F' | 'u' | 'U')
                && matches!(chars.next(), Some('"') | Some('\''))
            {
                Some(&raw[first.len_utf8()..])
            } else {
                None
            }
        })
        .unwrap_or(raw);
    let no_lead = stripped_prefix
        .strip_prefix('"')
        .or_else(|| stripped_prefix.strip_prefix('\''))
        .unwrap_or(stripped_prefix);
    let no_trail = no_lead
        .strip_suffix('"')
        .or_else(|| no_lead.strip_suffix('\''))
        .unwrap_or(no_lead);
    no_trail.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// CR1 regression test (wave-7 fix): `route("books")` MUST extract
    /// `books`, not `ooks`. The previous greedy strip ate the leading
    /// `b` after the opening `"`.
    #[test]
    fn strip_python_string_books_not_ooks() {
        assert_eq!(strip_python_string_literal(r#""books""#), "books");
        assert_eq!(
            strip_python_string_literal(r#""favicon.ico""#),
            "favicon.ico"
        );
        assert_eq!(strip_python_string_literal(r#"'banana'"#), "banana");
        // f-string still gets prefix peeled exactly once.
        assert_eq!(strip_python_string_literal(r#"f"books""#), "books");
        // Combo prefix `rb`/`br`.
        assert_eq!(strip_python_string_literal(r#"rb"path""#), "path");
        assert_eq!(strip_python_string_literal(r#"br"path""#), "path");
        // Leading `/` paths (the typical case).
        assert_eq!(strip_python_string_literal(r#""/users""#), "/users");
    }
}
