<?php

use Illuminate\Database\Eloquent\Builder;

use Mtc\Core\Models\SeoDefault;
use Mtc\Core\Models\SeoHeading;
use Mtc\Core\Models\SeoPage;
use MTC\Core\SeoCanonical as SeoCanonical;
use Mtc\Shop\Brand;
use Mtc\Shop\Category;

/**
 * Seo class
 *
 * @author: Lukas Giegerich | mtc.
 * @version 2014-04-22
 */
class Seo
{

    /**
     * array holding seo meta information
     * @var array
     */
    public $data = [];

    /**
     * String holding details of what criteria was used to match seo data (script_name, fallback, etc)
     * @var string
     */
    public $matched_by;

    /**
     * path to current page from site root
     * @var string
     */
    public $path;

    /**
     * @var \Page
     */
    protected $p;

    function __construct()
    {
        $this->data = [
            'title' => '',
            'description' => '',
            'heading' => '',
            'canonical' => '',
        ];

        $this->setPath();
    }

    /**
     * Sets CMS Page.
     *
     * @param Page $p
     */
    public function setCmsPage(Page $p)
    {
        $this->p = $p;
    }

    /**
     * Works out the SEO data for the requested URI
     * @return array
     */
    public function getSeoData()
    {
        if (php_sapi_name() == "cli") {
            return;
        }

        $matched_by = '';
        $uri_matches = [];


        //do we have an exact uri match?
        $matched = $this->tryMatchApproximateURI($uri_matches);
        if ($matched) {
            $matched_by = 'approximate_uri';
        }

        $matched_exact = $this->tryMatchExactUri($uri_matches);
        if (!$matched) {
            $matched = $matched_exact;
        }

        //do we have a script name match?
        if (!$matched) {
            $matched = $this->tryMatchRequestUri();
        } elseif (empty($matched_by)) {
            $matched_by = 'exact_uri';
        }
        //do we have a module set seo data match?
        if (!$matched) {
            $matched = $this->tryMatchModuleSetSeoData();
        } elseif (empty($matched_by)) {
            $matched_by = 'module_set_seo_data';
        }
        //do we have applicable defaults?
        if (!$matched) {
            $matched = $this->tryMatchDefaults();
        } elseif (empty($matched_by)) {
            $matched_by = 'script_name';
        }

        //if still no match, use fallback default
        if (!$matched || (empty($this->data['title']) && empty($this->data['description']))) {
            $matched = $this->tryMatchFallback();
        } elseif (empty($matched_by) && $matched) {
            $matched_by = 'default';
        }

        //this code block is the safetey net if someone were to delete the fallback from the db
        if (!$matched) {
            if (!$this->data['title']) {
                $this->data['title'] = config('app.name');
            }

            if (!$this->data['description']) {
                $this->data['description'] = config('app.name');
            }

            $matched_by = 'safety';
        } elseif (empty($matched_by)) {
            $matched_by = 'fallback';
        }

        $this->matched_by = $matched_by;

        $this->tryFindHeading();
        //let's process the given {keywords} based on the context of the current path
        $this->processSeoKeywords();

        $this->tryFindCanonical();

        return $this->data;
    }

    /**
     * Check if we have a specific canonical
     * in SeoAdmin for this URL
     *
     * @return boolean
     */
    private function tryFindCanonical()
    {
        if (!parse_url($this->path)) {
            return false;
        }

        if (!$canonical_path = SeoCanonical::where('path', parse_url($this->path)['path'])->first()) {
            return false;
        }

        if (parse_url($this->path) === $canonical_path->canonical_path) {
            return false;
        }

        $this->data['canonical'] = $canonical_path->canonical_path;
        return true;
    }

    /**
     * Check if we have a specific heading
     * in SeoAdmin for this URL
     *
     * @return void
     */
    private function tryFindHeading(): void
    {
        $seoHeading = SeoHeading::query()
            ->where('path', $this->path)
            ->first();

        if (empty($seoHeading)) {
            return;
        }
        $this->data['heading'] = $seoHeading->text;
    }

    /**
     * Matches path to exact uri page rule in the db
     * The by product of this method is that if no exact match has been made a
     * list of all appoximate uri matches (page rules that fit into the current
     * uri) will be available.
     *
     * @param array $uri_matches
     * @return boolean
     */
    private function tryMatchExactUri(array &$uri_matches)
    {
        $pages = SeoPage::query()
            ->whereRaw('? LIKE CONCAT(`path`, "%")', [$this->path])
            ->get();
        if ($pages->count() === 0) {
            return false;
        }
        foreach ($pages as $page) {
            if ($page->path === $this->path) {
                return $this->setSeoData($page->toArray());
            }
        }
        return false;
    }

    /**
     * Matches if a single brand has been set (browse only)
     *
     * @param ProductFilter $filter
     * @return boolean|array
     */
    private function tryMatchBrand(ProductFilter $filter): bool|array
    {
        if (count($filter->selections['brands']) != 1) {
            return false;
        }

        $brand = Brand::query()
            ->select('seo_title as title', 'seo_description as description')
            ->where('id', $filter->selections['brands'][0])
            ->first();

        if (empty($brand)) {
            return false;
        }
        return $brand->toArray();
    }

    /**
     * Matches if a category selection makes sense (part of path on category tree selected)
     *
     * @param ProductFilter $filter
     * @return boolean|array
     * @throws Exception
     */
    private function tryMatchCategory(ProductFilter $filter): bool|array
    {
        $total_count = count($filter->selections['categories_all']);
        if ($total_count >= MAX_CATEGORY_DEPTH || $total_count <= 0) {
            return false;
        }
        $selected_cats = Category::query()
            ->select('id', 'sub_id')
            ->whereIn('id', $filter->selections['categories_all'])
            ->get()
            ->keyBy('id')
            ->toArray();
        //figure out if the selected cats are a path
        $bottom_cat_id = array_diff($filter->selections['categories_all'], $selected_cats);
        if (count($bottom_cat_id) != 1) {
            return false;
        }

        $bottom_cat_id = end($bottom_cat_id);
        $category = Category::query()
            ->select('seo_title as title', 'seo_description as description')
            ->where('id', $bottom_cat_id)
            ->where(static function (Builder $query): void {
                $query->where('seo_title', '!=', '')
                    ->orWhere('seo_description', '!=', '');
            })
            ->first();
        if (empty($category)) {
            return false;
        }
        return $category->toArray();
    }

    /**
     * Matches path to closest page rule
     *
     * @param array $uri_matches
     * @return boolean
     */
    private function tryMatchApproximateURI(array &$uri_matches)
    {
        if (count($uri_matches) > 0) {
            if (count($uri_matches) > 1) {
                //sort matches with best on top
                usort($uri_matches, ['SeoHelper', 'sortByLength']);
            }
            //return best match
            return $this->setSeoData($uri_matches[0]);
        } else {
            return false;
        }
    }

    /**
     * Matches path against script name defined in page rules
     *
     * @return boolean
     */
    private function tryMatchRequestUri()
    {
        $seoPage = SeoPage::query()
            ->where('path', parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))
            ->first();

        if (empty($seoPage)) {
            return false;
        }
        return $this->setSeoData($seoPage->toArray());
    }

    /**
     * Matches path against default rules
     *
     * @return boolean
     */
    private function tryMatchDefaults()
    {
        if (SeoHelper::isBrowse()) {
            $path = '/browse/index.php';
        } elseif (!empty($this->p)) {
            if ($this->p->listing_item) {
                $path = 'cms_listing_item';
            } elseif ($this->p->listing_container) {
                $path = 'cms_listing_container';
            } else {
                $path = 'cms_page';
            }
        } else {
            $path = parse_url($_SERVER['PHP_SELF'], PHP_URL_PATH);
        }

        $seoDefault = SeoDefault::query()
            ->where('path', $path)
            ->first();
        if (empty($seoDefault)) {
            return false;
        }
        return $this->setSeoData($seoDefault->toArray());
    }

    /**
     * Attempts to get seo data from a module(shop/cms/..) seo area/fields
     * Currently only does this for CMS as shop doesn't have a seo area/fields
     * @return boolean
     * @author Rihards Silins <rihards.silins@mtcmedia.co.uk>
     */
    private function tryMatchModuleSetSeoData()
    {
        $seo_data = [];

        // SHOP (browse)
        if (SeoHelper::isBrowse()) {
            $filter = SeoHelper::getFilter();
        }

        // CMS
        if (!empty($this->p)) {
            if (!empty($this->p->seo_title)) {
                $seo_data['title'] = $this->p->seo_title;
            }
            if (!empty($this->p->seo_description)) {
                $seo_data['description'] = $this->p->seo_description;
            }
            if (empty($seo_data)) {
                return false;
            }
            return $this->setSeoData($seo_data);
        }

        return false;
    }

    /**
     * Matches all against fallback
     *
     * @return boolean
     */
    private function tryMatchFallback()
    {
        $seoDefault = SeoDefault::query()
            ->where('path', '')
            ->first();
        if (empty($seoDefault)) {
            return false;
        }
        $default_seo_data = $seoDefault->toArray();
        foreach ($this->data as $key => $value) {
            if ($this->data[$key]) { continue; }

$this->data[$key] = $default_seo_data[$key];
        }
        return true;
    }

    /**
     * replaces keywords with defined values in seo data and otherwise removes
     * keywords
     */
    private function processSeoKeywords()
    {

        $this->data = str_replace('{SITE_NAME}', config('app.name'), $this->data);

        //process {FILE_NAME}
        if (str_contains(implode($this->data), '{FILE_NAME}')  && $_SERVER['SCRIPT_NAME']) {

            //get the name of the file or folder if index for the current page
            $pathinfo = (pathinfo($_SERVER['SCRIPT_NAME']));
            $sanitized = filter_var($pathinfo, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW);
            if ($sanitized['filename'] != 'index') {
                $file_name = $sanitized['filename'];
            } else {
                $file_name = preg_replace('#.*/([^/]+)$#', '$1', $sanitized['dirname']);
            }

            $file_name = preg_replace('/[._-]/', ' ', $file_name); //replace special chars with spaces
            $file_name = preg_replace('/([A-Z](?=[a-z ]))/', ' $1', $file_name); //insert spaces before upper case letters
            $file_name = preg_replace('/((?<=[a-z])[A-Z])/', ' $1', $file_name); //insert spaces before upper case letters
            $file_name = ucwords($file_name); //make all words uppercase

            $this->data = str_replace('{FILE_NAME}', $file_name, $this->data);
        }

        if ($_SERVER['PHP_SELF'] == '/shop/item.php') {
            $this->processItemPageSeoKeywords();
        } elseif (SeoHelper::isBrowse()) {
            $this->processBrowsePageSeoKeywords();
        } elseif ($this->p) {
            $this->processCmsSeoKeywords();
        }

        //remove unmatched keywords and resulting whitespace surplus
        $this->data = preg_replace('/\{[^}]+\}?/', '', $this->data);
        $this->data = preg_replace('/(\s+)/', ' ', $this->data);
        $this->data = preg_replace('/ (- )+/', ' - ', $this->data);

        //make the ends nice and neat
        foreach ($this->data as $key => $value) {
            while ($this->data[$key] != trim($value, " \t\n\r\0\x0B|-")) {
                $this->data[$key] = trim($value, " \t\n\r\0\x0B|-");
            }
        }
    }

    /**
     * Processes keywords that bear special meaning in the context of the item
     * page
     */
    private function processItemPageSeoKeywords()
    {

        //construct search string
        $search_string = implode('', $this->data);
        $item_id = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0;

        if (! $item_id && !empty($_REQUEST['slug'])) {
            $item_id = \Mtc\Shop\Item\Custom::query()
                ->where('slug', $_REQUEST['slug'])
                ->value('item_id') ?? 0;
        }

        if (! $item_id) {
            return;
        }

        if (str_contains($search_string, '{ITEM}')) {
            $replacement = '';
            $item = \Mtc\Shop\Item::query()
                ->where('id', $item_id)
                ->first();
            if (!empty($item)) {
                $replacement = $item->name;
            }
            $this->data = str_replace('{ITEM}', $replacement, $this->data);
        }
        if (str_contains($search_string, '{BRAND}')) {
            $replacement = '';
            $brand = Brand::query()
                ->whereHas('items', static function ($query) use ($item_id): void {
                    $query->where('items.id', $item_id);
                })
                ->first();
            if (!empty($brand)) {
                $replacement = $brand->name;
            }
            $this->data = str_replace('{BRAND}', $replacement, $this->data);
        }
        if (str_contains($search_string, '{CATEGORY}')) {
            $replacement = '';
            $category = Category::query()
                ->whereHas('items', static function ($query) use ($item_id): void {
                    $query->where('items.id', $item_id);
                })
                ->first();
            if (!empty($category)) {
                $replacement = $category->name;
            }
            $this->data = str_replace('{CATEGORY}', $replacement, $this->data);
        }
    }

    /**
     * Processes keywords that bear special meaning in the context of the browse
     * page
     */
    private function processBrowsePageSeoKeywords()
    {
        //construct search string
        $search_string = implode('', $this->data);

        $filter = SeoHelper::getFilter();

        if (str_contains($search_string, '{BRAND}') ) {
            $replacement = '';
            if (count($filter->selections['brands']) == 1) {
                $brand = Brand::query()
                    ->where('id', $filter->selections['brands'][0])
                    ->first();
                if (!empty($brand)) {
                    $replacement = $brand->name;
                }
            }
            $this->data = str_replace('{BRAND}', $replacement, $this->data);
        }

        if (str_contains($search_string, '{PAGE}') ) {
            $replace_page = $filter->page > 1 ? 'Page ' . $filter->page : '';
            $this->data = str_replace('{PAGE}', $replace_page, $this->data);
        }

        if (str_contains($search_string, '{CATEGORIES}') || str_contains($search_string, '{CATEGORY}')) {
            $cat_replacement = '';
            $cats_replacement = '';
            $total_count = count($filter->selections['categories_all']);
            if ($total_count <= MAX_CATEGORY_DEPTH && $total_count > 0) { //maximum of six levels assumed
                $parents_pairs = Category::query()
                    ->select('id', 'sub_id')
                    ->whereIn('id', $filter->selections['categories_all'])
                    ->get()
                    ->keyBy('id')
                    ->toArray();

                $bottom_cat_id = array_diff($filter->selections['categories_all'], array_keys($parents_pairs));
                if (count($bottom_cat_id) != 1) {
                    return false;
                }

                $cur_id = end($bottom_cat_id);
                $category = Category::query()
                    ->find($cur_id);
                $parentCategories = Category::query()
                    ->whereIn('id', Category::allParents($cur_id))
                    ->get();
                if (!empty($category)) {
                    $cat_replacement = $category->name;
                }
                if ($parentCategories->count() > 0) {
                    $cats_replacement = $parentCategories->pluck('name')->implode('name', ' | ');
                }
            }
            if (empty($cat_replacement)) {
                $cat_replacement = 'Shop';
            }
            $this->data = str_replace('{CATEGORIES}', $cats_replacement, $this->data);
            $this->data = str_replace('{CATEGORY}', $cat_replacement, $this->data);
        }
    }

    /**
     * Sets SEO data - returns true as this is used in the context of a
     * successfull rule match
     *
     * @param array $seo_data
     * @return boolean
     */
    private function setSeoData($seo_data)
    {
        $this->data = [
            'title' => $seo_data['title'] ?? null,
            'description' => $seo_data['description'] ?? null,
        ];
        return true;
    }

    /**
     * sets the path to be looked at by working out the clients request uri
     */
    public function setPath()
    {
        $path = '';
        if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] !== '') {
            $path = $_SERVER['REQUEST_URI'];
        } else {
            $path = $_SERVER['PHP_SELF'];
            if (count($_SERVER['argv']) > 0) {
                $path .= '?' . $_SERVER['argv'][0];
            }
        }

        // if the URI is just a forward slash it should be interpreted as
        // index.php for seo purposes
        if (($path == '/') || (substr($path, 0, 2) === '/?')) {
            $path = '/index.php';
        }

        $this->path = $path;
    }

    /**
     * Replaces two keywords {PAGE_TITLE} by page title and {PAGE_CONTENT}{<CUTOFF CHARS>}
     * by Content shortened to a maximum of <CUTOFF CHARS> while not cutting in the middle
     * of the last word and appending … at the end/replacing ?/!/. if it's last char.
     * If Content is not available, falls back to <page title> | SITE_NAME.
     *
     * @author Jindřich Prokop <jindrich.prokop@mtcmedia.co.uk>
     */
    protected function processCmsSeoKeywords()
    {
        $this->data = str_replace('{PAGE_TITLE}', $this->p->title, $this->data);

        if (empty($this->p->pagedata['Content'][0]['value'])) {
            $replacement = "{$this->p->title} | " . config('app.name');
        } else {
            $replacement = strip_tags(preg_replace('/\s+/', ' ', $this->p->pagedata['Content'][0]['value']));
        }

        foreach ($this->data as &$datum) {
            if (!(preg_match_all('/\{PAGE_CONTENT\}\{([0-9]*)\}/', $datum, $matches))) { continue; }

foreach ($matches[1] as $cutoff) {
                    $cut_position = strpos(wordwrap($replacement, $cutoff - 2), "\n");
                    $cut_position = $cut_position ?: $cutoff;
                    $replacement = rtrim(substr($replacement, 0, $cut_position), '?.!') . '…';
                    $datum = str_replace("{PAGE_CONTENT}{{$cutoff}}", $replacement, $datum);
                }
        }
    }

}
