// r8r: rust port of n8n.io
// Initial author: Michael Cochran <mcochran@cochranblock.org>
// Built for: Tanner Woodrum, intended cofounder
// License: Unlicense (public domain dedication, see UNLICENSE)
//
// Walks the n8n source tree and discovers `*.node.ts` files.
// Best-effort extraction of the description literal via regex/heuristic on a first pass;
// full swc_ecma_parser integration is the v0.2 hardening pass.

use super::spec::{CredSpec, NodeSpec, OperationSpec, RoutingSpec, SendSpec};
use anyhow::Result;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use walkdir::WalkDir;

pub fn discover_node_files(n8n_root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let scan_roots = [
        n8n_root.join("packages/nodes-base/nodes"),
        n8n_root.join("packages/@n8n/nodes-langchain/nodes"),
    ];
    for root in scan_roots {
        if !root.exists() { continue; }
        for entry in WalkDir::new(&root).into_iter().filter_map(Result::ok) {
            if !entry.file_type().is_file() { continue; }
            let name = entry.file_name().to_string_lossy();
            if name.ends_with(".node.ts") && !name.ends_with(".test.node.ts") {
                out.push(entry.path().to_path_buf());
            }
        }
    }
    out.sort();
    out
}

pub fn extract_one(path: &Path, n8n_root: &Path) -> Result<NodeSpec> {
    let raw = std::fs::read_to_string(path)?;
    // The node-level description block looks like:
    //   description: INodeTypeDescription = { displayName: 'Foo', name: 'foo', ... }
    // Anchor extraction at that marker. Property literals before the class
    // body have their own `displayName:` and `name:` fields — using the first
    // `displayName:` in the file picks those up by mistake (the Wait/Form bug).
    let anchor = raw.find(": INodeTypeDescription = {")
        .or_else(|| raw.find(":INodeTypeDescription={"))
        .or_else(|| {
            // Fallback: look for the class declaration and scope from there.
            ::regex_lite::Regex::new(r"class\s+\w+\s+implements\s+INodeType")
                .ok()
                .and_then(|re| re.find(&raw).map(|m| m.start()))
        })
        .or_else(|| raw.find("INodeTypeBaseDescription"))
        .unwrap_or(0);
    let scoped = &raw[anchor..];
    let display_name = grab_str(scoped, r#"displayName:\s*['"`]([^'"`]+)['"`]"#)
        .unwrap_or_else(|| stem_from_path(path));
    let name_machine = grab_str(scoped, r#"name:\s*['"`]([^'"`]+)['"`]"#)
        .unwrap_or_else(|| stem_from_path(path).to_lowercase());
    let description = grab_str(scoped, r#"description:\s*['"`]([^'"`]+)['"`]"#)
        .unwrap_or_default();

    let mut credentials = Vec::new();
    for caps in regex_iter(&raw, r#"name:\s*['"`]([A-Za-z0-9_]+)['"`],\s*required:\s*(true|false)"#) {
        if caps.len() >= 3 {
            credentials.push(CredSpec {
                name: caps[1].clone(),
                required: caps[2] == "true",
            });
        }
    }

    let properties = extract_properties(&raw);
    let routing = extract_routing(path, &raw, &credentials, n8n_root);

    Ok(NodeSpec {
        name: sanitize_machine_name(&name_machine),
        display_name,
        description,
        group: vec!["transform".into()],
        versions: vec![1.0],
        inputs: 1,
        outputs: 1,
        credentials,
        properties,
        source_path: path.display().to_string(),
        routing,
    })
}

/// Extract the n8n declarative routing data: `requestDefaults.baseURL`
/// from the .node.ts; per-operation `routing.request.{method,url,body}`
/// from the .node.ts plus all sibling .ts files in its directory tree
/// (n8n splits properties into descriptions/<resource>.resource.ts and
/// <op>.operation.ts files, all imported into the main .node.ts —
/// concatenating the sources catches the splits without resolving imports).
/// Follows the credential reference to pull `authenticate.properties.headers`
/// from the matching credentials file. Returns None when the node isn't
/// declarative — those stay as NotYetPorted stubs for hand-fill.
fn extract_routing(node_path: &Path, raw: &str, credentials: &[CredSpec], n8n_root: &Path) -> Option<RoutingSpec> {
    let combined = combine_node_sources(node_path, raw);
    let base_url = grab_str(&combined, r#"baseURL:\s*['"`]([^'"`]+)['"`]"#)?;
    let operations = extract_operations(&combined);
    let sends = extract_sends(&combined);
    let _ = node_path; // node_path now only used for combine_node_sources above

    // Pick the first credential whose .credentials.ts file we can find —
    // that's the auth path the routing runtime will use.
    let creds_index = credentials_index(n8n_root);
    let mut auth_headers: Vec<(String, String)> = Vec::new();
    let mut auth_query: Vec<(String, String)> = Vec::new();
    let mut credential_name: Option<String> = None;

    for cred in credentials {
        if let Some(cred_file) = creds_index.get(&cred.name) {
            let cred_raw = match std::fs::read_to_string(cred_file) {
                Ok(s) => s,
                Err(_) => continue,
            };
            // headers: { Authorization: '=Bearer {{$credentials.apiKey}}', ... }
            let headers_block = grab_balanced(&cred_raw, "headers:")
                .or_else(|| grab_balanced(&cred_raw, "headers :"));
            if let Some(block) = headers_block {
                for (k, v) in extract_kv_pairs(&block) {
                    auth_headers.push((k, normalize_template(&v)));
                }
            }
            let qs_block = grab_balanced(&cred_raw, "qs:")
                .or_else(|| grab_balanced(&cred_raw, "queryString:"));
            if let Some(block) = qs_block {
                for (k, v) in extract_kv_pairs(&block) {
                    auth_query.push((k, normalize_template(&v)));
                }
            }
            credential_name = Some(cred.name.clone());
            break;
        }
    }

    Some(RoutingSpec {
        base_url,
        auth_headers,
        auth_query,
        credential_name,
        operations,
        sends,
    })
}

/// Walk every `routing: { send: { type: '...', property: '...' } }`
/// occurrence and pair it with its enclosing property's `name: '...'`
/// (the parameter the user fills in workflow.toml). Capture
/// displayOptions.show.{operation,resource} so the runtime knows which
/// operations this property applies to.
fn extract_sends(raw: &str) -> Vec<SendSpec> {
    let mut out = Vec::new();
    let mut search_start = 0;
    while let Some(rel) = raw[search_start..].find("send:") {
        let send_idx = search_start + rel;
        let after = &raw[send_idx + "send:".len()..];
        let open = match after.find('{') { Some(i) => i, None => break };
        let body_start = send_idx + "send:".len() + open + 1;
        let body_end = match find_close_brace(raw, body_start) {
            Some(i) => i,
            None => { search_start = body_start; continue; }
        };
        let send_body = &raw[body_start..body_end];
        search_start = body_end + 1;

        // Heuristic: must be inside a `routing:` block. Easiest test:
        // the most recent `routing:` ancestor is closer than the most
        // recent `}` that would have closed it.
        if !is_inside_routing_block(raw, send_idx) { continue; }

        let send_type = match grab_str(send_body, r#"type:\s*['"`]([^'"`]+)['"`]"#) {
            Some(t) => t,
            None => continue,
        };
        let dest_key = grab_str(send_body, r#"property:\s*['"`]([^'"`]+)['"`]"#)
            .unwrap_or_default();

        // Walk backwards to find the enclosing property's `name: '...'`.
        let param_name = match nearest_prop_name_before(raw, send_idx) {
            Some(n) => n,
            None => continue,
        };

        let (ops, ress) = nearest_display_options_before(raw, send_idx);

        // Avoid duplicates
        if !out.iter().any(|s: &SendSpec| s.param_name == param_name && s.send_type == send_type) {
            out.push(SendSpec {
                param_name,
                send_type,
                dest_key: if dest_key.is_empty() { "".to_string() } else { dest_key },
                applies_to_operations: ops,
                applies_to_resources: ress,
            });
        }
    }
    out
}

fn is_inside_routing_block(raw: &str, idx: usize) -> bool {
    let lookback = 200.min(idx);
    let slice = &raw[idx - lookback..idx];
    // Cheap test: somewhere in the last 200 chars before `send:` there
    // should be `routing:` or `routing :`.
    slice.contains("routing:") || slice.contains("routing :")
}

fn nearest_prop_name_before(raw: &str, idx: usize) -> Option<String> {
    let lookback = 1500.min(idx);
    let slice = &raw[idx - lookback..idx];
    let re = ::regex_lite::Regex::new(r#"\bname:\s*['"`]([A-Za-z][A-Za-z0-9_]*)['"`]"#).ok()?;
    re.captures_iter(slice).last()
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().to_string())
}

fn nearest_display_options_before(raw: &str, idx: usize) -> (Vec<String>, Vec<String>) {
    let lookback = 2500.min(idx);
    let slice = &raw[idx - lookback..idx];
    let mut ops = Vec::new();
    let mut ress = Vec::new();
    if let Some(do_idx) = slice.rfind("displayOptions:") {
        let after = &slice[do_idx..];
        if let Some(show_block) = grab_balanced_inner(after, "show:") {
            ops = extract_string_array(&show_block, "operation:");
            ress = extract_string_array(&show_block, "resource:");
        }
    }
    (ops, ress)
}

fn extract_string_array(body: &str, key: &str) -> Vec<String> {
    let pos = match body.find(key) { Some(p) => p, None => return Vec::new() };
    let after = &body[pos + key.len()..];
    let open = match after.find('[') { Some(i) => i, None => return Vec::new() };
    let array_start = pos + key.len() + open + 1;
    let bytes = body.as_bytes();
    let mut depth = 1i32;
    let mut i = array_start;
    let mut in_str: Option<u8> = None;
    let mut prev_bs = false;
    while i < bytes.len() {
        let c = bytes[i];
        if let Some(q) = in_str {
            if c == q && !prev_bs { in_str = None; }
            prev_bs = c == b'\\' && !prev_bs;
            i += 1; continue;
        }
        prev_bs = false;
        match c {
            b'\'' | b'"' | b'`' => in_str = Some(c),
            b'[' => depth += 1,
            b']' => {
                depth -= 1;
                if depth == 0 {
                    let inner = &body[array_start..i];
                    let re = ::regex_lite::Regex::new(r#"['"`]([^'"`]+)['"`]"#).ok();
                    return match re {
                        Some(r) => r.captures_iter(inner)
                            .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
                            .collect(),
                        None => Vec::new(),
                    };
                }
            }
            _ => {}
        }
        i += 1;
    }
    Vec::new()
}

/// Concatenate the .node.ts and all sibling .ts files (excluding tests)
/// into a single haystack. n8n splits properties across
/// descriptions/*.ts files imported into the .node.ts; reading them all
/// catches everything without resolving imports.
fn combine_node_sources(node_path: &Path, primary_raw: &str) -> String {
    let dir = match node_path.parent() {
        Some(d) => d,
        None => return primary_raw.to_string(),
    };
    let mut out = String::with_capacity(primary_raw.len() * 2);
    out.push_str(primary_raw);
    out.push('\n');
    for entry in WalkDir::new(dir).max_depth(4).into_iter().filter_map(Result::ok) {
        if !entry.file_type().is_file() { continue; }
        let p = entry.path();
        if p == node_path { continue; }
        let name = match p.file_name().and_then(|s| s.to_str()) {
            Some(n) => n,
            None => continue,
        };
        if !name.ends_with(".ts") { continue; }
        if name.ends_with(".test.ts") || name.ends_with(".spec.ts") { continue; }
        if name.ends_with(".node.ts") { continue; }  // a different node, would pollute
        if name.ends_with(".credentials.ts") { continue; }  // credential files handled separately
        if let Ok(s) = std::fs::read_to_string(p) {
            out.push_str(&s);
            out.push('\n');
        }
    }
    out
}

/// Find every `routing: { request: { method: '...', url: '...', body: {...}? } }`
/// occurrence and pair it with the nearest preceding `value: '...'` (the
/// option value that selects this routing). Returns the list of
/// OperationSpec entries.
fn extract_operations(raw: &str) -> Vec<OperationSpec> {
    let mut out = Vec::new();
    let mut search_start = 0;
    while let Some(rel) = raw[search_start..].find("routing:") {
        let routing_idx = search_start + rel;
        // Find the opening brace after `routing:`
        let after = &raw[routing_idx + "routing:".len()..];
        let open = match after.find('{') { Some(i) => i, None => break };
        let body_start = routing_idx + "routing:".len() + open + 1;
        let body_end = match find_close_brace(raw, body_start) {
            Some(i) => i,
            None => { search_start = body_start; continue; }
        };
        let routing_body = &raw[body_start..body_end];
        search_start = body_end + 1;

        // Inside the routing block, find a `request: { ... }` sub-block
        let req = match grab_balanced_inner(routing_body, "request:") {
            Some(b) => b,
            None => continue,
        };
        let method = match grab_str(&req, r#"method:\s*['"`]([^'"`]+)['"`]"#) { Some(m) => m, None => continue };
        let url = match grab_str(&req, r#"url:\s*['"`=]+([^'"`]+)['"`]"#) { Some(u) => u, None => continue };
        let body = grab_balanced_inner(&req, "body:")
            .map(|b| b.trim().to_string())
            .unwrap_or_default();

        // Skip loadOptions inner routing (dynamic dropdown sources).
        // These have a `responseItem` / `=` template as their `value`
        // because the option itself is generated from the API response.
        if is_load_options_block(raw, routing_idx) { continue; }

        // Walk backwards from routing_idx to find the nearest `value: '...'`
        // — that's the option this routing belongs to.
        if let Some(value) = nearest_value_before(raw, routing_idx) {
            if value.starts_with("={{") || value.contains("$response") {
                continue;
            }
            // Avoid duplicates: same value shouldn't appear twice
            if !out.iter().any(|o: &OperationSpec| o.value == value) {
                out.push(OperationSpec { value, method, url, body });
            }
        }
    }
    out
}

/// Returns true if `routing:` at `idx` is nested inside a `loadOptions:`
/// context (n8n uses these for dynamic dropdown population — they hit
/// the API at editor-load time, not at workflow-execute time).
fn is_load_options_block(raw: &str, idx: usize) -> bool {
    let lookback = 800.min(idx);
    let slice = &raw[idx - lookback..idx];
    // Cheap heuristic: the most recent of `loadOptions:` vs `options:`
    // before the routing position. If loadOptions wins, this is a noise block.
    let lo = slice.rfind("loadOptions:");
    let opts = slice.rfind("\noptions:").or_else(|| slice.rfind("\toptions:"));
    match (lo, opts) {
        (Some(l), Some(o)) => l > o,
        (Some(_), None) => true,
        _ => false,
    }
}

fn grab_balanced_inner(raw: &str, key: &str) -> Option<String> {
    let pos = raw.find(key)?;
    let after = &raw[pos + key.len()..];
    let open = after.find('{')?;
    let body_start = pos + key.len() + open + 1;
    let end = find_close_brace(raw, body_start)?;
    Some(raw[body_start..end].to_string())
}

fn find_close_brace(raw: &str, body_start: usize) -> Option<usize> {
    let bytes = raw.as_bytes();
    let mut depth = 1i32;
    let mut i = body_start;
    let mut in_str: Option<u8> = None;
    let mut prev_backslash = false;
    while i < bytes.len() {
        let ch = bytes[i];
        if let Some(q) = in_str {
            if ch == q && !prev_backslash { in_str = None; }
            prev_backslash = ch == b'\\' && !prev_backslash;
            i += 1; continue;
        }
        prev_backslash = false;
        match ch {
            b'\'' | b'"' | b'`' => in_str = Some(ch),
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 { return Some(i); }
            }
            _ => {}
        }
        i += 1;
    }
    None
}

fn nearest_value_before(raw: &str, idx: usize) -> Option<String> {
    // Limit search to a reasonable lookback (avoid pulling some unrelated
    // property's `value:` from a different option object).
    let lookback = 1500.min(idx);
    let slice = &raw[idx - lookback..idx];
    let re = ::regex_lite::Regex::new(r#"value:\s*['"`]([^'"`]+)['"`]"#).ok()?;
    re.captures_iter(slice).last()
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().to_string())
}

/// Map credential machine name → file path. Indexes
/// `<n8n_root>/packages/nodes-base/credentials/*.credentials.ts` and
/// `<n8n_root>/packages/@n8n/nodes-langchain/credentials/*.credentials.ts`.
/// Discovery key: each file's `name = '...';` line.
fn credentials_index(n8n_root: &Path) -> &'static HashMap<String, PathBuf> {
    static CACHE: OnceLock<HashMap<String, PathBuf>> = OnceLock::new();
    let n8n_root = n8n_root.to_path_buf();
    CACHE.get_or_init(move || {
        let mut idx = HashMap::new();
        let dirs = [
            n8n_root.join("packages/nodes-base/credentials"),
            n8n_root.join("packages/@n8n/nodes-langchain/credentials"),
        ];
        for dir in &dirs {
            if !dir.is_dir() { continue; }
            for entry in WalkDir::new(dir).into_iter().filter_map(Result::ok) {
                if !entry.file_type().is_file() { continue; }
                let name = entry.file_name().to_string_lossy().into_owned();
                if !name.ends_with(".credentials.ts") { continue; }
                if let Ok(raw) = std::fs::read_to_string(entry.path()) {
                    if let Some(machine) = grab_str(&raw, r#"name\s*=\s*['"`]([^'"`]+)['"`]"#) {
                        idx.insert(machine, entry.path().to_path_buf());
                    }
                }
            }
        }
        eprintln!("[codegen] credentials index: {} entries", idx.len());
        idx
    })
}

/// From a TS/JS source, find `<key>: { ... }` and return the matched
/// braces' contents (without the outer braces).
fn grab_balanced(raw: &str, key: &str) -> Option<String> {
    let pos = raw.find(key)?;
    let after = &raw[pos + key.len()..];
    let open_offset = after.find('{')?;
    let body_start = pos + key.len() + open_offset + 1;
    let bytes = raw.as_bytes();
    let mut depth = 1i32;
    let mut i = body_start;
    let mut in_str: Option<u8> = None;
    let mut prev_backslash = false;
    while i < bytes.len() {
        let ch = bytes[i];
        if let Some(q) = in_str {
            if ch == q && !prev_backslash { in_str = None; }
            prev_backslash = ch == b'\\' && !prev_backslash;
            i += 1; continue;
        }
        prev_backslash = false;
        match ch {
            b'\'' | b'"' | b'`' => in_str = Some(ch),
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 {
                    return Some(raw[body_start..i].to_string());
                }
            }
            _ => {}
        }
        i += 1;
    }
    None
}

/// Walk a TS object literal body and pull `key: 'value'` and
/// `'Key Name': 'value'` pairs at depth 0. Skips nested objects.
fn extract_kv_pairs(body: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    let bytes = body.as_bytes();
    let mut i = 0;
    let mut depth = 0i32;
    let mut in_str: Option<u8> = None;
    let mut prev_backslash = false;
    while i < bytes.len() {
        let ch = bytes[i];
        if let Some(q) = in_str {
            if ch == q && !prev_backslash { in_str = None; }
            prev_backslash = ch == b'\\' && !prev_backslash;
            i += 1; continue;
        }
        prev_backslash = false;
        match ch {
            b'{' | b'[' | b'(' => depth += 1,
            b'}' | b']' | b')' => depth -= 1,
            b'\'' | b'"' | b'`' => in_str = Some(ch),
            _ => {}
        }
        if depth == 0 && (ch.is_ascii_alphabetic() || ch == b'_' || ch == b'\'' || ch == b'"') {
            // Try to parse a key-value at this position
            if let Some((k, v, len)) = parse_kv(&body[i..]) {
                out.push((k, v));
                i += len;
                continue;
            }
        }
        i += 1;
    }
    out
}

fn parse_kv(s: &str) -> Option<(String, String, usize)> {
    let bytes = s.as_bytes();
    let mut i = 0;
    // Optional quote
    let key_quote = if matches!(bytes.first(), Some(b'\'') | Some(b'"') | Some(b'`')) {
        let q = bytes[0]; i = 1; Some(q)
    } else { None };
    let key_start = i;
    while i < bytes.len() {
        let c = bytes[i];
        match key_quote {
            Some(q) => if c == q { break; }
            None => if !(c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b' ') { break; }
        }
        i += 1;
    }
    if i == key_start { return None; }
    let key = s[key_start..i].trim().to_string();
    if let Some(_) = key_quote { i += 1; }
    while i < bytes.len() && (bytes[i] as char).is_ascii_whitespace() { i += 1; }
    if i >= bytes.len() || bytes[i] != b':' { return None; }
    i += 1;
    while i < bytes.len() && (bytes[i] as char).is_ascii_whitespace() { i += 1; }
    if i >= bytes.len() { return None; }
    // Value must be a string literal at depth 0
    let val_quote = match bytes[i] { b'\'' | b'"' | b'`' => bytes[i], _ => return None };
    i += 1;
    let val_start = i;
    let mut prev_bs = false;
    while i < bytes.len() {
        let c = bytes[i];
        if c == val_quote && !prev_bs { break; }
        prev_bs = c == b'\\' && !prev_bs;
        i += 1;
    }
    if i >= bytes.len() { return None; }
    let val = s[val_start..i].to_string();
    i += 1;
    // Eat trailing comma/whitespace
    while i < bytes.len() && matches!(bytes[i], b',' | b' ' | b'\n' | b'\t' | b'\r') { i += 1; }
    Some((key, val, i))
}

/// n8n template syntax is `={{$credentials.apiKey}}` (note the leading `=`
/// for "expression mode"). Convert to our runtime's `{{apiKey}}` form.
fn normalize_template(s: &str) -> String {
    let s = s.strip_prefix('=').unwrap_or(s);
    let mut out = String::with_capacity(s.len());
    let mut rest = s;
    while let Some(start) = rest.find("{{") {
        out.push_str(&rest[..start]);
        let body_start = start + 2;
        match rest[body_start..].find("}}") {
            Some(end) => {
                let inner = rest[body_start..body_start + end].trim();
                let key = inner
                    .strip_prefix("$credentials.").unwrap_or(inner)
                    .strip_prefix("$parameter.").unwrap_or(inner.strip_prefix("$credentials.").unwrap_or(inner))
                    .trim();
                out.push_str("{{");
                out.push_str(key);
                out.push_str("}}");
                rest = &rest[body_start + end + 2..];
            }
            None => { out.push_str(&rest[start..]); break; }
        }
    }
    out.push_str(rest);
    out
}

/// Pull top-level property objects out of the `properties: [...]` array.
/// Best-effort: balanced-brace scan + regex per object. Skips entries that
/// don't have at least a `name` field. Misses spread-imported arrays
/// (e.g. `properties: [...common, {...}]`) — those resolve to upstream files
/// the AST pass would chase. Acceptable v0.1 limitation.
fn extract_properties(raw: &str) -> serde_json::Value {
    let needle = "properties: [";
    let start = match raw.find(needle) {
        Some(i) => i + needle.len(),
        None => return serde_json::Value::Array(vec![]),
    };
    let end = match find_balanced(raw, start - 1, '[', ']') {
        Some(i) => i,
        None => return serde_json::Value::Array(vec![]),
    };
    let body = &raw[start..end];
    let mut out = Vec::new();
    let mut depth = 0i32;
    let mut start_obj: Option<usize> = None;
    let mut in_str: Option<char> = None;
    let mut prev_backslash = false;
    for (i, ch) in body.char_indices() {
        if let Some(q) = in_str {
            if ch == q && !prev_backslash { in_str = None; }
            prev_backslash = ch == '\\' && !prev_backslash;
            continue;
        }
        prev_backslash = false;
        match ch {
            '\'' | '"' | '`' => in_str = Some(ch),
            '{' => { if depth == 0 { start_obj = Some(i); } depth += 1; }
            '}' => {
                depth -= 1;
                if depth == 0 {
                    if let Some(s) = start_obj.take() {
                        let obj = &body[s..=i];
                        if let Some(p) = parse_property_obj(obj) { out.push(p); }
                    }
                }
            }
            _ => {}
        }
    }
    serde_json::Value::Array(out)
}

fn parse_property_obj(obj: &str) -> Option<serde_json::Value> {
    let name = grab_str(obj, r#"name:\s*['"`]([^'"`]+)['"`]"#)?;
    let display_name = grab_str(obj, r#"displayName:\s*['"`]([^'"`]+)['"`]"#)
        .unwrap_or_else(|| name.clone());
    let typ = grab_str(obj, r#"\btype:\s*['"`]([^'"`]+)['"`]"#)
        .unwrap_or_else(|| "string".into());
    let required = obj.contains("required: true");
    let default = grab_str(obj, r#"default:\s*['"`]([^'"`]*)['"`]"#);
    let description = grab_str(obj, r#"description:\s*['"`]([^'"`]+)['"`]"#);
    let mut o = serde_json::Map::new();
    o.insert("name".into(), serde_json::Value::String(name));
    o.insert("display_name".into(), serde_json::Value::String(display_name));
    o.insert("kind".into(), serde_json::Value::String(typ));
    o.insert("required".into(), serde_json::Value::Bool(required));
    if let Some(d) = default { o.insert("default".into(), serde_json::Value::String(d)); }
    if let Some(d) = description { o.insert("description".into(), serde_json::Value::String(d)); }
    Some(serde_json::Value::Object(o))
}

fn find_balanced(raw: &str, open_idx: usize, open: char, close: char) -> Option<usize> {
    let bytes = raw.as_bytes();
    if bytes.get(open_idx).copied() != Some(open as u8) { return None; }
    let mut depth = 0i32;
    let mut in_str: Option<u8> = None;
    let mut prev_backslash = false;
    for i in open_idx..bytes.len() {
        let ch = bytes[i];
        if let Some(q) = in_str {
            if ch == q && !prev_backslash { in_str = None; }
            prev_backslash = ch == b'\\' && !prev_backslash;
            continue;
        }
        prev_backslash = false;
        match ch {
            b'\'' | b'"' | b'`' => in_str = Some(ch),
            c if c == open as u8 => depth += 1,
            c if c == close as u8 => {
                depth -= 1;
                if depth == 0 { return Some(i); }
            }
            _ => {}
        }
    }
    None
}

fn stem_from_path(path: &Path) -> String {
    path.file_name().unwrap().to_string_lossy()
        .trim_end_matches(".node.ts").to_string()
}

fn sanitize_machine_name(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); }
        else if matches!(c, '_' | '-') { out.push('_'); }
    }
    if out.is_empty() { "unnamed".into() } else { out }
}

fn grab_str(haystack: &str, pat: &str) -> Option<String> {
    let re = ::regex_lite::Regex::new(pat).ok()?;
    re.captures(haystack).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())
}

fn regex_iter(haystack: &str, pat: &str) -> Vec<Vec<String>> {
    let re = match ::regex_lite::Regex::new(pat) {
        Ok(r) => r,
        Err(_) => return Vec::new(),
    };
    re.captures_iter(haystack)
        .map(|c| c.iter().map(|m| m.map(|m| m.as_str().to_string()).unwrap_or_default()).collect())
        .collect()
}

