<?php

namespace App\Services;

use App\Core\Config;
use App\Helpers\Logger;
use App\Models\Article;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

class InternalLinkService
{
    private const DEFAULT_MIN_LINKS = 2;
    private const DEFAULT_MAX_LINKS = 4;
    private const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small';
    private const EMBEDDING_INPUT_MAX_CHARS = 1800;
    private const MIN_PARAGRAPH_LENGTH = 120;

    private Client $client;

    public function __construct()
    {
        $this->client = new Client([
            'base_uri' => 'https://api.openai.com/v1/',
            'timeout' => 45,
        ]);
    }

    public function enrichContentWithInternalLinks(array $site, string $title, string $htmlContent): array
    {
        if (!$this->isEnabled()) {
            return [
                'content' => $htmlContent,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'disabled',
            ];
        }

        $siteId = (int) ($site['id'] ?? 0);
        if ($siteId <= 0 || trim($htmlContent) === '') {
            return [
                'content' => $htmlContent,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'invalid_input',
            ];
        }

        $maxLinks = $this->sanitizeLimit(Config::getDbSetting('internal_link_max_count', (string) self::DEFAULT_MAX_LINKS), self::DEFAULT_MAX_LINKS, 1, 8);
        $minLinks = $this->sanitizeLimit(Config::getDbSetting('internal_link_min_count', (string) self::DEFAULT_MIN_LINKS), self::DEFAULT_MIN_LINKS, 0, $maxLinks);

        $candidates = Article::findPublishedLinkCandidatesBySite($siteId, 80);
        if (count($candidates) === 0) {
            return [
                'content' => $htmlContent,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'no_candidates',
            ];
        }

        $ranked = $this->rankCandidates($site, $title, $htmlContent, $candidates);
        if (count($ranked) === 0) {
            return [
                'content' => $htmlContent,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'ranking_empty',
            ];
        }

        $selected = array_slice($ranked, 0, $maxLinks);
        if (count($selected) < $minLinks && count($ranked) >= $minLinks) {
            $selected = array_slice($ranked, 0, $minLinks);
        }

        $injected = $this->injectLinksIntoParagraphs($htmlContent, $selected);

        return [
            'content' => $injected['content'],
            'links_added' => $injected['links_added'],
            'targets' => $injected['targets'],
            'mode' => $injected['mode'],
        ];
    }

    private function isEnabled(): bool
    {
        $raw = (string) Config::getDbSetting('internal_linking_enabled', '1');
        return $raw !== '0';
    }

    private function rankCandidates(array $site, string $title, string $htmlContent, array $candidates): array
    {
        $sourceText = $this->buildEmbeddingText($title, $htmlContent);
        $candidatePayload = [];

        foreach ($candidates as $candidate) {
            $candidateId = (int) ($candidate['id'] ?? 0);
            $candidateWpPostId = (int) ($candidate['wp_post_id'] ?? 0);
            $candidateTitle = trim((string) ($candidate['title'] ?? ''));
            if ($candidateId <= 0 || $candidateWpPostId <= 0 || $candidateTitle === '') {
                continue;
            }

            $url = $this->resolveCandidateUrl($site, $candidate);
            if ($url === null) {
                continue;
            }

            $candidatePayload[] = [
                'id' => $candidateId,
                'title' => $candidateTitle,
                'url' => $url,
                'text' => $this->buildEmbeddingText($candidateTitle, (string) ($candidate['content'] ?? '')),
            ];
        }

        if (count($candidatePayload) === 0) {
            return [];
        }

        $semanticScores = $this->calculateSemanticScores($sourceText, $candidatePayload);
        if ($semanticScores === null) {
            Logger::warning('Internal linking fallback to lexical ranking (embeddings unavailable).', 'publish');
            return $this->calculateLexicalRanking($sourceText, $candidatePayload);
        }

        usort($semanticScores, static fn (array $a, array $b) => $b['score'] <=> $a['score']);
        return array_values(array_filter($semanticScores, static fn (array $row) => $row['score'] > 0.15));
    }

    private function calculateSemanticScores(string $sourceText, array $candidates): ?array
    {
        $apiKey = Config::getDbSetting('openai_api_key', Config::get('OPENAI_API_KEY'));
        if (!is_string($apiKey) || trim($apiKey) === '') {
            return null;
        }

        $model = (string) Config::getDbSetting('openai_embedding_model', self::DEFAULT_EMBEDDING_MODEL);
        $inputs = [$sourceText];
        foreach ($candidates as $candidate) {
            $inputs[] = $candidate['text'];
        }

        try {
            $response = $this->client->post('embeddings', [
                'headers' => [
                    'Authorization' => 'Bearer ' . $apiKey,
                    'Content-Type' => 'application/json',
                ],
                'json' => [
                    'model' => $model,
                    'input' => $inputs,
                ],
            ]);
        } catch (GuzzleException $e) {
            Logger::warning('Embeddings API error for internal linking: ' . $e->getMessage(), 'publish');
            return null;
        }

        $data = json_decode($response->getBody()->getContents(), true);
        if (!is_array($data) || !isset($data['data']) || !is_array($data['data']) || count($data['data']) !== count($inputs)) {
            Logger::warning('Embeddings response invalid for internal linking.', 'publish');
            return null;
        }

        $vectors = [];
        foreach ($data['data'] as $item) {
            $embedding = $item['embedding'] ?? null;
            if (!is_array($embedding)) {
                return null;
            }
            $vectors[] = array_map('floatval', $embedding);
        }

        $sourceVector = $vectors[0];
        $ranked = [];

        foreach ($candidates as $index => $candidate) {
            $candidateVector = $vectors[$index + 1] ?? null;
            if (!is_array($candidateVector)) {
                continue;
            }

            $ranked[] = [
                'id' => $candidate['id'],
                'title' => $candidate['title'],
                'url' => $candidate['url'],
                'score' => $this->cosineSimilarity($sourceVector, $candidateVector),
            ];
        }

        return $ranked;
    }

    private function calculateLexicalRanking(string $sourceText, array $candidates): array
    {
        $sourceTokens = $this->tokenize($sourceText);
        $sourceSet = array_fill_keys($sourceTokens, true);
        $ranked = [];

        foreach ($candidates as $candidate) {
            $targetTokens = $this->tokenize($candidate['text']);
            if (count($targetTokens) === 0) {
                continue;
            }

            $shared = 0;
            foreach ($targetTokens as $token) {
                if (isset($sourceSet[$token])) {
                    $shared++;
                }
            }

            $score = $shared / max(1, count(array_unique($targetTokens)));
            $ranked[] = [
                'id' => $candidate['id'],
                'title' => $candidate['title'],
                'url' => $candidate['url'],
                'score' => $score,
            ];
        }

        usort($ranked, static fn (array $a, array $b) => $b['score'] <=> $a['score']);
        return $ranked;
    }

    private function injectLinksIntoParagraphs(string $html, array $selected): array
    {
        if (!class_exists(\DOMDocument::class) || !class_exists(\DOMXPath::class)) {
            return [
                'content' => $html,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'dom_extension_missing',
            ];
        }

        $doc = new \DOMDocument('1.0', 'UTF-8');
        $wrappedHtml = '<div id="backpro-root">' . $html . '</div>';

        libxml_use_internal_errors(true);
        $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?>' . $wrappedHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        libxml_clear_errors();

        if (!$loaded) {
            return [
                'content' => $html,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'dom_parse_failed',
            ];
        }

        $xpath = new \DOMXPath($doc);
        $paragraphNodes = $xpath->query('//div[@id="backpro-root"]//p');
        if ($paragraphNodes === false || $paragraphNodes->length === 0) {
            return [
                'content' => $html,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'no_paragraphs',
            ];
        }

        $availableParagraphIndexes = [];
        for ($i = 0; $i < $paragraphNodes->length; $i++) {
            $paragraphText = trim((string) $paragraphNodes->item($i)?->textContent);
            if (mb_strlen($paragraphText) >= self::MIN_PARAGRAPH_LENGTH) {
                $availableParagraphIndexes[] = $i;
            }
        }

        if (count($availableParagraphIndexes) === 0) {
            return [
                'content' => $html,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'paragraphs_too_short',
            ];
        }

        $usedParagraphs = [];
        $targets = [];
        $linksAdded = 0;

        foreach ($selected as $candidate) {
            $index = $this->pickBestParagraphIndex($paragraphNodes, $availableParagraphIndexes, $usedParagraphs, (string) $candidate['title']);
            if ($index === null) {
                continue;
            }

            $paragraph = $paragraphNodes->item($index);
            if (!$paragraph instanceof \DOMElement) {
                continue;
            }

            $paragraph->appendChild($doc->createTextNode(' Sprawdz tez: '));
            $anchor = $doc->createElement('a', (string) $candidate['title']);
            $anchor->setAttribute('href', (string) $candidate['url']);
            $anchor->setAttribute('title', (string) $candidate['title']);
            $paragraph->appendChild($anchor);
            $paragraph->appendChild($doc->createTextNode('.'));

            $usedParagraphs[$index] = true;
            $targets[] = [
                'title' => (string) $candidate['title'],
                'url' => (string) $candidate['url'],
                'score' => (float) ($candidate['score'] ?? 0.0),
            ];
            $linksAdded++;
        }

        $root = $xpath->query('//div[@id="backpro-root"]')->item(0);
        if (!$root instanceof \DOMElement) {
            return [
                'content' => $html,
                'links_added' => 0,
                'targets' => [],
                'mode' => 'root_missing',
            ];
        }

        $newHtml = '';
        foreach ($root->childNodes as $child) {
            $newHtml .= $doc->saveHTML($child);
        }

        return [
            'content' => $newHtml !== '' ? $newHtml : $html,
            'links_added' => $linksAdded,
            'targets' => $targets,
            'mode' => 'ok',
        ];
    }

    private function pickBestParagraphIndex(\DOMNodeList $paragraphNodes, array $candidateIndexes, array $usedParagraphs, string $title): ?int
    {
        $titleTokens = array_values(array_unique($this->tokenize($title)));
        $bestIndex = null;
        $bestScore = -1;

        foreach ($candidateIndexes as $index) {
            if (isset($usedParagraphs[$index])) {
                continue;
            }

            $paragraphNode = $paragraphNodes->item($index);
            if (!$paragraphNode instanceof \DOMElement) {
                continue;
            }

            $paragraphText = mb_strtolower(trim((string) $paragraphNode->textContent));
            if ($paragraphText === '') {
                continue;
            }

            $score = 0;
            foreach ($titleTokens as $token) {
                if (mb_strlen($token) < 4) {
                    continue;
                }
                if (str_contains($paragraphText, $token)) {
                    $score++;
                }
            }

            if ($score > $bestScore) {
                $bestScore = $score;
                $bestIndex = $index;
            }
        }

        return $bestIndex;
    }

    private function buildEmbeddingText(string $title, string $htmlContent): string
    {
        $cleanTitle = trim($title);
        $plain = trim((string) preg_replace('/\s+/u', ' ', strip_tags($htmlContent)));

        $text = $cleanTitle;
        if ($plain !== '') {
            $text .= "\n\n" . mb_substr($plain, 0, self::EMBEDDING_INPUT_MAX_CHARS);
        }

        return trim($text);
    }

    private function resolveCandidateUrl(array $site, array $candidate): ?string
    {
        $storedUrl = trim((string) ($candidate['wp_post_url'] ?? ''));
        if ($storedUrl !== '') {
            return $storedUrl;
        }

        $siteUrl = rtrim((string) ($site['url'] ?? ''), '/');
        $wpPostId = (int) ($candidate['wp_post_id'] ?? 0);

        if ($siteUrl === '' || $wpPostId <= 0) {
            return null;
        }

        return $siteUrl . '/?p=' . $wpPostId;
    }

    private function cosineSimilarity(array $a, array $b): float
    {
        $count = min(count($a), count($b));
        if ($count === 0) {
            return 0.0;
        }

        $dot = 0.0;
        $normA = 0.0;
        $normB = 0.0;

        for ($i = 0; $i < $count; $i++) {
            $dot += $a[$i] * $b[$i];
            $normA += $a[$i] * $a[$i];
            $normB += $b[$i] * $b[$i];
        }

        if ($normA <= 0.0 || $normB <= 0.0) {
            return 0.0;
        }

        return $dot / (sqrt($normA) * sqrt($normB));
    }

    private function tokenize(string $text): array
    {
        $text = mb_strtolower($text);
        $text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text) ?? $text;
        $parts = preg_split('/\s+/u', $text);
        if (!is_array($parts)) {
            return [];
        }

        $stopWords = [
            'oraz', 'ktory', 'ktora', 'ktore', 'ktorych', 'przez', 'takze', 'bardzo', 'mozna', 'mozesz',
            'czyli', 'jest', 'sa', 'ten', 'ta', 'to', 'dla', 'pod', 'nad', 'sie', 'jako', 'aby', 'albo', 'lub',
            'or', 'and', 'the', 'with', 'from', 'that', 'this', 'tego', 'tych', 'tym', 'juz', 'wiecej', 'mniej',
        ];
        $stopSet = array_fill_keys($stopWords, true);

        $tokens = [];
        foreach ($parts as $part) {
            $token = trim($part);
            if ($token === '' || mb_strlen($token) < 3) {
                continue;
            }
            if (isset($stopSet[$token])) {
                continue;
            }
            $tokens[] = $token;
        }

        return $tokens;
    }

    private function sanitizeLimit(mixed $value, int $default, int $min, int $max): int
    {
        $intValue = (int) $value;
        if ($intValue === 0) {
            $intValue = $default;
        }
        if ($intValue < $min) {
            return $min;
        }
        if ($intValue > $max) {
            return $max;
        }

        return $intValue;
    }
}
