<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Linkquiver_Media_Handler {

    /**
     * Upload a file from $_FILES-style array.
     *
     * @param array $file    $_FILES entry (tmp_name, name, type, size, error)
     * @param int   $post_id Optional parent post ID.
     * @return array
     */
    public function upload_file( $file, $post_id = 0, $alt = '' ) {
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';
        require_once ABSPATH . 'wp-admin/includes/media.php';

        // WordPress needs the file in $_FILES for media_handle_upload
        $_FILES['linkquiver_upload'] = $file;

        $attachment_id = media_handle_upload( 'linkquiver_upload', $post_id );

        unset( $_FILES['linkquiver_upload'] );

        if ( is_wp_error( $attachment_id ) ) {
            return array(
                'success' => false,
                'error'   => $attachment_id->get_error_message(),
            );
        }

        $this->set_alt_text( $attachment_id, $alt );

        return $this->format_attachment( $attachment_id );
    }

    /**
     * Download a remote image and add it to the media library.
     *
     * @param string $url      Remote image URL.
     * @param string $filename Optional filename override.
     * @param int    $post_id  Optional parent post ID.
     * @return array
     */
    public function sideload_from_url( $url, $filename = '', $post_id = 0, $alt = '' ) {
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';
        require_once ABSPATH . 'wp-admin/includes/media.php';

        // SSRF guard: only http/https + reject loopback / link-local / RFC1918
        // before letting WP fetch anything. We're not a generic URL proxy —
        // legitimate cover images always live on a public CDN.
        //
        // The guard returns the validated IP. We pin curl to that IP via
        // CURLOPT_RESOLVE so the actual TCP connection cannot be hijacked by a
        // DNS-rebinding attacker who served a public IP at validation time and
        // flips the record to 169.254.169.254 (or 10.x) before the fetch.
        $guard = self::guard_remote_url( $url );
        if ( is_wp_error( $guard ) ) {
            return array(
                'success' => false,
                'error'   => $guard->get_error_message(),
            );
        }
        $pinned_ip = $guard;

        // Download with a curl handle that pins host -> $pinned_ip. Redirects
        // are disabled because a 3xx response could relocate the fetch to a
        // private IP (rebinding via Location header).
        $tmp_file = self::download_pinned( $url, $pinned_ip, 30 );

        if ( is_wp_error( $tmp_file ) ) {
            return array(
                'success' => false,
                'error'   => 'Failed to download image: ' . $tmp_file->get_error_message(),
            );
        }

        // Reject SVG/XML payloads regardless of how the URL extension was
        // spelled. WordPress doesn't sanitize SVG by default and serves it
        // inline as `image/svg+xml`, so a <script> inside the file becomes
        // stored XSS on any page that embeds the attachment URL.
        $detected_mime = function_exists( 'mime_content_type' ) ? @mime_content_type( $tmp_file ) : '';
        $blocked_mimes = array( 'image/svg+xml', 'text/xml', 'application/xml', 'text/html' );
        if ( in_array( strtolower( (string) $detected_mime ), $blocked_mimes, true ) ) {
            @unlink( $tmp_file );
            return array(
                'success' => false,
                'error'   => 'SVG / XML / HTML uploads are not allowed.',
            );
        }

        // Determine filename
        if ( empty( $filename ) ) {
            $filename = basename( wp_parse_url( $url, PHP_URL_PATH ) );
        }

        // Strip an .svg extension if the caller hand-set the filename — we
        // don't want media_handle_sideload to attempt the upload under that
        // extension at all (defense in depth on top of the mime check above).
        if ( preg_match( '/\.svg(z)?$/i', $filename ) ) {
            @unlink( $tmp_file );
            return array(
                'success' => false,
                'error'   => 'SVG uploads are not allowed.',
            );
        }

        // Ensure the file has an extension
        if ( ! pathinfo( $filename, PATHINFO_EXTENSION ) ) {
            $mime = $detected_mime ?: 'image/jpeg';
            $ext  = $this->mime_to_ext( $mime );
            $filename .= '.' . $ext;
        }

        $file_array = array(
            'name'     => sanitize_file_name( $filename ),
            'tmp_name' => $tmp_file,
        );

        $attachment_id = media_handle_sideload( $file_array, $post_id );

        // Clean up temp file if sideload failed
        if ( is_wp_error( $attachment_id ) ) {
            if ( file_exists( $tmp_file ) ) {
                wp_delete_file( $tmp_file );
            }
            return array(
                'success' => false,
                'error'   => $attachment_id->get_error_message(),
            );
        }

        $this->set_alt_text( $attachment_id, $alt );

        return $this->format_attachment( $attachment_id );
    }

    /**
     * Set the attachment's alt text (`_wp_attachment_image_alt`) when provided.
     * No-op on empty input so we never clobber an existing alt with a blank.
     *
     * @param int    $attachment_id
     * @param string $alt
     */
    private function set_alt_text( $attachment_id, $alt ) {
        $alt = sanitize_text_field( (string) $alt );
        if ( '' !== $alt ) {
            update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
        }
    }

    /**
     * Format attachment data for the response.
     */
    private function format_attachment( $attachment_id ) {
        $url     = wp_get_attachment_url( $attachment_id );
        $meta    = wp_get_attachment_metadata( $attachment_id );

        return array(
            'success'    => true,
            'id'         => $attachment_id,
            'url'        => $url,
            'source_url' => $url,
            'width'      => $meta['width'] ?? null,
            'height'     => $meta['height'] ?? null,
            'mime_type'  => get_post_mime_type( $attachment_id ),
            'filesize'   => filesize( get_attached_file( $attachment_id ) ) ?: null,
        );
    }

    /**
     * Block obvious SSRF targets: non-http schemes, loopback, link-local,
     * RFC1918 private ranges, and unique-local IPv6. Returns WP_Error on
     * rejection, or the resolved-and-validated IP string on accept. The
     * caller must use that IP via CURLOPT_RESOLVE so the actual TCP
     * connection cannot be diverted by a DNS-rebinding attacker (TOCTOU).
     */
    private static function guard_remote_url( $url ) {
        $parts  = wp_parse_url( $url );
        $scheme = strtolower( $parts['scheme'] ?? '' );
        $host   = $parts['host'] ?? '';

        if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
            return new WP_Error( 'linkquiver_bad_scheme', 'Only http(s) URLs allowed.' );
        }
        if ( '' === $host ) {
            return new WP_Error( 'linkquiver_bad_host', 'URL host missing.' );
        }

        // Resolve to an IP. gethostbyname returns the hostname unchanged on
        // failure — guard against that. For IPv6 names, dns_get_record
        // catches AAAA records that gethostbyname doesn't.
        $candidates = array();
        if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
            $candidates[] = $host;
        } else {
            $v4 = gethostbyname( $host );
            if ( $v4 !== $host && filter_var( $v4, FILTER_VALIDATE_IP ) ) {
                $candidates[] = $v4;
            }
            if ( function_exists( 'dns_get_record' ) ) {
                $aaaa = @dns_get_record( $host, DNS_AAAA );
                if ( is_array( $aaaa ) ) {
                    foreach ( $aaaa as $rec ) {
                        if ( ! empty( $rec['ipv6'] ) ) $candidates[] = $rec['ipv6'];
                    }
                }
            }
        }

        if ( empty( $candidates ) ) {
            return new WP_Error( 'linkquiver_dns_failed', 'Could not resolve host.' );
        }

        foreach ( $candidates as $ip ) {
            // FILTER_FLAG_NO_PRIV_RANGE blocks 10/8, 172.16/12, 192.168/16, fc00::/7.
            // FILTER_FLAG_NO_RES_RANGE blocks loopback, link-local, multicast, etc.
            if ( false === filter_var(
                $ip,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) ) {
                return new WP_Error(
                    'linkquiver_private_ip',
                    'URL resolves to a private or reserved IP range.'
                );
            }
        }

        // Pick the first candidate as the IP to pin. Multi-record load-balancing
        // is intentionally lost: we trade a bit of DNS-RR rotation for a fully
        // closed rebinding window.
        return $candidates[0];
    }

    /**
     * Download $url into a temp file by pinning the TCP connection to
     * $resolved_ip via CURLOPT_RESOLVE. This closes the DNS-rebinding
     * TOCTOU window between the SSRF guard's DNS lookup and the actual
     * fetch.
     *
     * Redirects are disabled because a 3xx Location header could relocate
     * the fetch to a different host (and a private IP) that we have not
     * validated. The SaaS caller is expected to pass a fully-resolved
     * canonical URL.
     */
    private static function download_pinned( $url, $resolved_ip, $timeout = 30 ) {
        if ( ! function_exists( 'curl_init' ) ) {
            return new WP_Error(
                'linkquiver_no_curl',
                'cURL PHP extension required for secure image sideload.'
            );
        }

        $parts  = wp_parse_url( $url );
        $host   = $parts['host'] ?? '';
        $scheme = strtolower( $parts['scheme'] ?? 'https' );
        $port   = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'http' === $scheme ? 80 : 443 );

        $tmpfname = wp_tempnam( $url );
        if ( ! $tmpfname ) {
            return new WP_Error( 'http_no_file', 'Could not create temporary file.' );
        }
        $fp = @fopen( $tmpfname, 'wb' );
        if ( ! $fp ) {
            @unlink( $tmpfname );
            return new WP_Error( 'http_no_file', 'Could not write to temporary file.' );
        }

        // CURLOPT_RESOLVE format: "host:port:ip". IPv6 addresses need brackets
        // because the address contains colons that would otherwise collide
        // with the field separator.
        $resolve_ip = ( false !== strpos( $resolved_ip, ':' ) )
            ? '[' . $resolved_ip . ']'
            : $resolved_ip;
        $resolve = $host . ':' . $port . ':' . $resolve_ip;

        $ch = curl_init();
        curl_setopt( $ch, CURLOPT_URL, $url );
        curl_setopt( $ch, CURLOPT_FILE, $fp );
        curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false );
        curl_setopt( $ch, CURLOPT_TIMEOUT, (int) $timeout );
        curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
        curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
        curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );
        curl_setopt( $ch, CURLOPT_RESOLVE, array( $resolve ) );
        // Hard cap to keep an attacker who points at a giant file from
        // exhausting disk. WP's default media upload max is 50 MB.
        if ( defined( 'CURLOPT_MAXFILESIZE_LARGE' ) ) {
            curl_setopt( $ch, CURLOPT_MAXFILESIZE_LARGE, 50 * 1024 * 1024 );
        } else {
            curl_setopt( $ch, CURLOPT_MAXFILESIZE, 50 * 1024 * 1024 );
        }
        // Generic WP-style UA — the branded "LinkQuiver/x.y.z" string was a
        // needless fingerprint on the remote host we fetch from.
        curl_setopt( $ch, CURLOPT_USERAGENT, 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' ) );

        $ok           = curl_exec( $ch );
        $http_code    = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
        $effective_ip = curl_getinfo( $ch, CURLINFO_PRIMARY_IP );
        $curl_err     = curl_error( $ch );
        curl_close( $ch );
        fclose( $fp );

        if ( false === $ok ) {
            @unlink( $tmpfname );
            return new WP_Error( 'http_request_failed', 'Download failed: ' . $curl_err );
        }

        if ( $http_code >= 300 || $http_code < 200 ) {
            @unlink( $tmpfname );
            return new WP_Error(
                'http_bad_status',
                sprintf( 'Download failed with HTTP %d (redirects disabled).', $http_code )
            );
        }

        // Belt-and-suspenders: confirm curl actually talked to our pinned IP.
        // CURLOPT_RESOLVE should guarantee this; we verify in case of a
        // misbehaving curl version or transparent proxy in the middle.
        if ( $effective_ip && $effective_ip !== $resolved_ip ) {
            @unlink( $tmpfname );
            return new WP_Error(
                'linkquiver_ip_mismatch',
                sprintf( 'Connection landed on %s, expected %s.', $effective_ip, $resolved_ip )
            );
        }

        return $tmpfname;
    }

    private function mime_to_ext( $mime ) {
        // SVG is intentionally NOT in this map. WordPress does not sanitize
        // SVG files by default and serves them with `image/svg+xml`, which
        // means a <script> embedded in the SVG executes in any browser that
        // renders the attachment URL inline. The cover images we sideload
        // are always raster (JPG/WebP/PNG), so we drop SVG support to close
        // the stored-XSS vector that comes with it.
        $map = array(
            'image/jpeg' => 'jpg',
            'image/png'  => 'png',
            'image/gif'  => 'gif',
            'image/webp' => 'webp',
            'image/avif' => 'avif',
        );
        return $map[ $mime ] ?? 'jpg';
    }
}
