<?php
/**
 * Centralised, escape-by-default renderer for column-type cell output.
 *
 * Issue #484: every column-type output path must route user-controlled
 * data through the appropriate WordPress escape helper. CVE-2026-5721
 * (wpDataTables) is the canonical example of what we are guarding
 * against — input was clean at ingestion but the per-column renderer
 * concatenated `href` / `src` without re-escaping at emission time.
 *
 * The contract is intentionally simple: callers pass a value, a column
 * type, and an optional context array; the renderer returns HTML. There
 * is no "raw" path. Unknown column types fall through to esc_html().
 */
// @codeCoverageIgnoreStart
if (!defined('ABSPATH')) { exit; }
// @codeCoverageIgnoreEnd
class TC_Cell_Renderer
{
    public const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];

    /**
     * @param mixed  $value   Raw cell value (string, array, or JSON-encoded list).
     * @param string $type    Column type ('website', 'email', 'image',
     *                        'fileupload', 'text', or anything else — unknown
     *                        types fall through to esc_html()).
     * @param array  $context Optional. Recognised keys:
     *                        - 'name' (string): label / alt-text override.
     *                        - 'separator' (string): separator for multi-value.
     */
    public static function render($value, string $type, array $context = []): string
    {
        $type = strtolower($type);

        switch ($type) {
            case 'website':
            case 'url':
            case 'link':
                return self::render_website((string) $value, $context);

            case 'image':
            case 'img':
                return self::render_image((string) $value, $context);

            case 'fileupload':
                return self::render_fileupload($value, $context);

            case 'email':
                return self::render_email((string) $value, $context);

            case 'list':
                // #795 — GF list fields store serialised PHP at the
                // bare field-id slot. Delegate to the slice-1 renderer
                // which safely deserialises (allowed_classes=false)
                // and emits an HTML <table>.
                if (class_exists('TC_List_Field_Renderer')) {
                    $rendered = TC_List_Field_Renderer::render_as_html_table($value);
                    if ($rendered !== '') {
                        return $rendered;
                    }
                }
                return esc_html((string) (is_array($value) ? json_encode($value) : $value));

            case 'signature':
                // #799 — GF signature fields store a URL to a PNG
                // attachment generated by the Signature add-on. Render
                // it as an <img> like the image branch, with a sensible
                // max-height. Filter `gt_signature_max_height` lets
                // themes override.
                return self::render_signature((string) $value, $context);

            case 'post_content':
                // #810 — GF post_content stores HTML. Cell shows a
                // stripped-text preview (default 100 chars, filter
                // gt_post_content_preview_length overrides). Eye popup
                // gets the full wp_kses_post HTML via a separate
                // branch in get_entry_details.
                if (class_exists('TC_Post_Content_Field_Renderer')) {
                    return esc_html(TC_Post_Content_Field_Renderer::render_preview($value));
                }
                // @codeCoverageIgnoreStart
                return esc_html((string) (is_array($value) ? json_encode($value) : $value));
                // @codeCoverageIgnoreEnd

            case 'post_excerpt':
                // #810-adjacent — same shape as post_content.
                if (class_exists('TC_Post_Content_Field_Renderer')) {
                    return esc_html(TC_Post_Content_Field_Renderer::render_preview($value));
                }
                // @codeCoverageIgnoreStart
                return esc_html((string) (is_array($value) ? json_encode($value) : $value));
                // @codeCoverageIgnoreEnd

            case 'post_category':
                // #809 — GF post_category stores "Name:ID". Strip the
                // term-id suffix and emit just the name(s).
                if (class_exists('TC_Post_Category_Field_Renderer')) {
                    return esc_html(TC_Post_Category_Field_Renderer::render_text($value));
                }
                // @codeCoverageIgnoreStart
                return esc_html((string) (is_array($value) ? json_encode($value) : $value));
                // @codeCoverageIgnoreEnd

            case 'post_image':
                // #808 — GF post_image stores composite sub-inputs.
                // The cell renderer receives the bare slot value (a
                // pre-composed HTML string from get_entries when an
                // entry array was available, or empty string when the
                // composer couldn't run). When we receive an HTML
                // <img> token from the composer, emit it raw; when we
                // receive a plain URL or empty string, fall back to
                // the regular image render (matches the slice-1
                // TC_Image_Field branch).
                if (is_string($value) && stripos($value, '<img') !== false) {
                    return $value;
                }
                return self::render_image((string) $value, $context);

            case 'creditcard':
                // #806 — HIGH PRIORITY / PCI. Cell value is a redacted
                // card summary string (e.g. "Visa ending in 1234")
                // pre-composed by get_entries post-process. The renderer
                // ONLY escapes — never reaches back into sub-inputs.
                // Defence-in-depth: even if someone routes raw sub-input
                // data into this branch, we esc_html it as plain text,
                // never an <img>/<a>/markup tag.
                return esc_html((string) (is_array($value) ? '' : $value));

            default:
                return esc_html((string) (is_array($value) ? json_encode($value) : $value));
        }
    }

    /**
     * Resolve a URL fragment against a base, so cells stored as relative paths
     * (e.g. `/about-us`, `../page`, `//cdn.example.com/x.png`) render as absolute
     * URLs regardless of where the table is embedded.
     *
     * Issue #606: tables shipped through AJAX/REST were resolving relative URLs
     * against the API endpoint instead of the page URL.
     *
     * Pass-through cases: already-absolute URLs, in-page anchors (#frag),
     * mailto:/tel:/javascript: schemes. (esc_url() at the call site handles
     * neutralising javascript: separately.)
     *
     * @param string      $url  Raw URL (any of: absolute, protocol-relative, absolute-path, relative, anchor, scheme).
     * @param string|null $base Resolution base. Defaults to home_url('/'), filterable via 'gt_resolve_url_base'.
     * @return string Absolute URL, or the original on pass-through.
     */
    public static function resolve_url(string $url, ?string $base = null): string
    {
        if ($url === '') {
            return '';
        }
        $trimmed = ltrim($url);

        // In-page anchor — never absolutise.
        if ($trimmed !== '' && $trimmed[0] === '#') {
            return $url;
        }

        // Absolute URL with explicit scheme (https://, http://, ftp://, mailto:, tel:, javascript:, etc.)
        if (preg_match('#^[a-z][a-z0-9+.\-]*:#i', $trimmed)) {
            return $url;
        }

        // Determine the base. function_exists() guard so this works in standalone tests.
        if ($base === null) {
            $base = function_exists('home_url') ? home_url('/') : '/';
            if (function_exists('apply_filters')) {
                $base = (string) apply_filters('gt_resolve_url_base', $base);
            }
        }
        $parts = parse_url($base);
        $scheme = isset($parts['scheme']) ? $parts['scheme'] : 'https';
        $host = isset($parts['host']) ? $parts['host'] : '';
        $base_path = isset($parts['path']) ? $parts['path'] : '/';

        // Protocol-relative: //host/path → scheme + url
        if (substr($trimmed, 0, 2) === '//') {
            return $scheme . ':' . $trimmed;
        }

        // Absolute path: /foo → scheme://host + url
        if ($trimmed !== '' && $trimmed[0] === '/') {
            return $scheme . '://' . $host . $trimmed;
        }

        // Relative path: foo or ../foo — resolve against base path.
        // Drop the last segment of base_path if it looks like a file (no trailing slash).
        $base_dir = (substr($base_path, -1) === '/') ? $base_path : dirname($base_path) . '/';
        $combined = $base_dir . $trimmed;

        // Collapse "../" segments.
        $segments = explode('/', $combined);
        $stack = array();
        foreach ($segments as $seg) {
            if ($seg === '' || $seg === '.') {
                // Preserve a trailing empty segment for "/" suffixes by recording emptiness only at the start.
                if (empty($stack) && $seg === '') {
                    $stack[] = '';
                }
                continue;
            }
            if ($seg === '..') {
                if (count($stack) > 1) { array_pop($stack); }
                continue;
            }
            $stack[] = $seg;
        }
        $resolved_path = implode('/', $stack);
        // Re-append trailing slash if original ended with one.
        if (substr($combined, -1) === '/' && substr($resolved_path, -1) !== '/') {
            // @codeCoverageIgnoreStart
            $resolved_path .= '/';
            // @codeCoverageIgnoreEnd
        }
        return $scheme . '://' . $host . $resolved_path;
    }

    private static function render_website(string $url, array $context): string
    {
        $resolved = self::resolve_url($url);
        $safe_url = esc_url($resolved);
        if ($safe_url === '') {
            return esc_html($url);
        }
        $label = isset($context['name']) && $context['name'] !== ''
            ? (string) $context['name']
            : $url;
        return sprintf(
            '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
            $safe_url,
            esc_html($label)
        );
    }

    private static function render_image(string $url, array $context): string
    {
        $resolved = self::resolve_url($url);
        $safe_url = esc_url($resolved);
        if ($safe_url === '') {
            return '';
        }
        $alt = isset($context['name']) ? (string) $context['name'] : '';
        return sprintf(
            '<img src="%s" alt="%s" class="gt-cell-image" />',
            $safe_url,
            esc_attr($alt)
        );
    }

    /**
     * #799 — Render a Gravity Forms `signature` field. The stored
     * value is a URL to a PNG attachment generated by the GF
     * Signature add-on. We emit an `<img>` similar to `render_image`
     * but with a max-height suited to inline display (default 40px,
     * filterable via `gt_signature_max_height`).
     */
    private static function render_signature(string $url, array $context): string
    {
        $resolved = self::resolve_url($url);
        $safe_url = esc_url($resolved);
        if ($safe_url === '') {
            return '';
        }
        $max_height = 40;
        if (function_exists('apply_filters')) {
            $filtered = apply_filters('gt_signature_max_height', $max_height);
            if (is_numeric($filtered) && (int) $filtered > 0) {
                $max_height = (int) $filtered;
            }
        }
        $alt = isset($context['name']) ? (string) $context['name'] : 'signature';
        return sprintf(
            '<img src="%s" alt="%s" class="gt-cell-signature" style="max-height:%dpx;" />',
            $safe_url,
            esc_attr($alt),
            $max_height
        );
    }

    private static function render_email(string $email, array $context): string
    {
        $clean = sanitize_email($email);
        if ($clean === '' || !is_email($clean)) {
            return esc_html($email);
        }
        return sprintf(
            '<a href="mailto:%s">%s</a>',
            esc_attr($clean),
            esc_html($clean)
        );
    }

    /**
     * @param mixed $value   String URL, JSON-encoded array of URLs, or array of URLs.
     * @param array $context Recognised keys:
     *                       - 'name' (string): label / alt-text override.
     *                       - 'context' (string): 'compact' (default, table-cell sized
     *                         80x60 thumbnails) or 'detail' (popup-sized, fills the
     *                         available column up to ~360px tall). Either way the
     *                         image is wrapped in a click-to-open-fullsize anchor.
     */
    private static function render_fileupload($value, array $context): string
    {
        if (empty($value)) { return ''; }

        $urls = is_array($value)
            ? $value
            : (json_decode((string) $value, true) ?: [(string) $value]);

        $is_detail = isset($context['context']) && $context['context'] === 'detail';
        $img_style = $is_detail
            ? 'max-width:100%;max-height:360px;object-fit:contain;display:block;border-radius:4px;'
            : 'max-width:80px;max-height:60px;object-fit:contain;';
        $link_style = $is_detail
            ? 'display:block;line-height:0;'
            : '';

        $out = '';
        foreach ($urls as $url) {
            $url = (string) $url;
            $safe_url = esc_url($url);
            if ($safe_url === '') { continue; }

            $ext  = strtolower(pathinfo($url, PATHINFO_EXTENSION));
            $name = isset($context['name']) && $context['name'] !== ''
                ? (string) $context['name']
                : (string) basename((string) parse_url($url, PHP_URL_PATH));

            if (in_array($ext, self::IMAGE_EXTENSIONS, true)) {
                // Click the image (thumbnail or detail) to open the full-size file
                // in a new tab. Wrapping is safe in table cells too because GT marks
                // fileupload as a non-editable type, so the cell click handler doesn't
                // intercept and the anchor fires normally.
                $out .= sprintf(
                    '<a href="%s" class="gt-file-link gt-file-image-link" target="_blank" rel="noopener noreferrer" title="%s" style="%s"><img src="%s" alt="%s" class="gt-file-image" style="%s" /></a>',
                    $safe_url,
                    esc_attr($name),
                    esc_attr($link_style),
                    $safe_url,
                    esc_attr($name),
                    esc_attr($img_style)
                );
            } else {
                $out .= sprintf(
                    '<a href="%s" class="gt-file-link" target="_blank" rel="noopener noreferrer">%s</a> ',
                    $safe_url,
                    esc_html($name)
                );
            }
        }
        return $out;
    }
}
