<?php

declare(strict_types=1);

namespace PpPhp\Core\Seo;

/**
 * SEO-friendly slug URLs for generated apps (platform feature A1).
 *
 * Serves a record's view page at  /<prefix>/<id>/<slug>  (e.g. /q/123/connect-oracle)
 * instead of /generated/<table>/view.php?id=123. The numeric id is the AUTHORITY;
 * the slug is cosmetic and self-heals: a wrong or missing slug 301-redirects to the
 * canonical URL, which is ideal for SEO (one canonical page, no duplicate content).
 *
 * The pure methods — match(), canonicalPath(), needsRedirect() — carry the logic and
 * are fully unit-tested. dispatch() is the thin generated-app glue that adds the DB
 * lookup + the redirect/require/404 side effects. It is a NO-OP when no routes are
 * registered, so a build with the feature off behaves exactly as before.
 *
 * Route config shape (build-generated, trusted — never user input):
 *   [ 'q' => [ 'table'=>'qa_questions', 'file'=>'/generated/qa_questions/view.php',
 *              'id_param'=>'id', 'id_column'=>'id', 'slug_column'=>'slug',
 *              'title_column'=>'title', 'numeric_id'=>true ] ]
 */
final class SlugRouter
{
    /**
     * Parse a request path against the registered routes.
     *
     * @param array<string,array<string,mixed>> $routes
     * @return array{prefix:string,id:string,slug:string,route:array<string,mixed>}|null
     */
    public static function match(string $path, array $routes): ?array
    {
        $path = (string) (parse_url($path, PHP_URL_PATH) ?? $path);
        $path = trim($path, '/');
        if ($path === '') {
            return null;
        }
        $segs = explode('/', $path);
        // Only /<prefix>/<id> or /<prefix>/<id>/<slug> — anything longer isn't ours.
        if (count($segs) < 2 || count($segs) > 3) {
            return null;
        }
        $prefix = $segs[0];
        if (!isset($routes[$prefix]) || !is_array($routes[$prefix])) {
            return null;
        }
        $id = $segs[1];
        if (($routes[$prefix]['numeric_id'] ?? true) && !ctype_digit($id)) {
            return null;                                   // let normal routing handle non-id paths
        }
        return [
            'prefix' => $prefix,
            'id'     => $id,
            'slug'   => $segs[2] ?? '',
            'route'  => $routes[$prefix],
        ];
    }

    /** Build the canonical path for a resolved record (no APP_BASE prefix). */
    public static function canonicalPath(string $prefix, string $id, string $slug): string
    {
        return '/' . $prefix . '/' . $id . ($slug !== '' ? '/' . $slug : '');
    }

    /**
     * The canonical slug for a record — the ONE definition shared by the router,
     * per-record meta (A2) and the sitemap (A4) so their URLs never diverge:
     * the slug column if it has a value, else a slug from the title column, else
     * the id as a last resort.
     *
     * @param array<string,mixed> $row
     */
    public static function canonicalSlug(array $row, string $slugColumn, string $titleColumn, string $id): string
    {
        $slug = $slugColumn !== '' ? (string) ($row[$slugColumn] ?? '') : '';
        if ($slug === '' && $titleColumn !== '') {
            $slug = Slugify::make((string) ($row[$titleColumn] ?? ''));
        }
        return $slug !== '' ? $slug : $id;
    }

    /** True when the request slug differs from the canonical slug (→ 301). */
    public static function needsRedirect(array $match, string $canonicalSlug): bool
    {
        return (string) ($match['slug'] ?? '') !== $canonicalSlug;
    }

    /** Guard identifiers that come from the (trusted) route config before interpolating them. */
    private static function safeIdent(string $name): ?string
    {
        return preg_match('/^[A-Za-z0-9_]+$/', $name) ? $name : null;
    }

    /**
     * Generated-app entry point. Called once from the front controller with the
     * request path + registered routes. On a match it 301-redirects to canonical,
     * or requires the view page (with $_GET[id] set) and exits, or 404s. On no
     * match it simply returns, so normal routing continues untouched.
     *
     * @param array<string,array<string,mixed>> $routes
     */
    public static function dispatch(string $uri, array $routes, string $baseDir): void
    {
        $m = self::match($uri, $routes);
        if ($m === null) {
            return;
        }
        $route = $m['route'];

        $table   = self::safeIdent((string) ($route['table'] ?? ''));
        $idCol   = self::safeIdent((string) ($route['id_column'] ?? 'id'));
        $slugCol = ($route['slug_column'] ?? '') !== '' ? self::safeIdent((string) $route['slug_column']) : '';
        $titleCol = ($route['title_column'] ?? '') !== '' ? self::safeIdent((string) $route['title_column']) : '';
        $file    = (string) ($route['file'] ?? '');
        if ($table === null || $idCol === null || $file === '') {
            return;                                        // misconfigured → don't hijack the request
        }

        // One lookup: existence + the canonical slug source.
        $cols = $idCol;
        if ($slugCol) { $cols .= ', ' . $slugCol; }
        if (!$slugCol && $titleCol) { $cols .= ', ' . $titleCol; }
        $row = \dbRow("SELECT {$cols} FROM {$table} WHERE {$idCol} = ? LIMIT 1", [$m['id']]);

        if (!$row) {
            http_response_code(404);
            echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>404 — Not found</title></head>'
                . '<body><h1>404</h1><p>That page does not exist.</p></body></html>';
            exit;
        }

        $canonical = self::canonicalSlug($row, (string) $slugCol, (string) $titleCol, (string) $m['id']);

        if (self::needsRedirect($m, $canonical)) {
            $base = defined('APP_BASE') ? (string) constant('APP_BASE') : '';
            header('Location: ' . $base . self::canonicalPath($m['prefix'], (string) $m['id'], $canonical), true, 301);
            exit;
        }

        $_GET[(string) ($route['id_param'] ?? 'id')] = $m['id'];
        require $baseDir . $file;
        exit;
    }
}
