//! gRPC / Protocol Buffers `.proto` extractor.
//!
//! Emits Symbols for `service`, `message`, `enum` and one `Method` per `rpc`.
//! Implements edges from rpc method to its request type and from service to
//! each method (Defines).
//!
//! Text-scanning approach (no tree-sitter dep) — proto's grammar for
//! top-level declarations is tiny and stable across proto2/proto3.

use std::path::Path;
use std::sync::LazyLock;

use ckg_core::{Edge, EdgeKind, Kind, Result, Symbol, node_id};
use regex::Regex;

use crate::ParseOutput;

// Compile each pattern once. See `schema/graphql.rs` for the same
// rationale (recompilation per file dominates extract time on large
// repos).
static SVC_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?ms)^\s*service\s+(\w+)\s*\{(.*?)^\s*\}").expect("static literal")
});
static RPC_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?m)\brpc\s+(\w+)\s*\(\s*(?:stream\s+)?([\w.]+)\s*\)\s*returns\s*\(\s*(?:stream\s+)?([\w.]+)\s*\)")
        .expect("static literal")
});
static MSG_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?m)^\s*message\s+(\w+)").expect("static literal"));
static PROTO_ENUM_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?m)^\s*enum\s+(\w+)").expect("static literal"));
static IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"(?m)^\s*import\s+(?:public\s+|weak\s+)?"([^"]+)""#).expect("static literal")
});
static PACKAGE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?m)^\s*package\s+([\w.]+)\s*;").expect("static literal"));

pub fn extract(repo_id: &str, path: &Path, source: &str) -> Result<ParseOutput> {
    let mut out = ParseOutput::default();
    let relpath = crate::util::path_to_relpath(path)?;
    // PARSE-H2: strip comments before regex extraction. Proto regexes
    // match against raw text; a `message Foo {}` inside a block comment
    // or after `//` would produce ghost symbols.
    let clean_source = strip_proto_comments(source);
    let source = &clean_source;
    let pkg = extract_package(source).unwrap_or_default();

    let svc_re = &*SVC_RE;
    let rpc_re = &*RPC_RE;
    let msg_re = &*MSG_RE;
    let enum_re = &*PROTO_ENUM_RE;
    let import_re = &*IMPORT_RE;

    let qual = |name: &str| -> String {
        if pkg.is_empty() {
            name.to_string()
        } else {
            format!("{pkg}.{name}")
        }
    };

    for cap in import_re.captures_iter(source) {
        let imp = &cap[1];
        out.edges.push(Edge {
            kind: EdgeKind::Imports,
            src: relpath.clone(),
            dst: imp.to_string(),
            confidence: 1.0,
        });
    }

    for cap in svc_re.captures_iter(source) {
        let svc_name = cap[1].to_string();
        let body = &cap[2];
        let svc_qname = qual(&svc_name);
        let line = line_of(source, cap.get(0).unwrap().start());
        let svc_id = push_symbol(
            &mut out,
            repo_id,
            &relpath,
            Kind::Class,
            svc_qname.clone(),
            svc_name,
            line,
        );
        for rpc in rpc_re.captures_iter(body) {
            let mname = rpc[1].to_string();
            let req = rpc[2].to_string();
            let resp = rpc[3].to_string();
            // PARSE-H1: use local `svc_qname` instead of fragile `symbols.last().unwrap()`.
            let mqname = format!("{svc_qname}.{mname}");
            let mline = line_of(source, rpc.get(0).unwrap().start());
            let mid = push_symbol(
                &mut out,
                repo_id,
                &relpath,
                Kind::Method,
                mqname,
                mname,
                mline,
            );
            out.edges.push(Edge {
                kind: EdgeKind::Defines,
                src: svc_id.clone(),
                dst: mid.clone(),
                confidence: 1.0,
            });
            // The request and response types are referenced — emit Imports.
            out.edges.push(Edge {
                kind: EdgeKind::Imports,
                src: mid.clone(),
                dst: qual(&req),
                confidence: 1.0,
            });
            out.edges.push(Edge {
                kind: EdgeKind::Imports,
                src: mid,
                dst: qual(&resp),
                confidence: 1.0,
            });
        }
    }

    for cap in msg_re.captures_iter(source) {
        let n = cap[1].to_string();
        let line = line_of(source, cap.get(0).unwrap().start());
        push_symbol(&mut out, repo_id, &relpath, Kind::Struct, qual(&n), n, line);
    }
    for cap in enum_re.captures_iter(source) {
        let n = cap[1].to_string();
        let line = line_of(source, cap.get(0).unwrap().start());
        push_symbol(&mut out, repo_id, &relpath, Kind::Enum, qual(&n), n, line);
    }
    Ok(out)
}

fn extract_package(src: &str) -> Option<String> {
    PACKAGE_RE.captures(src).map(|c| c[1].to_string())
}

fn line_of(src: &str, byte_offset: usize) -> u32 {
    src[..byte_offset].matches('\n').count() as u32 + 1
}

fn push_symbol(
    out: &mut ParseOutput,
    repo_id: &str,
    relpath: &str,
    kind: Kind,
    qname: String,
    name: String,
    line: u32,
) -> String {
    let id = node_id(repo_id, "proto", relpath, &qname, kind);
    out.symbols.push(Symbol {
        id: id.clone(),
        qname: qname.clone(),
        name,
        kind,
        file: relpath.to_string(),
        line,
        col: 0,
        is_public: true,
        doc: String::new(),
        // PARSE-C2: compute identity hash so incremental indexer can
        // detect unchanged symbols and skip re-indexing.
        hash: blake3::hash(format!("{qname}:{kind:?}:{relpath}").as_bytes()).to_hex()[..16]
            .to_string(),
    });
    id
}

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

    #[test]
    fn extracts_service_rpc_messages() {
        let src = r#"
syntax = "proto3";
package my.api;

import "common.proto";

message GreetReq { string name = 1; }
message GreetResp { string greeting = 1; }

enum Color { RED = 0; GREEN = 1; }

service Greeter {
    rpc Greet (GreetReq) returns (GreetResp);
    rpc Stream (stream GreetReq) returns (stream GreetResp);
}
"#;
        let out = extract("repo", Path::new("greeter.proto"), src).unwrap();
        let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
        for expected in [
            "GreetReq",
            "GreetResp",
            "Color",
            "Greeter",
            "Greet",
            "Stream",
        ] {
            assert!(names.contains(&expected), "missing {expected}: {names:?}");
        }
        // package qualification
        let svc = out.symbols.iter().find(|s| s.name == "Greeter").unwrap();
        assert_eq!(svc.qname, "my.api.Greeter");
        // Imports edge for the import statement
        assert!(
            out.edges
                .iter()
                .any(|e| matches!(e.kind, EdgeKind::Imports) && e.dst == "common.proto")
        );
        // Defines edge from service to method
        assert!(out.edges.iter().any(|e| matches!(e.kind, EdgeKind::Defines)
            && e.src.contains("Greeter")
            && e.dst.contains("Greet:method")));
    }

    #[test]
    fn block_comment_message_ignored() {
        let src = r#"
syntax = "proto3";
/* message Ghost {} */
message Real {}
// message AlsoGhost {}
"#;
        let out = extract("repo", Path::new("test.proto"), src).unwrap();
        let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
        assert!(names.contains(&"Real"), "Real must be present: {names:?}");
        assert!(!names.contains(&"Ghost"), "Ghost inside block comment must be stripped: {names:?}");
        assert!(!names.contains(&"AlsoGhost"), "AlsoGhost after // must be stripped: {names:?}");
    }
}

/// PARSE-H2: Strip C-style (`// …`, `/* … */`) comments from proto source.
///
/// Respects string literal boundaries: `"http://example.com"` is not treated
/// as a comment. State machine: NORMAL → IN_LINE_COMMENT → IN_BLOCK_COMMENT.
/// Line numbers are preserved by replacing comment content with spaces (not
/// stripping) so `line_of()` offsets remain valid.
fn strip_proto_comments(source: &str) -> String {
    let bytes = source.as_bytes();
    let len = bytes.len();
    let mut out = Vec::with_capacity(len);
    let mut i = 0;
    let mut in_string = false;
    let mut in_line_comment = false;
    let mut in_block_comment = false;

    while i < len {
        if in_line_comment {
            if bytes[i] == b'\n' {
                in_line_comment = false;
                out.push(b'\n');
            } else {
                out.push(b' ');
            }
            i += 1;
            continue;
        }
        if in_block_comment {
            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
                in_block_comment = false;
                out.push(b' ');
                out.push(b' ');
                i += 2;
            } else {
                // Preserve newlines for line_of() accuracy.
                out.push(if bytes[i] == b'\n' { b'\n' } else { b' ' });
                i += 1;
            }
            continue;
        }
        if bytes[i] == b'"' {
            in_string = !in_string;
            out.push(bytes[i]);
            i += 1;
            continue;
        }
        if in_string {
            // Inside a string literal: pass through verbatim.
            // Handle escaped quote.
            if bytes[i] == b'\\' && i + 1 < len {
                out.push(bytes[i]);
                out.push(bytes[i + 1]);
                i += 2;
            } else {
                out.push(bytes[i]);
                i += 1;
            }
            continue;
        }
        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' {
            in_line_comment = true;
            out.push(b' ');
            out.push(b' ');
            i += 2;
            continue;
        }
        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
            in_block_comment = true;
            out.push(b' ');
            out.push(b' ');
            i += 2;
            continue;
        }
        out.push(bytes[i]);
        i += 1;
    }
    // SAFETY: we only replaced ASCII bytes with ASCII spaces/newlines.
    String::from_utf8(out).unwrap_or_else(|_| source.to_string())
}
