<?php
/**
 * DNS resolver with native + DoH fallback.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Audit;

defined( 'ABSPATH' ) || exit;

/**
 * Class DnsResolver
 *
 * Three-tier DNS resolver: native dns_get_record() first, then Cloudflare
 * DNS-over-HTTPS, then Google DNS-over-HTTPS. Each query records which path
 * succeeded so HealthAuditor can surface the resolver used to the UI.
 *
 * The resolver returns a flat list of textual record values (e.g. TXT contents,
 * IP addresses, PTR hostnames). Pillar logic in HealthAuditor parses these
 * strings, so the resolver does not interpret record contents.
 *
 * @since 1.0.0
 */
class DnsResolver {

    /**
     * Resolver path token: native dns_get_record().
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const RESOLVER_NATIVE = 'native';

    /**
     * Resolver path token: Cloudflare DoH.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const RESOLVER_CLOUDFLARE = 'doh-cloudflare';

    /**
     * Resolver path token: Google DoH.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const RESOLVER_GOOGLE = 'doh-google';

    /**
     * Resolver path token: all paths failed.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const RESOLVER_FAILED = 'failed';

    /**
     * Cloudflare DoH endpoint.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const CLOUDFLARE_ENDPOINT = 'https://cloudflare-dns.com/dns-query';

    /**
     * Google DoH endpoint.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const GOOGLE_ENDPOINT = 'https://dns.google/resolve';

    /**
     * HTTP timeout for DoH queries, in seconds.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const HTTP_TIMEOUT = 5;

    /**
     * Last resolver path used.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $last_resolver_used = self::RESOLVER_FAILED;

    /**
     * Reset the last-resolver tracker.
     *
     * Call once before a sequence of related queries (e.g. before running an
     * audit) so the most-degraded resolver path used wins after the run.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function reset_resolver_tracker(): void {
        $this->last_resolver_used = self::RESOLVER_NATIVE;
    }

    /**
     * Get the most-degraded resolver path used since the last reset.
     *
     * Native is the highest-fidelity path; Cloudflare is preferred over Google.
     * If multiple queries used different paths, the most-degraded path wins
     * so the UI conservatively reports "we had to fall back."
     *
     * @since 1.0.0
     *
     * @return string Resolver path token.
     */
    public function get_resolver_used(): string {
        return $this->last_resolver_used;
    }

    /**
     * Query DNS for a name and type.
     *
     * Tries native dns_get_record() first; on empty/false, falls back to
     * Cloudflare DoH; then Google DoH. Returns an empty array when all paths
     * fail.
     *
     * @since 1.0.0
     *
     * @param string $name Fully qualified domain name to query.
     * @param string $type Record type — one of TXT, A, PTR.
     *
     * @return string[] List of textual record values.
     */
    public function query( string $name, string $type ): array {
        $type = strtoupper( $type );

        $native = $this->query_native( $name, $type );
        if ( null !== $native ) {
            $this->record_resolver( self::RESOLVER_NATIVE );
            return $native;
        }

        $cloudflare = $this->query_doh( self::CLOUDFLARE_ENDPOINT, $name, $type );
        if ( null !== $cloudflare ) {
            $this->record_resolver( self::RESOLVER_CLOUDFLARE );
            return $cloudflare;
        }

        $google = $this->query_doh( self::GOOGLE_ENDPOINT, $name, $type );
        if ( null !== $google ) {
            $this->record_resolver( self::RESOLVER_GOOGLE );
            return $google;
        }

        $this->record_resolver( self::RESOLVER_FAILED );
        return array();
    }

    /**
     * Record the resolver path used by the most recent query.
     *
     * Promotes the tracker only when the new path is more degraded than the
     * existing one, so the worst path used during a sequence wins.
     *
     * @since 1.0.0
     *
     * @param string $path Resolver path token.
     *
     * @return void
     */
    private function record_resolver( string $path ): void {
        $rank = array(
            self::RESOLVER_NATIVE     => 0,
            self::RESOLVER_CLOUDFLARE => 1,
            self::RESOLVER_GOOGLE     => 2,
            self::RESOLVER_FAILED     => 3,
        );

        $current_rank = $rank[ $this->last_resolver_used ] ?? 0;
        $new_rank     = $rank[ $path ] ?? 0;

        if ( $new_rank > $current_rank ) {
            $this->last_resolver_used = $path;
        }
    }

    /**
     * Query DNS using PHP's native dns_get_record().
     *
     * Returns null when dns_get_record() is unavailable or returns false so
     * the caller can fall through to the next resolver. An empty array means
     * the function ran but found no records.
     *
     * @since 1.0.0
     *
     * @param string $name Fully qualified domain name.
     * @param string $type Record type (TXT, A, PTR).
     *
     * @return string[]|null Records, or null on hard failure.
     */
    private function query_native( string $name, string $type ): ?array {
        if ( ! function_exists( 'dns_get_record' ) ) {
            return null;
        }

        $type_const = $this->map_type_to_native_constant( $type );
        if ( null === $type_const ) {
            return null;
        }

        // Suppress warnings — restricted hosts emit "request failed" notices
        // we want to swallow before falling back to DoH.
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Discouraged
        $records = @dns_get_record( $name, $type_const );

        if ( false === $records ) {
            return null;
        }

        return $this->extract_native_records( $records, $type );
    }

    /**
     * Map a textual DNS type to PHP's DNS_* constant.
     *
     * @since 1.0.0
     *
     * @param string $type Type token.
     *
     * @return int|null Constant value or null when unsupported.
     */
    private function map_type_to_native_constant( string $type ): ?int {
        switch ( $type ) {
            case 'TXT':
                return DNS_TXT;
            case 'A':
                return DNS_A;
            case 'PTR':
                return DNS_PTR;
            default:
                return null;
        }
    }

    /**
     * Extract textual record values from dns_get_record() output.
     *
     * @since 1.0.0
     *
     * @param array  $records Records array as returned by dns_get_record().
     * @param string $type    Record type.
     *
     * @return string[] Flat list of textual values.
     */
    private function extract_native_records( array $records, string $type ): array {
        $out = array();
        foreach ( $records as $record ) {
            if ( ! is_array( $record ) ) {
                continue;
            }

            switch ( $type ) {
                case 'TXT':
                    if ( isset( $record['txt'] ) ) {
                        $out[] = (string) $record['txt'];
                    } elseif ( isset( $record['entries'] ) && is_array( $record['entries'] ) ) {
                        $out[] = implode( '', array_map( 'strval', $record['entries'] ) );
                    }
                    break;
                case 'A':
                    if ( isset( $record['ip'] ) ) {
                        $out[] = (string) $record['ip'];
                    }
                    break;
                case 'PTR':
                    if ( isset( $record['target'] ) ) {
                        $out[] = (string) $record['target'];
                    }
                    break;
            }
        }
        return $out;
    }

    /**
     * Query DNS via a DNS-over-HTTPS endpoint (Cloudflare or Google).
     *
     * Both endpoints accept the same JSON query interface (name, type) and
     * return the same shape (Answer[].data). Returns null on transport error
     * so the caller can fall through; empty array means the resolver ran but
     * found no records.
     *
     * @since 1.0.0
     *
     * @param string $endpoint DoH endpoint URL.
     * @param string $name     Fully qualified domain name.
     * @param string $type     Record type.
     *
     * @return string[]|null Records, or null on transport error.
     */
    private function query_doh( string $endpoint, string $name, string $type ): ?array {
        $url = add_query_arg(
            array(
                'name' => rawurlencode( $name ),
                'type' => $type,
            ),
            $endpoint
        );

        $response = wp_remote_get(
            $url,
            array(
                'timeout'            => self::HTTP_TIMEOUT,
                'redirection'        => 0,
                'reject_unsafe_urls' => true,
                'headers'            => array(
                    'Accept' => 'application/dns-json',
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return null;
        }

        $code = (int) wp_remote_retrieve_response_code( $response );
        if ( $code < 200 || $code >= 300 ) {
            return null;
        }

        $body = (string) wp_remote_retrieve_body( $response );
        $data = json_decode( $body, true );

        if ( ! is_array( $data ) ) {
            return null;
        }

        $answers = isset( $data['Answer'] ) && is_array( $data['Answer'] ) ? $data['Answer'] : array();
        $out     = array();

        foreach ( $answers as $answer ) {
            if ( ! is_array( $answer ) || ! isset( $answer['data'] ) ) {
                continue;
            }

            $value = (string) $answer['data'];

            // TXT records arrive wrapped in surrounding quotes from DoH; strip them
            // and concatenate split strings (separated by `" "`) into one value.
            if ( 'TXT' === $type ) {
                $value = $this->normalize_doh_txt( $value );
            }

            $out[] = $value;
        }

        return $out;
    }

    /**
     * Normalize a DoH TXT record string.
     *
     * DoH returns TXT data exactly as it appears on the wire, including
     * surrounding double-quotes and the `" "` boundary between split strings.
     * Strip both so callers see one continuous string.
     *
     * @since 1.0.0
     *
     * @param string $raw Raw DoH TXT data string.
     *
     * @return string Normalized record.
     */
    private function normalize_doh_txt( string $raw ): string {
        $value = trim( $raw );

        if ( str_starts_with( $value, '"' ) && str_ends_with( $value, '"' ) ) {
            $value = substr( $value, 1, -1 );
        }

        // Concatenate split strings: `"part1" "part2"` becomes `part1part2`.
        $value = str_replace( '" "', '', $value );

        return $value;
    }
}
