<?php
/**
 * Free ASN lookup: bundled IP-to-ASN/organization resolver.
 *
 * Ships a compact, gzipped IPv4+IPv6 ASN table (built from the DB-IP ASN Lite
 * dataset, CC-BY-4.0) under data/asn.bin.gz. On first use it inflates once
 * into wp-content/uploads/shieldforge/ and answers lookups via a seek-based
 * binary search — the full table is never loaded into memory.
 *
 * IP-to-ASN lookups are pure local, no network calls, no raw IP retained.
 *
 * Binary format ("SFA1"):
 *   magic      4   "SFA1"
 *   v4_count   u32 BE
 *   v6_count   u32 BE
 *   name_count u32 BE
 *   name_dict  name_count * [ asn:u32 BE, name_len:u8, name:utf8 ]
 *   v4_starts  v4_count*4    u32 BE, ascending range starts
 *   v4_asns    v4_count*4    u32 BE, ASN of the range beginning at that start
 *   v6_starts  v6_count*16   u128 BE, ascending range starts
 *   v6_asns    v6_count*4    u32 BE
 *
 * @package Brainwerk Security Suite
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Shieldforge_Asn {

    const BIN = 'asn.bin';
    const GZ  = 'asn.bin.gz';

    /** Name dict, loaded once per request: asn => org name. */
    private static $names       = null;
    private static $names_asns  = array();

    /**
     * In-memory fallback copy of the inflated DB for this request, used only
     * when neither the uploads cache dir nor the system temp dir is writable.
     */
    private static $mem_buf = null;

    /** Absolute path of the inflated runtime DB inside uploads. */
    private static function bin_path() {
        $upload = wp_upload_dir();
        return trailingslashit( $upload['basedir'] ) . 'shieldforge/' . self::BIN;
    }

    /** Secondary on-disk cache location, tried if uploads/ isn't writable. */
    private static function temp_path() {
        return trailingslashit( sys_get_temp_dir() ) . 'shieldforge-' . self::BIN;
    }

    /** Read + gzinflate the bundled .gz source. '' if missing/corrupt. */
    private static function inflate_source() {
        $gz = SHIELDFORGE_PATH . 'data/' . self::GZ;
        if ( ! file_exists( $gz ) || ! function_exists( 'gzdecode' ) ) {
            return '';
        }
        $raw = gzdecode( (string) file_get_contents( $gz ) );
        if ( false === $raw || strlen( $raw ) < 1024 ) {
            return '';
        }
        return $raw;
    }

    /** Best-effort write of $data to $target, creating its parent dir if needed. */
    private static function try_cache_write( $target, $data ) {
        $dir = dirname( $target );
        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
            return false;
        }
        return false !== @file_put_contents( $target, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged -- optional cache write; failure is handled via the false-return check, warning suppression avoids noise on read-only filesystems.
    }

    /**
     * Open a readable handle to the inflated DB: a real file resource for the
     * common case (bundled .gz inflated once into uploads, or the system temp
     * dir as a secondary cache), or a Shieldforge_Mem_Handle wrapping an
     * in-memory buffer when neither location is writable. Returns null if the
     * bundled source is missing or corrupt.
     */
    private static function open_handle() {
        $path = self::bin_path();
        if ( file_exists( $path ) && filesize( $path ) > 1024 ) {
            $fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
            if ( $fh ) {
                return $fh;
            }
        }

        if ( null !== self::$mem_buf ) {
            return new Shieldforge_Mem_Handle( self::$mem_buf );
        }

        $raw = self::inflate_source();
        if ( '' === $raw ) {
            return null;
        }

        foreach ( array( $path, self::temp_path() ) as $target ) {
            if ( self::try_cache_write( $target, $raw ) ) {
                $fh = fopen( $target, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
                if ( $fh ) {
                    return $fh;
                }
            }
        }

        self::$mem_buf = $raw;
        return new Shieldforge_Mem_Handle( $raw );
    }

    /**
     * Resolve an IP to its ASN number. Returns null for private/reserved/
     * unknown/invalid input. Pure local database lookup — no network access,
     * the raw IP never leaves this call.
     */
    public static function asn_for_ip( $ip ) {
        $ip = trim( (string) $ip );
        if ( '' === $ip || ! function_exists( 'inet_pton' ) ) {
            return null;
        }
        if ( self::is_private_or_reserved( $ip ) ) {
            return null;
        }
        $packed = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed IP triggers a PHP warning; failure is handled via the false-return check below.
        if ( false === $packed ) {
            return null;
        }
        $fh = self::open_handle();
        if ( null === $fh ) {
            return null;
        }
        $asn = null;
        try {
            if ( 'SFA1' !== Shieldforge_Mem_Handle::read( $fh, 4 ) ) {
                Shieldforge_Mem_Handle::close( $fh );
                return null;
            }
            $hdr = unpack( 'Nv4/Nv6/Nnames', Shieldforge_Mem_Handle::read( $fh, 12 ) );
            $v4  = (int) $hdr['v4'];
            $v6  = (int) $hdr['v6'];
            $nc  = (int) $hdr['names'];

            $names_len = self::skip_name_dict( $fh, $nc );

            $v4_starts = 4 + 12 + $names_len;

            if ( 4 === strlen( $packed ) ) {
                $key = (int) unpack( 'N', $packed )[1];
                $idx = self::bsearch_u32( $fh, $v4_starts, $v4, $key );
                if ( $idx >= 0 ) {
                    Shieldforge_Mem_Handle::seek( $fh, $v4_starts + $v4 * 4 + $idx * 4 );
                    $asn = (int) unpack( 'N', Shieldforge_Mem_Handle::read( $fh, 4 ) )[1];
                }
            } else {
                $v6_starts = $v4_starts + $v4 * 4 + $v4 * 4;
                $idx       = self::bsearch_u128( $fh, $v6_starts, $v6, $packed );
                if ( $idx >= 0 ) {
                    Shieldforge_Mem_Handle::seek( $fh, $v6_starts + $v6 * 16 + $idx * 4 );
                    $asn = (int) unpack( 'N', Shieldforge_Mem_Handle::read( $fh, 4 ) )[1];
                }
            }
        } catch ( \Throwable $e ) {
            $asn = null;
        }
        Shieldforge_Mem_Handle::close( $fh );

        return ( null !== $asn && $asn > 0 ) ? $asn : null;
    }

    /**
     * Provider/organization name for an ASN number, from the bundled name
     * dictionary. Returns null if the ASN is unknown or the DB is unavailable.
     */
    public static function org_for_asn( $asn ) {
        $asn = (int) $asn;
        if ( $asn <= 0 ) {
            return null;
        }
        self::load_names();
        return isset( self::$names[ $asn ] ) ? self::$names[ $asn ] : null;
    }

    /**
     * Convenience: resolve an IP straight to ['asn' => int, 'org' => ?string],
     * or null if the ASN can't be resolved.
     */
    public static function lookup( $ip ) {
        $asn = self::asn_for_ip( $ip );
        if ( null === $asn ) {
            return null;
        }
        return array(
            'asn' => $asn,
            'org' => self::org_for_asn( $asn ),
        );
    }

    /** Load the whole asn => org name dict into memory, once per request. */
    private static function load_names() {
        if ( null !== self::$names ) {
            return;
        }
        self::$names = array();
        $fh          = self::open_handle();
        if ( null === $fh ) {
            return;
        }
        try {
            if ( 'SFA1' !== Shieldforge_Mem_Handle::read( $fh, 4 ) ) {
                Shieldforge_Mem_Handle::close( $fh );
                return;
            }
            $hdr = unpack( 'Nv4/Nv6/Nnames', Shieldforge_Mem_Handle::read( $fh, 12 ) );
            $nc  = (int) $hdr['names'];
            for ( $i = 0; $i < $nc; $i++ ) {
                $head = unpack( 'Nasn/Clen', Shieldforge_Mem_Handle::read( $fh, 5 ) );
                $len  = (int) $head['len'];
                $name = $len > 0 ? Shieldforge_Mem_Handle::read( $fh, $len ) : '';
                self::$names[ (int) $head['asn'] ] = $name;
            }
        } catch ( \Throwable $e ) {
            // leave whatever was parsed so far
        }
        Shieldforge_Mem_Handle::close( $fh );
    }

    /** Skip over the name dict while reading the header; returns its byte length. */
    private static function skip_name_dict( $fh, $count ) {
        $len = 0;
        for ( $i = 0; $i < $count; $i++ ) {
            $head = unpack( 'Nasn/Clen', Shieldforge_Mem_Handle::read( $fh, 5 ) );
            $nlen = (int) $head['len'];
            if ( $nlen > 0 ) {
                Shieldforge_Mem_Handle::read( $fh, $nlen );
            }
            $len += 5 + $nlen;
        }
        return $len;
    }

    /** True for private/reserved/loopback/link-local IPv4+IPv6 ranges. */
    private static function is_private_or_reserved( $ip ) {
        return false === filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /** Greatest index whose 4-byte start <= $key (uint32), or -1. */
    private static function bsearch_u32( $fh, $base, $n, $key ) {
        $lo  = 0;
        $hi  = $n - 1;
        $res = -1;
        while ( $lo <= $hi ) {
            $mid = intdiv( $lo + $hi, 2 );
            Shieldforge_Mem_Handle::seek( $fh, $base + $mid * 4 );
            $v = (int) unpack( 'N', Shieldforge_Mem_Handle::read( $fh, 4 ) )[1];
            if ( $v <= $key ) {
                $res = $mid;
                $lo  = $mid + 1;
            } else {
                $hi = $mid - 1;
            }
        }
        return $res;
    }

    /** Greatest index whose 16-byte start <= $key16 (big-endian), or -1. */
    private static function bsearch_u128( $fh, $base, $n, $key16 ) {
        $lo  = 0;
        $hi  = $n - 1;
        $res = -1;
        while ( $lo <= $hi ) {
            $mid = intdiv( $lo + $hi, 2 );
            Shieldforge_Mem_Handle::seek( $fh, $base + $mid * 16 );
            $v = Shieldforge_Mem_Handle::read( $fh, 16 );
            // Binary big-endian strings compare correctly as unsigned via strcmp.
            if ( strcmp( $v, $key16 ) <= 0 ) {
                $res = $mid;
                $lo  = $mid + 1;
            } else {
                $hi = $mid - 1;
            }
        }
        return $res;
    }
}
