//! Member expression lowering: `ast::Expr::Member`.
//!
//! Tier 2.3 round 3 (v0.5.339) — extracts the 405-LOC `Member` arm
//! from `lower_expr`. Member expressions cover `obj.prop`,
//! `obj["key"]`, `obj[i]`, the namespace-form `Math.PI`, enum member
//! access (`Color.Red`), private field reads (`#field`), and a fast
//! path for `Symbol.iterator` / `Symbol.asyncIterator` / friends.
//! The arm is mostly a long match cascade: identify the receiver kind
//! (regular object vs class static vs enum vs builtin namespace) then
//! emit the right HIR variant.

use anyhow::Result;
use perry_types::Type;
use swc_ecma_ast as ast;

use crate::ir::Expr;

use super::{lower_expr, LoweringContext};

pub(super) fn lower_member(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Result<Expr> {
    // Issue #444: `import.meta.<prop>` folds directly to a literal at
    // lowering time. Routing through the bare-`import.meta` Object
    // synthesis hits a long-standing module-level NaN-boxing bug where
    // string fields read back as 0 — producing `url: 0` / `main: NaN`
    // for the user. Folding here sidesteps it entirely.
    //
    // Surface aligned with Node 20+ spec (`url` / `dirname` / `filename`
    // / `main`). Bun-only aliases (`dir` / `path` / `file`) intentionally
    // omitted — adding them would silently break code moving Perry → Node.
    if let ast::Expr::MetaProp(mp) = member.obj.as_ref() {
        if matches!(mp.kind, ast::MetaPropKind::ImportMeta) {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let (url, dirname, filename) = super::expr_misc::import_meta_paths(ctx);
                return Ok(match prop_ident.sym.as_ref() {
                    "url" => Expr::String(url),
                    "main" => Expr::Bool(ctx.is_entry_module),
                    "dirname" => Expr::String(dirname),
                    "filename" => Expr::String(filename),
                    // Unknown property — undefined matches the spec'd
                    // "missing property on a frozen object" behavior of
                    // import.meta in Node / Bun.
                    _ => Expr::Undefined,
                });
            }
        }
        // Issue #449: `new.target.<prop>` folds directly to a literal at
        // lowering time. The bare `MetaProp(NewTarget)` lowering in
        // `expr_misc::lower_meta_prop` returns an Object literal whose
        // string field reads back as the raw u64 handle bits (rendering
        // as `2e-323` / `NaN`) when constructed inside a class
        // constructor — same module-globals NaN-boxing bug class as
        // #444's `import.meta` Object. Folding the most common access
        // patterns here sidesteps it entirely. Inside a constructor,
        // `.name` is the class name string; outside, the whole
        // expression evaluates to `undefined.<prop>` which would throw
        // — but `new.target` outside a constructor is `undefined`, so
        // we lower the access to `Undefined` and let downstream
        // optional-chain rewrites (`new.target?.name`) handle the
        // null-guard correctly.
        if matches!(mp.kind, ast::MetaPropKind::NewTarget) {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let prop_name = prop_ident.sym.as_ref();
                if let Some(class_name) = ctx.in_constructor_class.clone() {
                    return Ok(match prop_name {
                        "name" => Expr::String(class_name),
                        // Other props on a class reference (`prototype`,
                        // arbitrary) — undefined is the safe fallback;
                        // adding `prototype` would need a real class
                        // reference, not in scope for #449.
                        _ => Expr::Undefined,
                    });
                }
                // Outside a constructor: `new.target` is undefined and
                // `undefined.<prop>` throws TypeError. We model the
                // observable result as Undefined (matches Node when
                // wrapped in `new.target?.<prop>` short-circuiting).
                return Ok(Expr::Undefined);
            }
        }
    }

    // process.std{in,out,err}.{isTTY,columns,rows} — direct extern-call
    // shapes recognized BEFORE the regular process.X arm below, since the
    // double-Member shape (Member(Member(process, stream), prop)) doesn't
    // match the simple `process.X` Ident-then-prop dispatch. (#347 Phase 3.)
    if let ast::Expr::Member(inner_member) = member.obj.as_ref() {
        if let ast::Expr::Ident(root_ident) = inner_member.obj.as_ref() {
            if root_ident.sym.as_ref() == "process" {
                if let (ast::MemberProp::Ident(stream_ident), ast::MemberProp::Ident(prop_ident)) =
                    (&inner_member.prop, &member.prop)
                {
                    let stream = stream_ident.sym.as_ref();
                    let prop = prop_ident.sym.as_ref();
                    match (stream, prop) {
                        ("stdin", "isTTY") => return Ok(Expr::ProcessStdinIsTTY),
                        ("stdout", "isTTY") => return Ok(Expr::ProcessStdoutIsTTY),
                        ("stderr", "isTTY") => return Ok(Expr::ProcessStderrIsTTY),
                        ("stdout", "columns") => return Ok(Expr::ProcessStdoutColumns),
                        ("stdout", "rows") => return Ok(Expr::ProcessStdoutRows),
                        _ => {}
                    }
                }
            }
        }
    }

    // Check if this is process.* property access
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        if obj_ident.sym.as_ref() == "process" {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                match prop_ident.sym.as_ref() {
                    "argv" => return Ok(Expr::ProcessArgv),
                    "platform" => return Ok(Expr::OsPlatform),
                    "arch" => return Ok(Expr::OsArch),
                    "pid" => return Ok(Expr::ProcessPid),
                    "ppid" => return Ok(Expr::ProcessPpid),
                    "version" => return Ok(Expr::ProcessVersion),
                    "versions" => return Ok(Expr::ProcessVersions),
                    "stdin" => return Ok(Expr::ProcessStdin),
                    "stdout" => return Ok(Expr::ProcessStdout),
                    "stderr" => return Ok(Expr::ProcessStderr),
                    "env" => return Ok(Expr::ProcessEnv),
                    _ => {}
                }
            }
        }
        // `globalThis.process` returns an object whose `.env`/`.argv`/
        // etc. should resolve just like bare `process.*`. Without this
        // shim, `globalThis.process.env` walks through generic
        // PropertyGet dispatch and hits a 0.0 sentinel. Matches the
        // static `process.env` fast path above.
        if obj_ident.sym.as_ref() == "globalThis" {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                if prop_ident.sym.as_ref() == "process" {
                    // `globalThis.process` on its own — fall through
                    // to generic handling below (returns 0.0 sentinel,
                    // which is fine as the outer chain handles env/etc.).
                }
            }
        }
    }
    // Handle `globalThis.process.X` (and any PropertyGet whose object
    // resolves to `globalThis.process`): treat the outer `.X` as if
    // it were a bare `process.X` access. Unwraps transparent TS
    // wrappers (TsAs, TsNonNull, TsSatisfies, TsTypeAssertion, Paren)
    // so that `(globalThis as any).process.env` works too.
    fn unwrap_transparent(e: &ast::Expr) -> &ast::Expr {
        let mut cur = e;
        loop {
            match cur {
                ast::Expr::TsAs(x) => cur = &x.expr,
                ast::Expr::TsNonNull(x) => cur = &x.expr,
                ast::Expr::TsSatisfies(x) => cur = &x.expr,
                ast::Expr::TsTypeAssertion(x) => cur = &x.expr,
                ast::Expr::TsConstAssertion(x) => cur = &x.expr,
                ast::Expr::Paren(x) => cur = &x.expr,
                _ => return cur,
            }
        }
    }
    let member_obj_unwrapped = unwrap_transparent(member.obj.as_ref());
    if let ast::Expr::Member(inner) = member_obj_unwrapped {
        let inner_obj_unwrapped = unwrap_transparent(inner.obj.as_ref());
        let inner_is_global_process = matches!(
            inner_obj_unwrapped,
            ast::Expr::Ident(i) if i.sym.as_ref() == "globalThis"
        ) && matches!(
            &inner.prop,
            ast::MemberProp::Ident(p) if p.sym.as_ref() == "process"
        );
        if inner_is_global_process {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                match prop_ident.sym.as_ref() {
                    "argv" => return Ok(Expr::ProcessArgv),
                    "platform" => return Ok(Expr::OsPlatform),
                    "arch" => return Ok(Expr::OsArch),
                    "pid" => return Ok(Expr::ProcessPid),
                    "ppid" => return Ok(Expr::ProcessPpid),
                    "version" => return Ok(Expr::ProcessVersion),
                    "versions" => return Ok(Expr::ProcessVersions),
                    "env" => return Ok(Expr::ProcessEnv),
                    _ => {}
                }
            }
        }
    }

    // Check if this is Symbol.<well-known> — Symbol.toPrimitive,
    // Symbol.hasInstance, Symbol.toStringTag, Symbol.iterator,
    // Symbol.asyncIterator, Symbol.dispose, Symbol.asyncDispose.
    // Lowered to `SymbolFor(String("@@__perry_wk_<name>"))` which the
    // runtime's `js_symbol_for` sniffs via prefix and resolves from
    // the well-known cache (not the registry). Gives each well-known
    // symbol a stable pointer without needing a new HIR variant.
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        if obj_ident.sym.as_ref() == "Symbol" {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let prop_name = prop_ident.sym.as_ref();
                if matches!(
                    prop_name,
                    "toPrimitive"
                        | "hasInstance"
                        | "toStringTag"
                        | "iterator"
                        | "asyncIterator"
                        | "dispose"
                        | "asyncDispose"
                ) {
                    return Ok(Expr::SymbolFor(Box::new(Expr::String(format!(
                        "@@__perry_wk_{}",
                        prop_name
                    )))));
                }
            }
        }
    }

    // `util.inspect.custom` / `inspect.custom` (named import from node:util)
    // — Node exposes this as the registered symbol `Symbol.for("nodejs.util.inspect.custom")`,
    // and object-literal keys / inspect output expect that exact description.
    // See #1201.
    if let ast::MemberProp::Ident(prop_ident) = &member.prop {
        if prop_ident.sym.as_ref() == "custom" {
            // Case A: `inspect.custom` where `inspect` is a named import from
            // node:util.
            if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
                if let Some((module_name, Some(method_name))) =
                    ctx.lookup_native_module(obj_ident.sym.as_ref())
                {
                    if (module_name == "util" || module_name == "node:util")
                        && method_name == "inspect"
                    {
                        return Ok(Expr::SymbolFor(Box::new(Expr::String(
                            "nodejs.util.inspect.custom".to_string(),
                        ))));
                    }
                }
            }
            // Case B: `util.inspect.custom` where `util` is a whole-module
            // alias (`import * as util from "node:util"` or
            // `import util from "node:util"`).
            if let ast::Expr::Member(inner) = member.obj.as_ref() {
                if let (ast::Expr::Ident(obj_ident), ast::MemberProp::Ident(inner_prop)) =
                    (inner.obj.as_ref(), &inner.prop)
                {
                    let obj_name = obj_ident.sym.to_string();
                    let is_util_module = obj_name == "util"
                        || ctx.lookup_builtin_module_alias(&obj_name) == Some("util");
                    if is_util_module && inner_prop.sym.as_ref() == "inspect" {
                        return Ok(Expr::SymbolFor(Box::new(Expr::String(
                            "nodejs.util.inspect.custom".to_string(),
                        ))));
                    }
                }
            }
        }
    }

    // Check if this is path.sep / path.delimiter constant access
    // (where `path` is an imported alias of the node:path module).
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        let is_path_module =
            obj_name == "path" || ctx.lookup_builtin_module_alias(&obj_name) == Some("path");
        if is_path_module {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                match prop_ident.sym.as_ref() {
                    "sep" => return Ok(Expr::PathSep),
                    "delimiter" => return Ok(Expr::PathDelimiter),
                    _ => {}
                }
            }
        }
    }

    // path.win32.sep / path.win32.delimiter (and path.posix.sep/.delimiter)
    // — sub-namespace constants. Lower directly to string literals; no
    // runtime call needed (issue #1162).
    if let ast::Expr::Member(inner) = member.obj.as_ref() {
        if let (ast::Expr::Ident(root_ident), ast::MemberProp::Ident(sub_prop)) =
            (inner.obj.as_ref(), &inner.prop)
        {
            let root_name = root_ident.sym.to_string();
            let is_path_root =
                root_name == "path" || ctx.lookup_builtin_module_alias(&root_name) == Some("path");
            if is_path_root {
                let sub = sub_prop.sym.as_ref();
                if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                    let prop = prop_ident.sym.as_ref();
                    match (sub, prop) {
                        ("win32", "sep") => return Ok(Expr::String("\\".to_string())),
                        ("win32", "delimiter") => return Ok(Expr::String(";".to_string())),
                        ("posix", "sep") => return Ok(Expr::String("/".to_string())),
                        ("posix", "delimiter") => return Ok(Expr::String(":".to_string())),
                        _ => {}
                    }
                }
            }
        }
    }

    // Check if this is a process.env.VARNAME or process.env[expr] access
    if let ast::Expr::Member(inner_member) = member.obj.as_ref() {
        if let ast::Expr::Ident(obj_ident) = inner_member.obj.as_ref() {
            if obj_ident.sym.as_ref() == "process" {
                if let ast::MemberProp::Ident(prop_ident) = &inner_member.prop {
                    if prop_ident.sym.as_ref() == "env" {
                        // This is process.env access
                        match &member.prop {
                            ast::MemberProp::Ident(var_ident) => {
                                // process.env.VARNAME (static key)
                                let var_name = var_ident.sym.to_string();
                                return Ok(Expr::EnvGet(var_name));
                            }
                            ast::MemberProp::Computed(computed) => {
                                // process.env[expr] (dynamic key)
                                let key_expr = Box::new(lower_expr(ctx, &computed.expr)?);
                                return Ok(Expr::EnvGetDynamic(key_expr));
                            }
                            _ => {}
                        }
                    }
                }
            }
        }
    }

    // Check for Math constants (e.g., Math.PI, Math.E)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        if obj_ident.sym.as_ref() == "Math" {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let val = match prop_ident.sym.as_ref() {
                    "PI" => Some(std::f64::consts::PI),
                    "E" => Some(std::f64::consts::E),
                    "LN2" => Some(std::f64::consts::LN_2),
                    "LN10" => Some(std::f64::consts::LN_10),
                    "LOG2E" => Some(std::f64::consts::LOG2_E),
                    "LOG10E" => Some(std::f64::consts::LOG10_E),
                    "SQRT2" => Some(std::f64::consts::SQRT_2),
                    "SQRT1_2" => Some(std::f64::consts::FRAC_1_SQRT_2),
                    _ => None,
                };
                if let Some(v) = val {
                    return Ok(Expr::Number(v));
                }
            }
        }
    }

    // Check for Number constants (e.g., Number.MAX_SAFE_INTEGER)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        if obj_ident.sym.as_ref() == "Number" {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let val = match prop_ident.sym.as_ref() {
                    "MAX_SAFE_INTEGER" => Some(9007199254740991.0),
                    "MIN_SAFE_INTEGER" => Some(-9007199254740991.0),
                    "MAX_VALUE" => Some(f64::MAX),
                    "MIN_VALUE" => Some(f64::MIN_POSITIVE),
                    "EPSILON" => Some(f64::EPSILON),
                    "POSITIVE_INFINITY" => Some(f64::INFINITY),
                    "NEGATIVE_INFINITY" => Some(f64::NEG_INFINITY),
                    "NaN" => Some(f64::NAN),
                    _ => None,
                };
                if let Some(v) = val {
                    return Ok(Expr::Number(v));
                }
            }
        }
    }

    // Check if this is an enum member access (e.g., Color.Red)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        if ctx.lookup_enum(&obj_name).is_some() {
            // This is an enum access
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let member_name = prop_ident.sym.to_string();
                return Ok(Expr::EnumMember {
                    enum_name: obj_name,
                    member_name,
                });
            }
        }
    }

    // Check if this is a static field access (e.g., Counter.count)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        if ctx.lookup_class(&obj_name).is_some() {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let field_name = prop_ident.sym.to_string();
                if ctx.has_static_field(&obj_name, &field_name) {
                    return Ok(Expr::StaticFieldGet {
                        class_name: obj_name,
                        field_name,
                    });
                }
            }
        }
    }

    // Check if this is a namespace variable access (e.g., Flag.OPENCODE_AUTO_SHARE)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        if let ast::MemberProp::Ident(prop_ident) = &member.prop {
            let member_name = prop_ident.sym.to_string();
            if let Some(local_id) = ctx.lookup_namespace_var(&obj_name, &member_name) {
                return Ok(Expr::LocalGet(local_id));
            }
        }
    }

    // Check if this is os.EOL / os.devNull property access
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.as_ref();
        let is_os_module =
            obj_name == "os" || ctx.lookup_builtin_module_alias(obj_name) == Some("os");
        if is_os_module {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                match prop_ident.sym.as_ref() {
                    "EOL" => return Ok(Expr::OsEOL),
                    "devNull" => return Ok(Expr::OsDevNull),
                    _ => {}
                }
            }
        }
    }

    // --- Proxy property get: `p.foo` / `p[k]` for known proxy locals ---
    {
        fn unwrap_member_obj(mut e: &ast::Expr) -> &ast::Expr {
            loop {
                match e {
                    ast::Expr::TsAs(ts_as) => e = &ts_as.expr,
                    ast::Expr::TsNonNull(nn) => e = &nn.expr,
                    ast::Expr::TsConstAssertion(ca) => e = &ca.expr,
                    ast::Expr::TsTypeAssertion(ta) => e = &ta.expr,
                    ast::Expr::Paren(p) => e = &p.expr,
                    _ => break,
                }
            }
            e
        }
        let inner = unwrap_member_obj(member.obj.as_ref());
        if let ast::Expr::Ident(obj_ident) = inner {
            let obj_name = obj_ident.sym.to_string();
            if ctx.proxy_locals.contains(&obj_name) {
                let proxy_expr = if let Some(id) = ctx.lookup_local(&obj_name) {
                    Expr::LocalGet(id)
                } else {
                    lower_expr(ctx, &member.obj)?
                };
                let key_expr = match &member.prop {
                    ast::MemberProp::Ident(i) => Expr::String(i.sym.to_string()),
                    ast::MemberProp::Computed(c) => lower_expr(ctx, &c.expr)?,
                    ast::MemberProp::PrivateName(pn) => {
                        Expr::String(format!("#{}", pn.name.as_str()))
                    }
                };
                return Ok(Expr::ProxyGet {
                    proxy: Box::new(proxy_expr),
                    key: Box::new(key_expr),
                });
            }
        }
    }

    // Issue #838 followup (b) — read side: `<funcDecl>.prototype.<name>`
    // (and the computed-string-literal form
    // `<funcDecl>.prototype['<name>']`). The assignment side routes
    // through `Expr::RegisterFunctionPrototypeMethod` which stores the
    // method in `CLASS_PROTOTYPE_METHODS[synthetic_cid]`; pre-fix the
    // matching read fell through to `PropertyGet(PropertyGet(funcDecl,
    // "prototype"), name)` whose receiver evaluated to `undefined`, so
    // `typeof Foo.prototype.method` came back `'undefined'` even with a
    // working dispatch. Look up the side-table directly here. Same
    // unwrap helper as the assignment-side recogniser so TS casts
    // (`(Foo.prototype as any).method`) don't defeat the match.
    {
        fn unwrap_ts_local(e: &ast::Expr) -> &ast::Expr {
            let mut cur = e;
            loop {
                match cur {
                    ast::Expr::TsAs(x) => cur = &x.expr,
                    ast::Expr::TsNonNull(x) => cur = &x.expr,
                    ast::Expr::TsSatisfies(x) => cur = &x.expr,
                    ast::Expr::TsTypeAssertion(x) => cur = &x.expr,
                    ast::Expr::TsConstAssertion(x) => cur = &x.expr,
                    ast::Expr::Paren(x) => cur = &x.expr,
                    _ => return cur,
                }
            }
        }
        let method_name_opt: Option<String> = match &member.prop {
            ast::MemberProp::Ident(p) => Some(p.sym.to_string()),
            ast::MemberProp::Computed(c) => match c.expr.as_ref() {
                ast::Expr::Lit(ast::Lit::Str(s)) => {
                    Some(s.value.as_str().unwrap_or("").to_string())
                }
                _ => None,
            },
            _ => None,
        };
        if let Some(method_name) = method_name_opt {
            let obj_unwrapped = unwrap_ts_local(member.obj.as_ref());
            if let ast::Expr::Member(inner) = obj_unwrapped {
                let prop_is_prototype = matches!(
                    &inner.prop,
                    ast::MemberProp::Ident(p) if p.sym.as_ref() == "prototype"
                );
                if prop_is_prototype {
                    let inner_obj = unwrap_ts_local(inner.obj.as_ref());
                    if let ast::Expr::Ident(fn_ident) = inner_obj {
                        let fn_name = fn_ident.sym.to_string();
                        // Mirror the assignment-side resolution order:
                        // function-typed local > top-level FuncRef. Skip
                        // classes — `class C` already has a real proto
                        // object exposed elsewhere and the side-table
                        // walk wouldn't help here. Skip native imports
                        // since their `.prototype` is module-managed.
                        if ctx.lookup_class(&fn_name).is_none()
                            && !matches!(ctx.lookup_native_module(&fn_name), Some((_, Some(_))))
                        {
                            let func_expr = if let Some(local_id) = ctx.lookup_local(&fn_name) {
                                if ctx.function_valued_locals.contains(&local_id) {
                                    Some(Expr::LocalGet(local_id))
                                } else {
                                    None
                                }
                            } else if let Some(func_id) = ctx.lookup_func(&fn_name) {
                                Some(Expr::FuncRef(func_id))
                            } else {
                                None
                            };
                            if let Some(func_expr) = func_expr {
                                return Ok(Expr::GetFunctionPrototypeMethod {
                                    func: Box::new(func_expr),
                                    method_name,
                                });
                            }
                        }
                    }
                }
            }
        }
    }

    // Check for native instance property access (e.g., response.status, response.ok)
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        // Clone module_name + class_name early to avoid borrow issues.
        // Issue #577 — preserve class_name in the lowered NativeMethodCall
        // so the codegen NATIVE_MODULE_TABLE class_filter dispatch fires
        // for getters like `req.method` / `res.statusCode` that have
        // class_filter = Some("IncomingMessage" / "ServerResponse").
        let native_instance = ctx
            .lookup_native_instance(&obj_name)
            .map(|(m, c)| (m.to_string(), c.to_string()));
        if let Some((module_name, class_name)) = native_instance {
            if let ast::MemberProp::Ident(prop_ident) = &member.prop {
                let property_name = prop_ident.sym.to_string();
                // Issue #562: stream subclass instances (e.g.
                // `class W extends WritableStream`) carry the bare-stream
                // module/class tag for inherited-method dispatch
                // (`w.pipeTo(...)` / `w.getWriter()`), but they ALSO
                // declare their own fields (`w.seenLengths` / `w.config`).
                // Without this gate, every plain field read would route
                // through the NativeMethodCall arm in `lower_call.rs`,
                // miss the streams' known-method match, fall through to
                // the receiver-less zero-sentinel, and read as 0. Only
                // route to NativeMethodCall when the property name is a
                // known stream API method/property — let everything else
                // fall through to regular object property access.
                if matches!(
                    module_name.as_str(),
                    "readable_stream"
                        | "writable_stream"
                        | "transform_stream"
                        | "readable_stream_reader"
                        | "writable_stream_writer"
                ) && !is_stream_api_member(&module_name, &property_name)
                {
                    // Fall through — let the regular member access path
                    // below handle the user-declared subclass field.
                } else {
                    // Issue #577 — `req.method` / `res.statusCode` etc.
                    // get rewritten to `__get_<name>` so the property
                    // read dispatches through NATIVE_MODULE_TABLE entries
                    // with class_filter = Some("IncomingMessage" |
                    // "ServerResponse"). Mapping table is the set of
                    // properties exposed via per-class FFI getters in
                    // perry-ext-http-server. Anything not in the set
                    // falls back to the existing bare-method-name
                    // dispatch (covers `request.headers` on fastify
                    // and similar).
                    let property_name = if module_name == "http" {
                        match (class_name.as_str(), property_name.as_str()) {
                            ("IncomingMessage", "method")
                            | ("IncomingMessage", "url")
                            | ("IncomingMessage", "httpVersion")
                            | ("IncomingMessage", "complete")
                            | ("IncomingMessage", "aborted")
                            | ("IncomingMessage", "destroyed")
                            // Closes #769 followup — client-side `res.statusCode`
                            // (and statusMessage / headers) returned the
                            // 0.0 zero-sentinel from `lower_native_method_call`
                            // because no NativeModSig matched and the receiver
                            // had been pre-tagged ("http", "IncomingMessage"),
                            // so the generic property dispatcher in the runtime
                            // never saw the read. Rewrite to `__get_<prop>` so
                            // the codegen routes through the perry-ext-http
                            // accessor (which knows the client-IncomingMessage
                            // registry).
                            | ("IncomingMessage", "statusCode")
                            | ("IncomingMessage", "statusMessage")
                            | ("IncomingMessage", "headers")
                            | ("ServerResponse", "statusCode")
                            | ("ServerResponse", "headersSent")
                            | ("ServerResponse", "writableEnded")
                            | ("ServerResponse", "writableFinished") => {
                                format!("__get_{}", property_name)
                            }
                            _ => property_name,
                        }
                    } else {
                        property_name
                    };
                    let class_filter = if module_name == "http" {
                        Some(class_name.clone())
                    } else {
                        None
                    };
                    // For properties that map to FFI functions, generate a NativeMethodCall
                    // with no args (property getter)
                    let object_expr = lower_expr(ctx, &member.obj)?;
                    return Ok(Expr::NativeMethodCall {
                        module: module_name,
                        class_name: class_filter,
                        object: Some(Box::new(object_expr)),
                        method: property_name,
                        args: Vec::new(),
                    });
                }
            }
        }
    }

    // TextEncoder / TextDecoder property access
    if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
        let obj_name = obj_ident.sym.to_string();
        if let ast::MemberProp::Ident(prop_ident) = &member.prop {
            let prop_name = prop_ident.sym.as_ref();
            let is_text_encoder = ctx
                .lookup_local_type(&obj_name)
                .map(|ty| matches!(ty, Type::Named(name) if name == "TextEncoder"))
                .unwrap_or(false);
            let is_text_decoder = ctx
                .lookup_local_type(&obj_name)
                .map(|ty| matches!(ty, Type::Named(name) if name == "TextDecoder"))
                .unwrap_or(false);
            if (is_text_encoder || is_text_decoder) && prop_name == "encoding" {
                return Ok(Expr::String("utf-8".to_string()));
            }
        }
    }

    // RegExp property access: regex.source / .flags / .lastIndex
    // Detect when receiver is a regex literal or local typed as RegExp.
    if let ast::MemberProp::Ident(prop_ident) = &member.prop {
        let prop_name = prop_ident.sym.as_ref();
        if prop_name == "source" || prop_name == "flags" || prop_name == "lastIndex" {
            let is_regex_obj = match member.obj.as_ref() {
                ast::Expr::Lit(ast::Lit::Regex(_)) => true,
                ast::Expr::Ident(ident) => ctx
                    .lookup_local_type(ident.sym.as_ref())
                    .map(|ty| matches!(ty, Type::Named(n) if n == "RegExp"))
                    .unwrap_or(false),
                _ => false,
            };
            if is_regex_obj {
                let regex_expr = lower_expr(ctx, &member.obj)?;
                if matches!(&regex_expr, Expr::RegExp { .. })
                    || matches!(&regex_expr, Expr::LocalGet(_))
                {
                    return Ok(match prop_name {
                        "source" => Expr::RegExpSource(Box::new(regex_expr)),
                        "flags" => Expr::RegExpFlags(Box::new(regex_expr)),
                        "lastIndex" => Expr::RegExpLastIndex(Box::new(regex_expr)),
                        _ => unreachable!(),
                    });
                }
            }
        }
        // RegExpExecArray.index / .groups — receiver is a local that holds the result
        // of regex.exec(...). The runtime stores the most recent exec metadata in
        // thread-locals which RegExpExecIndex/Groups read.
        if prop_name == "index" || prop_name == "groups" {
            // Strip non-null assertion (m1! → m1)
            let inner = match member.obj.as_ref() {
                ast::Expr::TsNonNull(nn) => nn.expr.as_ref(),
                other => other,
            };
            if let ast::Expr::Ident(ident) = inner {
                if ctx.regex_exec_locals.contains(&ident.sym.to_string()) {
                    return Ok(if prop_name == "index" {
                        Expr::RegExpExecIndex
                    } else {
                        Expr::RegExpExecGroups
                    });
                }
            }
        }
    }

    // Tagged-template `.raw` — recognize `<strings>.raw` where the
    // receiver is an Array-typed local (the typical signature is
    // `function tag(strings: TemplateStringsArray, ...)`, which Perry's
    // HIR types as a plain `Type::Array(Type::String)` after stripping
    // the alias). Folds to `Expr::TemplateRaw`, which the codegen
    // resolves to `js_template_raw(arr)` — a thread-local lookup of the
    // raw-strings array registered by the matching
    // `Expr::TaggedTemplateStrings` build at the call site.
    if let ast::MemberProp::Ident(prop_ident) = &member.prop {
        if prop_ident.sym.as_ref() == "raw" {
            if let ast::Expr::Ident(ident) = member.obj.as_ref() {
                let recv_ty = ctx.lookup_local_type(ident.sym.as_ref());
                let is_array = match recv_ty {
                    Some(perry_types::Type::Array(_)) | Some(perry_types::Type::Tuple(_)) => true,
                    Some(perry_types::Type::Named(n)) if n == "TemplateStringsArray" => true,
                    _ => false,
                };
                if is_array {
                    let arr_expr = lower_expr(ctx, &member.obj)?;
                    return Ok(Expr::TemplateRaw(Box::new(arr_expr)));
                }
            }
        }
    }

    let mut object_expr = lower_expr(ctx, &member.obj)?;

    // #973 (5ddccbbc) rerouted bare built-in identifiers used as VALUES
    // (`Number`, `Object`, `Array`, ...) to `PropertyGet { GlobalGet(0),
    // name }` so identity comparisons like `inst.constructor === Date`
    // resolve both sides to the same `populate_global_this_builtins`
    // closure. But when the built-in ident is the OBJECT of a member
    // access (`Number.parseFloat`, `Object.keys`, `Array.isArray`, ...),
    // that reroute turns the intrinsic static-method/property lookup into
    // `globalThis.Number.parseFloat`, which is no longer the same value
    // as the intrinsic global `parseFloat` — silently breaking
    // `Number.parseFloat === parseFloat`, `Number.parseInt === parseInt`,
    // and similar identity checks (regressed test_gap_number_math).
    // Static surfaces must keep the pre-#973 intrinsic `GlobalGet(0)`
    // dispatch. Detect and undo the reroute only in member-object
    // position; local shadowing is unaffected because a shadowing local
    // would have lowered to `LocalGet`, never this reroute.
    if let Expr::PropertyGet {
        object: inner,
        property,
    } = &object_expr
    {
        if matches!(inner.as_ref(), Expr::GlobalGet(0))
            && crate::analysis::is_builtin_global_value_name(property)
        {
            if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
                if obj_ident.sym.as_ref() == property.as_str() {
                    object_expr = Expr::GlobalGet(0);
                }
            }
        }
    }
    let object = Box::new(object_expr);

    // Unimplemented-API gate (#463). When the receiver is a
    // `NativeModuleRef("crypto")`-style import binding and the user is
    // reading a named property, fail loudly if the manifest doesn't
    // know about that property. The check is gated on the module
    // having at least one entry in `API_MANIFEST`, so modules whose
    // surface hasn't been enumerated yet (incremental coverage) keep
    // working — adding entries to a module promotes it to strict mode
    // automatically.
    //
    // Stubs (`stub: true` in the manifest) are NOT treated as
    // unimplemented — those are intentional no-ops surfaced by #464's
    // runtime first-call warning. The call only checks that
    // `module_has_symbol` returns Some; the stub flag is consulted by
    // the docs serializer, not by the gate.
    //
    // Escape hatch: setting `PERRY_ALLOW_UNIMPLEMENTED=1` skips the
    // check entirely (downgrades to existing silent-undefined
    // behavior). Useful when the manifest has a real gap that a
    // followup will fix; documents the bypass instead of forcing an
    // unrelated change in this PR.
    if let (Expr::NativeModuleRef(module), ast::MemberProp::Ident(prop_ident)) =
        (&*object, &member.prop)
    {
        let prop = prop_ident.sym.as_ref();
        let allow_unimplemented = std::env::var_os("PERRY_ALLOW_UNIMPLEMENTED").is_some();
        // Skip the gate when `member.obj` is an Ident that was a
        // *named* import binding from the module (e.g. `import {
        // EventEmitter } from "node:events"; EventEmitter.prototype`).
        // `lookup_native_module(name)` returns `(module, Some(symbol))`
        // for named imports and `(module, None)` for namespace imports
        // (`import * as events from "node:events"`). For named imports,
        // the member access is reading a property of that imported
        // *value*, not of the module namespace — so the appropriate
        // manifest entry to consult is the imported symbol itself
        // (which is already known to exist; that's how the import
        // resolved). Without this skip, every `EventEmitter.prototype`
        // / `Buffer.from(...).x` shape tripped the gate even when the
        // imported symbol was fully manifest-registered, because by
        // the time we're here the imported Ident has already been
        // value-form-lowered to `NativeModuleRef(module)` and the
        // original symbol name is no longer reachable from `object`.
        // Issue #859 followup: `test_issue_pino_prototype_undefined`
        // (the v0.5.938 #894 regression) hits exactly this with
        // `(EventEmitter as any).prototype`.
        let obj_is_named_import = match member.obj.as_ref() {
            ast::Expr::Ident(obj_ident) => matches!(
                ctx.lookup_native_module(obj_ident.sym.as_ref()),
                Some((_, Some(_)))
            ),
            // The `as any` / `as Foo` / `<T>x` casts wrap the Ident in
            // a TS-cast AST node before it reaches member access. Peel
            // them so the named-import detection survives the cast.
            ast::Expr::TsAs(ts_as) => match ts_as.expr.as_ref() {
                ast::Expr::Ident(obj_ident) => matches!(
                    ctx.lookup_native_module(obj_ident.sym.as_ref()),
                    Some((_, Some(_)))
                ),
                _ => false,
            },
            ast::Expr::TsNonNull(ts_nn) => match ts_nn.expr.as_ref() {
                ast::Expr::Ident(obj_ident) => matches!(
                    ctx.lookup_native_module(obj_ident.sym.as_ref()),
                    Some((_, Some(_)))
                ),
                _ => false,
            },
            ast::Expr::TsTypeAssertion(ts_ta) => match ts_ta.expr.as_ref() {
                ast::Expr::Ident(obj_ident) => matches!(
                    ctx.lookup_native_module(obj_ident.sym.as_ref()),
                    Some((_, Some(_)))
                ),
                _ => false,
            },
            ast::Expr::Paren(paren) => match paren.expr.as_ref() {
                ast::Expr::Ident(obj_ident) => matches!(
                    ctx.lookup_native_module(obj_ident.sym.as_ref()),
                    Some((_, Some(_)))
                ),
                ast::Expr::TsAs(ts_as) => match ts_as.expr.as_ref() {
                    ast::Expr::Ident(obj_ident) => matches!(
                        ctx.lookup_native_module(obj_ident.sym.as_ref()),
                        Some((_, Some(_)))
                    ),
                    _ => false,
                },
                _ => false,
            },
            _ => false,
        };
        if !allow_unimplemented
            && !obj_is_named_import
            && perry_api_manifest::module_has_any_entries(module)
            && perry_api_manifest::module_has_symbol(module, prop).is_none()
        {
            // #925: when there's a known supported equivalent for this
            // shape, append it to the error so the user doesn't have to
            // grep through the manifest to find the replacement.
            let hint = super::unimpl_hints::module_member_hint(module, prop)
                .map(|h| format!(" {h}"))
                .unwrap_or_default();
            crate::lower_bail!(
                member.span,
                "`{}.{}` is not implemented in Perry — see `perry --print-api-manifest` for the supported surface, \
                 or set `PERRY_ALLOW_UNIMPLEMENTED=1` to ignore. (#463){}",
                module,
                prop,
                hint,
            );
        }
    }

    match &member.prop {
        ast::MemberProp::Ident(ident) => {
            let property = ident.sym.to_string();
            Ok(Expr::PropertyGet { object, property })
        }
        ast::MemberProp::Computed(computed) => {
            // #503: refuse compile-time dynamic dispatch on stdlib namespace
            // receivers — `process[runtimeVar]`, `fs[atob(...)]()`, etc. —
            // the dispatch-by-string class of supply-chain evasion. The check
            // runs on the AST so it sees the un-folded shape, and bails before
            // we lower the index (lowering can have side effects we want to
            // avoid for refused code).
            //
            // Only fires when:
            //   - the receiver AST is a bare ident naming a stdlib namespace
            //     (or an alias bound to one via `import x from 'fs'`),
            //   - the index is NOT a string literal at the source level
            //     (literal keys are caught by the fold below, and never
            //     constitute string-obfuscation),
            //   - the refusal pass is enabled (`PERRY_ALLOW_DYNAMIC_STDLIB=0` /
            //     `perry.allowDynamicStdlibDispatch: false`; on by default),
            //   - the currently-lowering source file does NOT belong to a
            //     package on the per-package allow-list, and
            //   - there is no `// @perry-allow-dynamic` line annotation on
            //     or immediately above the offending site.
            if crate::ir::refuse_dynamic_stdlib_dispatch_enabled() {
                if let Some(ns) = stdlib_namespace_receiver(ctx, member.obj.as_ref()) {
                    if !matches!(*computed.expr, ast::Expr::Lit(ast::Lit::Str(_))) {
                        let pkg = crate::ir::package_name_for_source_path(&ctx.source_file_path);
                        let pkg_allowed = pkg
                            .map(crate::ir::dynamic_stdlib_allowed_for_package)
                            .unwrap_or(false);
                        // #996: `// @perry-allow-dynamic` is host-code only.
                        // A malicious npm package can write the annotation next
                        // to its own call to defeat the refusal — closing the
                        // hole means dependencies must be opted in by the host
                        // via `perry.allowDynamicStdlibDispatch` (the
                        // `pkg_allowed` branch above), never by themselves.
                        let site_allowed = pkg.is_none()
                            && crate::ir::current_module_has_allow_dynamic_at(member.span.lo.0);
                        if !pkg_allowed && !site_allowed {
                            let pkg_label = pkg
                                .map(|p| format!(" (in package `{}`)", p))
                                .unwrap_or_default();
                            crate::lower_bail!(
                                member.span,
                                "dynamic dispatch on stdlib namespace `{}` is refused at \
                                 compile time{} — this catches the obfuscation pattern \
                                 `{}[runtimeVar]()` used by malicious npm packages. (#503)\n\
                                 \n\
                                 Options:\n\
                                 - Replace with a static call: `{}.<methodName>(...)`.\n\
                                 - If the indirection is intentional, add `// @perry-allow-dynamic` \
                                   on the line above the call.\n\
                                 - To opt an entire dependency out, add its name to \
                                   `perry.allowDynamicStdlibDispatch` in the host package.json, \
                                   or set `perry.allowDynamicStdlibDispatch: true` to disable \
                                   the check globally.\n\
                                 - Or set `PERRY_ALLOW_DYNAMIC_STDLIB=1` for a one-off build.",
                                ns,
                                pkg_label,
                                ns,
                                ns,
                            );
                        }
                    }
                }
            }

            let index = Box::new(lower_expr(ctx, &computed.expr)?);
            // Specialize for Uint8Array/Buffer variables → byte-level access.
            // Params declared `Buffer` (e.g. `function f(src: Buffer)`)
            // reach here with `Type::Named("Buffer")` — treat it as a
            // synonym for Uint8Array so `src[i]` uses the byte-read
            // path instead of the generic f64-element IndexGet, which
            // would return NaN-boxed pointer bits as a denormal f64.
            if let Expr::LocalGet(id) = &*object {
                if let Some((_, _, ty)) = ctx.locals.iter().find(|(_, lid, _)| lid == id) {
                    if matches!(ty, Type::Named(n) if n == "Uint8Array" || n == "Buffer") {
                        return Ok(Expr::Uint8ArrayGet {
                            array: object,
                            index,
                        });
                    }
                }
            }
            // Issue #529: `obj["method"]` on a class instance with a static
            // string key is semantically equivalent to `obj.method` — both
            // forms must hit the same vtable dispatch. The dot form lowers
            // to `Expr::PropertyGet`, which codegen routes through
            // `js_class_method_bind` / vtable lookup; `IndexGet` on a class
            // instance falls through to the generic property-by-name read
            // (`js_dyn_index_get`), which only sees object fields and
            // returns undefined for methods. Fold static-string IndexGet
            // into PropertyGet so the two forms share a code path.
            //
            // Fold only when the index is a literal string that does NOT
            // parse as a non-negative integer — `arr["0"]` keeps IndexGet
            // semantics (string-coerced numeric element access on arrays).
            // This is the same disambiguator JavaScript's spec uses
            // internally for indexed-vs-named properties.
            if let Expr::String(key) = &*index {
                let is_numeric_string = !key.is_empty()
                    && key.chars().all(|c| c.is_ascii_digit())
                    && !(key.len() > 1 && key.starts_with('0'));
                if !is_numeric_string {
                    return Ok(Expr::PropertyGet {
                        object,
                        property: key.clone(),
                    });
                }
            }
            Ok(Expr::IndexGet { object, index })
        }
        ast::MemberProp::PrivateName(private) => {
            // Private field access: this.#field -> PropertyGet with "#field"
            let property = format!("#{}", private.name);
            Ok(Expr::PropertyGet { object, property })
        }
    }
}

/// #503 — Node-core stdlib namespace receivers whose dynamic (`obj[x]`)
/// member access is refused at compile time. These are the namespaces
/// the issue calls out: the well-known shapes used by string-based
/// obfuscation in malicious npm packages. Globals (`process`, `Buffer`)
/// and `require`-imported core modules are both covered — Buffer is
/// intentionally omitted because it is a class constructor (`new Buffer`)
/// rather than a namespace; the meaningful attack surface there is the
/// constructor itself, not dynamic property access. Keep this list in
/// sync with the docs in `docs/src/security/dynamic-dispatch.md`.
const STDLIB_NAMESPACE_NAMES: &[&str] = &[
    "process",
    "fs",
    "crypto",
    "child_process",
    "net",
    "os",
    "path",
    "http",
    "https",
    "http2",
    "stream",
    "url",
    "util",
    "events",
    "dns",
    "tls",
    "querystring",
    "zlib",
    "async_hooks",
    "readline",
    "string_decoder",
    "tty",
    "worker_threads",
];

/// #503 — does the given AST receiver expression resolve to a known
/// stdlib namespace? Recognised shapes:
///   - bare ident matching one of `STDLIB_NAMESPACE_NAMES` (global
///     `process` or top-level imported `fs` etc.),
///   - bare ident bound to a stdlib alias via `import x from 'fs'`
///     (`ctx.builtin_module_aliases` populated by `require()` and ESM
///     default imports), or
///   - bare ident bound to a namespace import (`import * as fs from
///     'fs'`) via `ctx.native_modules` with a `None` method-name.
///
/// Returns the canonical stdlib namespace name (e.g. `"fs"`) when a
/// match is found, so the diagnostic can name the namespace concretely.
pub(super) fn stdlib_namespace_receiver(
    ctx: &super::LoweringContext,
    obj: &ast::Expr,
) -> Option<&'static str> {
    // TS type-position wrappers like `(process as any)` and
    // `<any>process` parse as `TsAsExpr` / `TsTypeAssertion`, and the
    // `(...)` itself shows up as a `Paren`. Strip them so an idiomatic
    // `(process as any)[k]()` still surfaces `process` as the receiver.
    let mut current = obj;
    loop {
        match current {
            ast::Expr::Paren(p) => current = p.expr.as_ref(),
            ast::Expr::TsAs(a) => current = a.expr.as_ref(),
            ast::Expr::TsTypeAssertion(a) => current = a.expr.as_ref(),
            ast::Expr::TsNonNull(a) => current = a.expr.as_ref(),
            ast::Expr::TsConstAssertion(a) => current = a.expr.as_ref(),
            ast::Expr::TsSatisfies(a) => current = a.expr.as_ref(),
            _ => break,
        }
    }
    let ident = match current {
        ast::Expr::Ident(ident) => ident,
        _ => return None,
    };
    let name = ident.sym.as_ref();

    // Direct global / module specifier match.
    if let Some(canon) = STDLIB_NAMESPACE_NAMES.iter().find(|n| **n == name) {
        return Some(*canon);
    }

    // `require()` / default-import alias: `import fs from 'fs'` →
    // builtin_module_aliases["fs"] = "fs", but the user may rename:
    // `import myFs from 'fs'` → ["myFs"] = "fs". Resolve to the
    // canonical specifier.
    for (local, module) in ctx.builtin_module_aliases.iter() {
        if local == name {
            if let Some(canon) = STDLIB_NAMESPACE_NAMES
                .iter()
                .find(|n| **n == module.as_str())
            {
                return Some(*canon);
            }
        }
    }

    // Namespace import: `import * as fs from 'fs'` — tracked as a
    // native_modules entry with method_name = None.
    for (local, module, method) in ctx.native_modules.iter() {
        if local == name && method.is_none() {
            if let Some(canon) = STDLIB_NAMESPACE_NAMES
                .iter()
                .find(|n| **n == module.as_str())
            {
                return Some(*canon);
            }
        }
    }

    None
}

/// Issue #562 — does `prop` name a stream-API method or property on the
/// given stream module? Used to gate the native-instance property
/// rerouting so subclass-declared fields fall through to regular object
/// property access. Mirrors the methods + accessors hardcoded in
/// `crates/perry-codegen/src/lower_call.rs`'s
/// `module == "<stream_kind>"` arms.
fn is_stream_api_member(module: &str, prop: &str) -> bool {
    match module {
        "readable_stream" => matches!(
            prop,
            "getReader"
                | "cancel"
                | "tee"
                | "pipeTo"
                | "pipeThrough"
                | "locked"
                | "enqueue"
                | "close"
                | "error"
                | "desiredSize"
        ),
        "readable_stream_reader" => {
            matches!(prop, "read" | "releaseLock" | "cancel" | "closed")
        }
        "writable_stream" => matches!(prop, "getWriter" | "abort" | "close" | "locked"),
        "writable_stream_writer" => matches!(
            prop,
            "write" | "close" | "abort" | "releaseLock" | "closed" | "ready" | "desiredSize"
        ),
        "transform_stream" => matches!(prop, "readable" | "writable"),
        _ => false,
    }
}
