<?php
/**
 * TamRank Score Provider
 *
 * Centrale READ-ONLY score aggregator voor het dashboard.
 *
 * BELANGRIJKE PRINCIPES:
 * - Deze class LEEST alleen scores, berekent ze NOOIT
 * - Scores komen van hun originele bronnen (SEO Audit, Content Analyzer, PageSpeed)
 * - Bij ontbrekende data: return 'available' => false, GEEN fallback berekeningen
 * - Altijd traceerbaar: welke score, van welke bron, op welk moment
 *
 * @package TamRank
 * @since 1.1.8
 */

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

class TamRank_Score_Provider
{

    /**
     * Singleton instance
     * @var TamRank_Score_Provider|null
     */
    private static $instance = null;

    /**
     * In-memory cache voor huidige request
     * @var array
     */
    private $cache = [];

    /**
     * Get singleton instance
     *
     * @return TamRank_Score_Provider
     */
    public static function get_instance()
    {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Private constructor
     */
    private function __construct()
    {
        // Geen initialization nodig - pure reader class
    }

    /**
     * Build the WHERE-exclusion SQL applied by the SEO Audit.
     *
     * Keeps the dashboard's post-set identical to the audit page: noindex posts
     * (opt-in, default on) and manually excluded post IDs are left out of the
     * averages. Without this the dashboard SEO/content cards would count posts the
     * audit ignores, producing a different number than the audit page.
     *
     * Assumes the posts table is aliased as `p` in the surrounding query.
     *
     * @return string SQL fragment beginning with " AND ..." (or empty string)
     */
    private function get_audit_exclusion_clauses(): string
    {
        global $wpdb;

        $general_settings = function_exists('tam_rank_cached_option')
            ? tam_rank_cached_option('tam_rank_general_settings', array())
            : get_option('tam_rank_general_settings', array());
        $excl_noindex = isset($general_settings['audit_exclude_noindex']) ? (bool) $general_settings['audit_exclude_noindex'] : true;
        $excl_ids     = get_option('tam_rank_audit_excluded_posts', array());

        $clauses = '';
        if ($excl_noindex) {
            $clauses .= " AND NOT EXISTS (
                SELECT 1 FROM {$wpdb->postmeta} ni
                WHERE ni.post_id = p.ID AND ni.meta_key = '_tam_rank_noindex' AND ni.meta_value = '1'
            )";
        }
        if (!empty($excl_ids)) {
            $safe_ids = implode(',', array_map('intval', $excl_ids));
            $clauses .= " AND p.ID NOT IN ({$safe_ids})";
        }
        return $clauses;
    }

    /**
     * Haal SEO/Meta score op (ALLEEN LEZEN)
     *
     * Bron: _tam_rank_audit_score postmeta
     * Berekend door: TamRank_SEO_Audit class
     *
     * @return array Score data met value, source, type, available
     */
    public function get_seo_score(): array
    {
        if (isset($this->cache['seo'])) {
            return $this->cache['seo'];
        }

        global $wpdb;

        $seo_slugs = TamRank_Content_Type_Config::get_enabled_audit_slugs();
        if (empty($seo_slugs)) {
            $seo_slugs = array('post', 'page');
        }
        $seo_placeholders = implode(', ', array_fill(0, count($seo_slugs), '%s'));
        $excl = $this->get_audit_exclusion_clauses();

        // Haal gemiddelde audit score op (al berekend door SEO Audit)
        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers ({$wpdb->postmeta}/{$wpdb->posts}), a runtime-built %s placeholder string ($seo_placeholders) and an intval'd ID-exclusion clause ($excl) are interpolated; all user values bind via prepare(); request-scoped admin read, caching not applicable.
        $avg_score = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT AVG(CAST(meta_value AS UNSIGNED))
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = '_tam_rank_audit_score'
                 AND p.post_status = 'publish'
                 AND p.post_type IN ({$seo_placeholders})
                 {$excl}",
                ...$seo_slugs
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Tel posts met score < 80 als issues
        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers ({$wpdb->postmeta}/{$wpdb->posts}), a runtime-built %s placeholder string ($seo_placeholders) and an intval'd ID-exclusion clause ($excl) are interpolated; all user values bind via prepare(); request-scoped admin read, caching not applicable.
        $issues = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = '_tam_rank_audit_score'
                 AND CAST(pm.meta_value AS UNSIGNED) < 80
                 AND p.post_status = 'publish'
                 AND p.post_type IN ({$seo_placeholders})
                 {$excl}",
                ...$seo_slugs
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Check of er data is
        $has_data = $avg_score !== null && $avg_score > 0;

        $result = [
            'value' => $has_data ? round((float) $avg_score) : null,
            'issues' => $has_data ? (int) $issues : 0,
            'source' => 'tamrank_seo_audit',
            'type' => 'percentage',
            'available' => $has_data
        ];

        $this->cache['seo'] = $result;
        return $result;
    }

    /**
     * Haal Content score op (ALLEEN LEZEN)
     *
     * Bron: _tam_rank_content_score postmeta
     * Berekend door: TamRank_Content_Analyzer class
     *
     * @return array Score data met value, source, type, available
     */
    public function get_content_score(): array
    {
        if (isset($this->cache['content'])) {
            return $this->cache['content'];
        }

        global $wpdb;

        $content_slugs = TamRank_Content_Type_Config::get_enabled_audit_slugs();
        if (empty($content_slugs)) {
            $content_slugs = array('post', 'page');
        }
        $content_placeholders = implode(', ', array_fill(0, count($content_slugs), '%s'));
        $excl = $this->get_audit_exclusion_clauses();

        // Haal gemiddelde content score op (al berekend door Content Analyzer)
        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers ({$wpdb->postmeta}/{$wpdb->posts}), a runtime-built %s placeholder string ($content_placeholders) and an intval'd ID-exclusion clause ($excl) are interpolated; all user values bind via prepare(); request-scoped admin read, caching not applicable.
        $avg_score = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT AVG(CAST(meta_value AS UNSIGNED))
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = '_tam_rank_content_score'
                 AND p.post_status = 'publish'
                 AND p.post_type IN ({$content_placeholders})
                 {$excl}",
                ...$content_slugs
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Tel posts met score < 80 als issues
        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers ({$wpdb->postmeta}/{$wpdb->posts}), a runtime-built %s placeholder string ($content_placeholders) and an intval'd ID-exclusion clause ($excl) are interpolated; all user values bind via prepare(); request-scoped admin read, caching not applicable.
        $issues = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = '_tam_rank_content_score'
                 AND CAST(pm.meta_value AS UNSIGNED) < 80
                 AND p.post_status = 'publish'
                 AND p.post_type IN ({$content_placeholders})
                 {$excl}",
                ...$content_slugs
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Check of er data is
        $has_data = $avg_score !== null && $avg_score > 0;

        $result = [
            'value' => $has_data ? round((float) $avg_score) : null,
            'issues' => $has_data ? (int) $issues : 0,
            'source' => 'tamrank_content_analyzer',
            'type' => 'percentage',
            'available' => $has_data
        ];

        $this->cache['content'] = $result;
        return $result;
    }

    /**
     * Haal PageSpeed score op (ALLEEN LEZEN - GEEN API CALL!)
     *
     * Bron: transient tam_rank_pagespeed_*
     * Berekend door: TamRank_PageSpeed class (via Google API)
     *
     * @return array Score data met value, mobile, desktop, source, type, available
     */
    public function get_pagespeed(): array
    {
        if (isset($this->cache['pagespeed'])) {
            return $this->cache['pagespeed'];
        }

        $cached = $this->get_homepage_pagespeed_cache();

        if (!$cached || !isset($cached['results'])) {
            $result = [
                'value' => null,
                'mobile' => null,
                'desktop' => null,
                'source' => 'google_pagespeed',
                'type' => 'score',
                'last_scan' => null,
                'available' => false
            ];
            $this->cache['pagespeed'] = $result;
            return $result;
        }

        $results = $cached['results'];

        // Haal scores op - check beide mogelijke keys
        $mobile_score = $results['mobile']['performance_score']
            ?? $results['mobile']['score']
            ?? null;
        $desktop_score = $results['desktop']['performance_score']
            ?? $results['desktop']['score']
            ?? null;

        // Bereken gemiddelde
        $avg_score = null;
        if ($mobile_score !== null && $desktop_score !== null) {
            $avg_score = round(($mobile_score + $desktop_score) / 2);
        } elseif ($mobile_score !== null) {
            $avg_score = $mobile_score;
        } elseif ($desktop_score !== null) {
            $avg_score = $desktop_score;
        }

        // Tel issues (score < 90 = verbeterpunt)
        $issues = 0;
        if ($mobile_score !== null && $mobile_score < 90)
            $issues++;
        if ($desktop_score !== null && $desktop_score < 90)
            $issues++;

        $result = [
            'value' => $avg_score,
            'mobile' => $mobile_score,
            'desktop' => $desktop_score,
            'issues' => $issues,
            'source' => 'google_pagespeed',
            'type' => 'score',
            'last_scan' => $cached['test_time'] ?? null,
            'available' => $avg_score !== null
        ];

        $this->cache['pagespeed'] = $result;
        return $result;
    }

    /**
     * Haal Images health data op (ALLEEN LEZEN - GEEN BEREKENING)
     *
     * Bron: tam_rank_audit_data option
     * Berekend door: TamRank_SEO_Audit class (tijdens audit)
     *
     * @return array Raw data: issues count, total count, available flag
     */
    public function get_images_health(): array
    {
        if (isset($this->cache['images'])) {
            return $this->cache['images'];
        }

        // TODO: 'tam_rank_audit_data' wordt momenteel nergens geschreven — de
        // image-audit -> dashboard koppeling is onaf. Deze read geeft dus altijd
        // de lege default terug (images 'available' => false). Niet weggehaald
        // omdat dit een onafgemaakte feature markeert, geen pure dode code.
        $audit_data = get_option('tam_rank_audit_data', []);

        // Check voor images data (opgeslagen onder 'images' key)
        $images_data = $audit_data['images'] ?? [];

        if (empty($images_data) || !isset($images_data['total'])) {
            $result = [
                'issues' => 0,
                'total' => 0,
                'source' => 'tamrank_image_audit',
                'type' => 'issues',
                'last_scan' => null,
                'available' => false
            ];
            $this->cache['images'] = $result;
            return $result;
        }

        // ALLEEN LEZEN - geen percentage berekening hier
        // Dashboard bepaalt zelf hoe dit getoond wordt
        $result = [
            'issues' => (int) ($images_data['missing_alt'] ?? 0),
            'total' => (int) ($images_data['total'] ?? 0),
            'source' => 'tamrank_image_audit',
            'type' => 'issues',
            'last_scan' => $audit_data['scan_time'] ?? null,
            'available' => true
        ];

        $this->cache['images'] = $result;
        return $result;
    }

    /**
     * Haal Schema status op (ALLEEN LEZEN)
     *
     * Schema is altijd "beschikbaar" als de plugin actief is.
     * We kunnen later echte schema validatie toevoegen.
     *
     * @return array Status data
     */
    public function get_schema_status(): array
    {
        if (isset($this->cache['schema'])) {
            return $this->cache['schema'];
        }

        // Check of er schema's geconfigureerd zijn
        $settings = get_option('tamrank_schema_global_settings', []);

        $score = 0;
        $max_score = 100;
        $issues = 0;

        // Entity + Social portion (40%). How this is scored depends on the
        // organization schema mode. In 'Custom' mode the structured fields below
        // are NEVER rendered — class-schema-render.php::render_schema() outputs ONLY
        // custom_jsonld_code for the org entity and returns early. Scoring those
        // ignored fields produced a false deduction (e.g. -10 for an empty
        // social_profiles field even though the custom JSON-LD already carries
        // sameAs). So branch on entity_type and score what actually renders.
        $entity_type = isset($settings['entity_type']) ? $settings['entity_type'] : 'Organization';

        if ($entity_type === 'Custom') {
            // Mirror output_global_custom_jsonld(): only valid, non-empty JSON is
            // actually output. Score the custom block accordingly.
            $custom_code = isset($settings['custom_jsonld_code']) ? trim((string) $settings['custom_jsonld_code']) : '';
            if ($custom_code === '') {
                // 'Custom' selected but nothing provided → no org schema renders.
                $issues++;
            } else {
                json_decode($custom_code);
                if (json_last_error() === JSON_ERROR_NONE) {
                    // Valid custom org schema renders → full entity+social portion.
                    $score += 40;
                } else {
                    // Invalid JSON → render silently drops it → genuine problem.
                    $issues++;
                }
            }
        } else {
            // 1. Global Settings (30%)
            // Entity (5%)
            if (!empty($settings['entity_type']))
                $score += 5;
            else
                $issues++;
            // Name (5%)
            if (!empty($settings['organization_name']))
                $score += 5;
            else
                $issues++;
            // URL (5%)
            if (!empty($settings['website_url']))
                $score += 5;
            else
                $issues++;
            // Logo (5%)
            if (!empty($settings['logo_url']))
                $score += 5;
            else
                $issues++;
            // Contact (5%) - Email OR Phone
            if (!empty($settings['email']) || !empty($settings['telephone']))
                $score += 5;
            else
                $issues++;
            // Address (5%) - City AND Country
            if (!empty($settings['address']['city']) && !empty($settings['address']['country']))
                $score += 5;
            else
                $issues++;

            // 2. Social Profiles (10%)
            $has_social = false;
            if (!empty($settings['social_profiles']) && is_array($settings['social_profiles'])) {
                $profiles = array_filter($settings['social_profiles']);
                if (!empty($profiles)) {
                    $score += 10;
                    $has_social = true;
                }
            }
            if (!$has_social)
                $issues++;
        }

        // 3. Page Coverage (60%)
        $coverage_score = 0;

        // Get active public post types
        $post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
        $post_types['post'] = 'post';
        $post_types['page'] = 'page';
        unset($post_types['attachment']); // Ignore media

        $active_types = [];
        $total_active_types = 0;

        foreach ($post_types as $pt) {
            $count = wp_count_posts($pt);
            if (isset($count->publish) && $count->publish > 0) {
                $active_types[] = $pt;
                $total_active_types++;
            }
        }

        if ($total_active_types > 0) {
            // Get templates
            $templates = get_option('tamrank_schema_templates', []);
            $covered_types = [];

            foreach ($templates as $tmpl) {
                if (!empty($tmpl['post_types']) && is_array($tmpl['post_types'])) {
                    foreach ($tmpl['post_types'] as $tpt) {
                        $covered_types[] = $tpt;
                    }
                }
            }
            $covered_types = array_unique($covered_types);

            $covered_count = 0;
            foreach ($active_types as $at) {
                if (in_array($at, $covered_types)) {
                    $covered_count++;
                }
            }

            // Auto-detect override: every public post type renders schema
            // automatically by default. Per-post 'tamrank_schema_bron' defaults to
            // 'automatisch' and is_schema_enabled() defaults to true, and there is
            // no global off-switch — so auto-detection is effectively always active.
            // A type therefore counts as covered if it has a template OR auto-detect.
            //
            // NOTE: this previously read get_option('tamrank_schema_bron'), but that
            // key only ever exists as PER-POST meta — never as a global option — so
            // the override never fired. Auto-rendered types without a matching
            // template (CPTs like 'project', or template-less sites on the default
            // auto mode) were wrongly scored as 0% coverage, tanking the schema score.
            $has_auto_detect = true;
            if ($has_auto_detect) {
                $covered_count = $total_active_types;
            }

            $coverage_ratio = $covered_count / $total_active_types;
            $score += ($coverage_ratio * 60);

            if ($coverage_ratio < 1)
                $issues++;
        } else {
            // No content yet? Grant full coverage points so we don't punish empty sites
            $score += 60;
        }

        // Round score
        $final_score = round($score);

        // Status logic
        $status = 'configure';
        if ($final_score >= 80)
            $status = 'active';
        elseif ($final_score > 0)
            $status = 'partial';
        elseif (empty($settings))
            $status = 'not_configured';

        $result = [
            'value' => $final_score,
            'issues' => $issues,
            'source' => 'tamrank_schema',
            'type' => 'status',
            'available' => true,
            'status' => $status
        ];

        $this->cache['schema'] = $result;
        return $result;
    }

    /**
     * Haal alle dashboard categories op in gestandaardiseerd formaat
     *
     * @return array Alle score categories
     */
    public function get_all_categories(): array
    {
        return [
            'seo' => $this->get_seo_score(),
            'content' => $this->get_content_score(),
            'schema' => $this->get_schema_status(),
            'images' => $this->get_images_health(),
            'internal_links' => $this->get_internal_links_score(),
            'pagespeed' => $this->get_pagespeed()
        ];
    }

    /**
     * Haal Internal Links status op (ALLEEN LEZEN)
     *
     * Bron: tam_rank_link_graph option
     * Berekend door: TamRank_Link_Graph class (weekly cron / "Scan now")
     *
     * Dit is een STATUS, geen score. De graaf leest contextuele links uit
     * post_content plus de items van elk nav-menu; links vanuit thema-templates,
     * widgets, page-builder-layouts en categorie-archieven ziet hij niet. Een
     * meting over twee opstellingen liet zien dat daardoor 4 van de 5 gemelde
     * orphans geen echte orphan was — de menu-blindheid was daarvan de grootste
     * bron en is sinds 1.3.9 weg. Het aantal blijft een bruikbare lijst om na te
     * lopen; er een percentage of een issue-teller van maken maakt er een fout
     * cijfer van.
     * Het dashboard toont daarom 'orphans' + 'status' en niet meer 'value'/'issues'.
     *
     * 'value' en 'issues' blijven wél gevuld: de MCP-health-endpoint in de PRO
     * add-on geeft ze door en die payload mag hier niet stilletjes leeglopen.
     *
     * @return array Status data met orphans, status, source, type, available
     */
    public function get_internal_links_score(): array
    {
        if (isset($this->cache['internal_links'])) {
            return $this->cache['internal_links'];
        }

        $snapshot = class_exists('TamRank_Link_Graph') ? TamRank_Link_Graph::get_snapshot() : null;
        $has_data = is_array($snapshot) && !empty($snapshot['generated_at']);

        $value = null;
        $issues = 0;
        $status = 'not_scanned';
        if ($has_data) {
            $total = max(0, (int) ($snapshot['total_posts'] ?? 0));
            // Via get_orphans(), niet rechtstreeks uit de snapshot: pagina's die
            // de gebruiker bewust genegeerd heeft horen niet meer mee te tellen,
            // anders spreekt deze kaart de lijst tegen waar hij naartoe linkt.
            $issues = count(TamRank_Link_Graph::get_orphans());
            $value = $total > 0 ? (int) round((($total - $issues) / $total) * 100) : 100;
            $status = $issues > 0 ? 'review' : 'complete';
        }

        $result = [
            'value' => $value,
            'issues' => $issues,
            'orphans' => $issues,
            'status' => $status,
            'source' => 'tamrank_link_graph',
            'type' => 'status',
            'available' => $has_data
        ];

        $this->cache['internal_links'] = $result;
        return $result;
    }

    /**
     * Haal gecachete PageSpeed resultaten op voor homepage
     *
     * Lost het trailing slash probleem op door URL te normaliseren
     *
     * @return array|false Cached results of false als niet gevonden
     */
    private function get_homepage_pagespeed_cache()
    {
        // Normaliseer homepage URL
        $normalized_url = $this->normalize_url_for_cache(home_url());
        $cache_key = 'tam_rank_pagespeed_' . md5($normalized_url);

        $cached = get_transient($cache_key);
        if ($cached) {
            return $cached;
        }

        // Fallback: probeer variaties (backwards compatibility)
        $variations = [
            home_url('/'),
            home_url(),
            untrailingslashit(home_url())
        ];

        foreach ($variations as $url) {
            $key = 'tam_rank_pagespeed_' . md5($url);
            $cached = get_transient($key);
            if ($cached) {
                return $cached;
            }
        }

        return false;
    }

    /**
     * Normaliseer URL voor consistente cache keys
     *
     * @param string $url URL om te normaliseren
     * @return string Genormaliseerde URL
     */
    private function normalize_url_for_cache(string $url): string
    {
        // Verwijder trailing slash
        $url = untrailingslashit($url);

        // Verwijder /index.php suffix
        $url = preg_replace('#/index\.php$#', '', $url);

        return $url;
    }

    /**
     * Invalideer de in-memory cache
     *
     * Gebruik na het uitvoeren van een nieuwe scan
     */
    public function invalidate_cache(): void
    {
        $this->cache = [];
        delete_transient('tamrank_api_xp_data');
    }
}
