<?php

/**
 * Internal front-end router for WordPress rewrite rules and site route dispatch.
 * Matches path and page routes, then delegates DI/middleware to Route instances.
 *
 * @package    Framework
 * @subpackage Routing
 * @since      1.0.0
 */
namespace Kirki\Framework\Routing;

\defined('ABSPATH') || exit;
use Kirki\Framework\Http\JsonResponse;
use Kirki\Framework\Http\RedirectResponse;
use Kirki\Framework\Http\Request;
use Kirki\Framework\Route;
use Kirki\Framework\Sanitizer;
use Kirki\Framework\SiteExceptionHandler;
use Kirki\Framework\View\TemplateEngine;
use Kirki\Framework\View\View;
use Kirki\Framework\View\ViewContext;
use Kirki\Framework\Wordpress\Constants\HookNames;
use Exception;
use function Kirki\Framework\app;
class SiteRouter
{
    /**
     * Short name used to prefix query vars and route IDs.
     *
     * @var string
     *
     * @since 1.0.0
     */
    protected $namespace;
    /**
     * How routes are matched against the current request.
     *
     * @var string
     *
     * @since 1.0.0
     */
    protected $routing_method;
    /**
     * Site route instances keyed by internal route id.
     *
     * @var array<string, Route>
     *
     * @since 1.0.0
     */
    protected $routes = [];
    /**
     * Registered dispatch hook name and priority combinations.
     *
     * @var array<string, bool>
     *
     * @since 1.0.0
     */
    protected $registered_hooks = [];
    /**
     * Whether boot hooks have been registered.
     *
     * @var bool
     *
     * @since 1.0.0
     */
    protected $hooked = \false;
    /**
     * Shared route parser.
     *
     * @var RouteParser
     *
     * @since 1.0.0
     */
    protected $parser;
    /**
     * Create a SiteRouter instance.
     *
     * @param string $namespace Unique short name for the plugin.
     * @param string $routing_method Routing method constant.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function __construct(string $namespace, string $routing_method = Route::ROUTING_REWRITE_RULES)
    {
        $safe = Sanitizer::apply_rule($namespace, Sanitizer::KEY);
        $this->namespace = $safe !== '' ? $safe : 'siteroute';
        $this->routing_method = $routing_method === Route::ROUTING_PARSE_REQUEST ? Route::ROUTING_PARSE_REQUEST : Route::ROUTING_REWRITE_RULES;
        $this->parser = new RouteParser();
    }
    /**
     * Boot the router with the given site routes.
     *
     * @param array<int, Route> $routes Site route instances.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function boot(array $routes)
    {
        foreach ($routes as $route) {
            if (!$route->is_site_route()) {
                continue;
            }
            $id = $this->route_id($route);
            $this->routes[$id] = $route;
            $this->ensure_hook_registered($route->get_hook_name(), $route->get_hook_priority());
        }
        if ($this->hooked) {
            return;
        }
        $this->hooked = \true;
        if ($this->routing_method === Route::ROUTING_PARSE_REQUEST) {
            add_action('parse_request', [$this, 'intercept_parse_request']);
            return;
        }
        $this->register_rewrite_rules();
        add_filter('query_vars', [$this, 'register_query_vars']);
    }
    /**
     * Flush rewrite rules after registering them.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function flush()
    {
        if ($this->routing_method !== Route::ROUTING_REWRITE_RULES) {
            return;
        }
        $this->register_rewrite_rules();
        flush_rewrite_rules();
    }
    /**
     * Build an absolute URL for a named site route.
     *
     * @param string $name Named route.
     * @param array $params Path param values.
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function url(string $name, array $params = [])
    {
        $route = Route::find_named_route($name);
        if ($route === null || !$route->is_site_route()) {
            return '';
        }
        if ($route->get_match_using() === Route::MATCH_PAGE) {
            $page_id = $this->resolve_page_id($route->get_endpoint());
            return $page_id ? (string) get_permalink($page_id) : '';
        }
        $segments = $route->get_segments();
        $pieces = [];
        foreach ($segments as $segment) {
            if ($segment['type'] === 'literal') {
                $pieces[] = $segment['value'];
                continue;
            }
            $value = $params[$segment['name']] ?? '{' . $segment['name'] . '}';
            $pieces[] = \rawurlencode((string) $value);
        }
        return esc_url(home_url('/' . \implode('/', $pieces)));
    }
    /**
     * Register rewrite rules for path-matched routes.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function register_rewrite_rules()
    {
        foreach ($this->routes as $id => $route) {
            if ($route->get_match_using() === Route::MATCH_PAGE) {
                continue;
            }
            [$pattern, $param_names] = $this->parser->build_site_pattern($route->get_segments(), $route->get_param_types());
            $query = 'index.php?' . $this->namespace . '_route=' . $id;
            foreach ($param_names as $index => $name) {
                $query .= '&' . $this->query_var_name($name) . '=$matches[' . ($index + 1) . ']';
            }
            add_rewrite_rule($pattern, $query, 'top');
        }
    }
    /**
     * Whitelist query vars for rewrite-based routing.
     *
     * @param array $vars Existing WordPress query vars.
     *
     * @return array
     *
     * @since 1.0.0
     */
    public function register_query_vars(array $vars)
    {
        $vars[] = $this->namespace . '_route';
        foreach ($this->routes as $route) {
            if ($route->get_match_using() === Route::MATCH_PAGE) {
                continue;
            }
            foreach ($route->get_segments() as $segment) {
                if ($segment['type'] === 'param') {
                    $vars[] = $this->query_var_name($segment['name']);
                }
            }
        }
        return $vars;
    }
    /**
     * Match path routes during parse_request and replace query vars on hit.
     *
     * @param \WP $wp Main WordPress request object.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function intercept_parse_request($wp)
    {
        $path = isset($wp->request) && $wp->request !== '' ? \trim((string) $wp->request, '/') : $this->resolve_request_path();
        foreach ($this->routes as $id => $route) {
            if ($route->get_match_using() === Route::MATCH_PAGE) {
                continue;
            }
            [$pattern, $param_names] = $this->parser->build_site_pattern($route->get_segments(), $route->get_param_types());
            if (!\preg_match('#' . $pattern . '#', $path, $matches)) {
                continue;
            }
            $query_vars = [$this->namespace . '_route' => $id];
            foreach ($param_names as $index => $name) {
                $query_vars[$this->query_var_name($name)] = $matches[$index + 1] ?? '';
            }
            $wp->query_vars = $query_vars;
            add_filter('redirect_canonical', '__return_false');
            return;
        }
    }
    /**
     * Dispatch matched routes registered on the template_redirect hook.
     *
     * @param int $priority Hook priority being handled.
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function handle_template_redirect(int $priority)
    {
        $match = $this->match_current_request(HookNames::TEMPLATE_REDIRECT, $priority);
        if ($match === null) {
            return;
        }
        $this->dispatch_match($match, \true);
    }
    /**
     * Dispatch matched routes registered on the template_include filter.
     *
     * @param string $template Default template path from WordPress.
     * @param int $priority Hook priority being handled.
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function handle_template_include(string $template, int $priority)
    {
        $match = $this->match_current_request(HookNames::TEMPLATE_INCLUDE, $priority);
        if ($match === null) {
            return $template;
        }
        $route = $match['route'];
        $params = $match['params'];
        if ($route->get_redirect() !== null) {
            $this->send_route_redirect($route, $params);
        }
        if ($route->get_template() !== null && $route->get_action() === null) {
            $file = $this->locate_route_template($route);
            if ($file === '') {
                SiteExceptionHandler::handle(new Exception('Template not found: ' . $route->get_template(), 500));
            }
            return $file;
        }
        try {
            $result = $this->run_route($route, $params);
            if ($result instanceof View) {
                $path = $result->get_template();
                $engine = app(TemplateEngine::class);
                $resolved = $engine->resolve_path($path);
                if ($resolved !== '') {
                    app(ViewContext::class)->prepare($result, (string) $route->get_name(), $resolved);
                    if ($result->uses_layout()) {
                        return $engine->layout_wrapper_path();
                    }
                    return $resolved;
                }
            }
            if (\is_string($result) && $result !== '' && \file_exists($result)) {
                return $result;
            }
            SiteExceptionHandler::handle(new Exception('Site route template_include callback must return a valid template file path.', 500));
        } catch (Exception $exception) {
            SiteExceptionHandler::handle($exception);
        }
        return $template;
    }
    /**
     * Ensure a WordPress action or filter is registered for a hook and priority.
     *
     * @param string $hook_name Dispatch hook name.
     * @param int $priority Hook priority.
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function ensure_hook_registered(string $hook_name, int $priority)
    {
        $key = $hook_name . ':' . $priority;
        if (isset($this->registered_hooks[$key])) {
            return;
        }
        $this->registered_hooks[$key] = \true;
        if ($hook_name === HookNames::TEMPLATE_INCLUDE) {
            add_filter(HookNames::TEMPLATE_INCLUDE, function ($template) use($priority) {
                return $this->handle_template_include($template, $priority);
            }, $priority);
            return;
        }
        add_action(HookNames::TEMPLATE_REDIRECT, function () use($priority) {
            $this->handle_template_redirect($priority);
        }, $priority);
    }
    /**
     * Find and authorize the route due to dispatch on the given hook and priority.
     *
     * @param string $expected_hook_name Expected dispatch hook name.
     * @param int $expected_priority Expected dispatch priority.
     *
     * @return array{route:Route,params:array}|null
     *
     * @since 1.0.0
     */
    protected function match_current_request(string $expected_hook_name, int $expected_priority)
    {
        $route = $this->find_matching_path_route($expected_hook_name, $expected_priority);
        if ($route === null) {
            $route = $this->find_matching_page_route($expected_hook_name, $expected_priority);
        }
        if ($route === null) {
            return null;
        }
        // phpcs:ignore Framework.NamingConventions.SnakeCaseVariable.NotSnakeCase
        $method = \strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
        if (\strtoupper($route->get_method()) !== $method) {
            SiteExceptionHandler::handle(new Exception('Method Not Allowed', 405));
        }
        $params = $this->collect_route_params($route);
        foreach ($route->get_param_validators() as $name => $validator) {
            if (!\call_user_func($validator, $params[$name] ?? null)) {
                SiteExceptionHandler::handle(new Exception('Not Found', 404));
            }
        }
        $params = \array_merge($route->get_with_data(), $params);
        if ($route->get_match_using() === Route::MATCH_PAGE) {
            $params['page_id'] = $this->resolve_page_id($route->get_endpoint());
            $params['current_page_id'] = get_queried_object_id();
        }
        CurrentRoute::set($route->get_name(), $params);
        return ['route' => $route, 'params' => $params];
    }
    /**
     * Dispatch a matched route on template_redirect.
     *
     * @param array{route:Route,params:array} $match Matched route data.
     * @param bool $exit Whether to exit after sending the response.
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function dispatch_match(array $match, bool $exit = \true)
    {
        $route = $match['route'];
        $params = $match['params'];
        status_header(200);
        nocache_headers();
        if ($route->get_redirect() !== null && $route->get_action() === null) {
            $this->send_route_redirect($route, $params);
        }
        if ($route->get_template() !== null && $route->get_action() === null) {
            $file = $this->locate_route_template($route);
            if ($file === '') {
                SiteExceptionHandler::handle(new Exception('Template not found: ' . $route->get_template(), 500));
            }
            \extract($params, \EXTR_SKIP);
            include $file;
            if ($exit) {
                exit;
            }
            return;
        }
        try {
            $result = $this->run_route($route, $params);
            $this->send_response($result);
        } catch (Exception $exception) {
            SiteExceptionHandler::handle($exception);
        }
        if ($exit) {
            exit;
        }
    }
    /**
     * Run authorize, middleware, validate, and controller DI for a site route.
     *
     * @param Route $route The matched route.
     * @param array $params Sanitized route params.
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    protected function run_route(Route $route, array $params)
    {
        return $route->dispatch_site($params);
    }
    /**
     * Send a controller/closure return value to the browser.
     *
     * @param mixed $result The route return value.
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function send_response($result)
    {
        if ($result instanceof RedirectResponse) {
            $result->send();
        }
        if ($result instanceof JsonResponse) {
            status_header($result->get_status());
            \header('Content-Type: application/json; charset=utf-8');
            foreach ($result->get_headers() as $name => $values) {
                $value = \is_array($values) ? \implode(', ', $values) : $values;
                \header($name . ': ' . $value);
            }
            echo wp_json_encode($result->get_data());
            return;
        }
        if ($result instanceof View) {
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled view HTML; dynamic data is escaped in templates via esc_*.
            echo $result->render();
            return;
        }
        if (\is_string($result)) {
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Controller string returns must be pre-escaped safe HTML.
            echo $result;
        }
    }
    /**
     * Collect and sanitize route params from query vars.
     *
     * @param Route $route The matched route.
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function collect_route_params(Route $route)
    {
        $params = [];
        foreach ($route->get_segments() as $segment) {
            if ($segment['type'] !== 'param') {
                continue;
            }
            $name = $segment['name'];
            $raw = get_query_var($this->query_var_name($name));
            $raw = \is_string($raw) ? wp_unslash($raw) : $raw;
            $type = $route->get_param_types()[$name] ?? 'any';
            $sanitizer = $this->parser->resolve_sanitizer($type);
            $params[$name] = Sanitizer::apply_rule($raw, $sanitizer);
        }
        return $params;
    }
    /**
     * Find a path-matched route for the current request and hook context.
     *
     * @param string $expected_hook_name Expected dispatch hook name.
     * @param int $expected_priority Expected dispatch priority.
     *
     * @return Route|null
     *
     * @since 1.0.0
     */
    protected function find_matching_path_route(string $expected_hook_name, int $expected_priority)
    {
        $id = get_query_var($this->namespace . '_route');
        if (empty($id) || !isset($this->routes[$id])) {
            return null;
        }
        $route = $this->routes[$id];
        if ($route->get_match_using() !== Route::MATCH_PATH) {
            return null;
        }
        if ($route->get_hook_name() !== $expected_hook_name || $route->get_hook_priority() !== $expected_priority) {
            return null;
        }
        return $route;
    }
    /**
     * Find a page-matched route for the current request and hook context.
     *
     * @param string $expected_hook_name Expected dispatch hook name.
     * @param int $expected_priority Expected dispatch priority.
     *
     * @return Route|null
     *
     * @since 1.0.0
     */
    protected function find_matching_page_route(string $expected_hook_name, int $expected_priority)
    {
        if (is_admin() || !is_page()) {
            return null;
        }
        $current_page_id = get_queried_object_id();
        foreach ($this->routes as $route) {
            if ($route->get_match_using() !== Route::MATCH_PAGE) {
                continue;
            }
            if ($route->get_hook_name() !== $expected_hook_name || $route->get_hook_priority() !== $expected_priority) {
                continue;
            }
            if ($this->resolve_page_id($route->get_endpoint()) === $current_page_id) {
                return $route;
            }
        }
        return null;
    }
    /**
     * Resolve a page ID or slug to a page ID.
     *
     * @param mixed $page_ref Page ID or slug.
     *
     * @return int
     *
     * @since 1.0.0
     */
    protected function resolve_page_id($page_ref)
    {
        if (\is_numeric($page_ref)) {
            return (int) $page_ref;
        }
        $page = get_page_by_path((string) $page_ref);
        return $page ? (int) $page->ID : 0;
    }
    /**
     * Locate a route-level template file.
     *
     * @param Route $route The route.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function locate_route_template(Route $route)
    {
        $path = $route->get_template();
        $located = locate_template($path);
        $file = $located !== '' ? $located : $path;
        return \file_exists($file) ? $file : '';
    }
    /**
     * Send a route-level redirect.
     *
     * @param Route $route The route.
     * @param array $params Route params.
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function send_route_redirect(Route $route, array $params)
    {
        $redirect = $route->get_redirect();
        $url = \preg_replace_callback('/\\{(\\w+)\\}/', function ($matches) use($params) {
            return isset($params[$matches[1]]) ? \rawurlencode((string) $params[$matches[1]]) : $matches[0];
        }, $redirect['url']);
        wp_safe_redirect($url, $redirect['status']);
        exit;
    }
    /**
     * Resolve the current request path relative to the site home path.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function resolve_request_path()
    {
        // phpcs:ignore Framework.NamingConventions.SnakeCaseVariable.NotSnakeCase
        $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
        $path = (string) \parse_url($request_uri, \PHP_URL_PATH);
        $home_path = (string) \parse_url(home_url(), \PHP_URL_PATH);
        if ($home_path !== '' && $home_path !== '/' && \strpos($path, $home_path) === 0) {
            $path = \substr($path, \strlen($home_path));
        }
        return \trim($path, '/');
    }
    /**
     * Build a stable internal route id.
     *
     * @param Route $route The route.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function route_id(Route $route)
    {
        return $this->namespace . '_' . \substr(\md5(\strtoupper($route->get_method()) . '|' . $route->get_endpoint()), 0, 12);
    }
    /**
     * Build the namespaced query var name for a route param.
     *
     * @param string $name Param name.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function query_var_name(string $name)
    {
        return $this->namespace . '_p_' . $name;
    }
}
