<?php

/**
 * Minimal stub classes for Grav CMS types used in the API plugin.
 *
 * These are loaded ONLY when the real Grav classes are not available
 * (i.e., when the plugin is tested outside of a Grav installation).
 * They provide just enough structure for the plugin classes to be
 * instantiated and tested in isolation.
 */

// Only declare stubs if the real classes don't exist.
// This file is loaded by the bootstrap's fallback autoloader.

namespace Grav\Common\Config {
    if (!class_exists(\Grav\Common\Config\Config::class, false)) {
        class Config
        {
            public function __construct(protected array $items = []) {}

            public function get(string $key, mixed $default = null): mixed
            {
                $segments = explode('.', $key);
                $current = $this->items;

                foreach ($segments as $segment) {
                    if (!is_array($current) || !array_key_exists($segment, $current)) {
                        return $default;
                    }
                    $current = $current[$segment];
                }

                return $current;
            }

            public function set(string $key, mixed $value): void
            {
                $segments = explode('.', $key);
                $current = &$this->items;

                foreach ($segments as $i => $segment) {
                    if ($i === count($segments) - 1) {
                        $current[$segment] = $value;
                    } else {
                        if (!isset($current[$segment]) || !is_array($current[$segment])) {
                            $current[$segment] = [];
                        }
                        $current = &$current[$segment];
                    }
                }
            }
        }
    }

    if (!class_exists(\Grav\Common\Config\Setup::class, false)) {
        /**
         * Minimal setup stub for environment-aware services. Grav exposes the
         * booted environment as a static value during bootstrap; the tests only
         * need that value and a small Data-like getter for stream metadata.
         */
        class Setup
        {
            public static ?string $environment = null;

            public function __construct(private array $items = []) {}

            public function get(string $key, mixed $default = null): mixed
            {
                $current = $this->items;
                foreach (explode('.', $key) as $segment) {
                    if (!is_array($current) || !array_key_exists($segment, $current)) {
                        return $default;
                    }
                    $current = $current[$segment];
                }

                return $current;
            }
        }
    }
}

namespace Grav\Common {
    if (!class_exists(\Grav\Common\Yaml::class, false)) {
        // Thin shim over symfony/yaml so tests that need YAML parsing run
        // without the full Grav core on the classpath.
        abstract class Yaml
        {
            public static function parse(string $data): array
            {
                $parsed = \Symfony\Component\Yaml\Yaml::parse($data);
                return is_array($parsed) ? $parsed : [];
            }

            public static function dump(mixed $data, ?int $inline = null, ?int $indent = null): string
            {
                return \Symfony\Component\Yaml\Yaml::dump($data, $inline ?? 5, $indent ?? 2);
            }
        }
    }

    if (!class_exists(\Grav\Common\Grav::class, false)) {
        class Grav implements \ArrayAccess
        {
            private static ?self $instance = null;
            private array $services = [];

            /** @var array<int, array{name: string, event: object}> Recorded event firings for test assertions. */
            private array $firedEvents = [];

            /** @var array<string, array<int, callable>> Listeners registered by tests that need to exercise event-driven code. */
            private array $listeners = [];

            /**
             * Register a listener so a test can simulate a plugin subscribing to
             * an event (the real dispatcher is not available under the stubs).
             * A listener that throws propagates out of fireEvent, exactly as
             * Grav's real dispatcher would.
             */
            public function addListener(string $name, callable $listener): void
            {
                $this->listeners[$name][] = $listener;
            }

            public static function instance(): static
            {
                if (self::$instance === null) {
                    self::$instance = new static();
                }
                return self::$instance;
            }

            /** Reset the singleton (useful between tests). */
            public static function resetInstance(): void
            {
                self::$instance = null;
            }

            /**
             * Fire a Grav event (stub implementation).
             * Records the event for later assertion in tests.
             */
            public function fireEvent(string $name, ?object $event = null): object
            {
                $event = $event ?? new \stdClass();
                $this->firedEvents[] = ['name' => $name, 'event' => $event];
                foreach ($this->listeners[$name] ?? [] as $listener) {
                    $listener($event);
                }
                return $event;
            }

            /**
             * Get all recorded fired events (for test assertions).
             * @return array<int, array{name: string, event: object}>
             */
            public function getFiredEvents(): array
            {
                return $this->firedEvents;
            }

            /** Clear the recorded events list. */
            public function clearFiredEvents(): void
            {
                $this->firedEvents = [];
            }

            public function offsetExists(mixed $offset): bool
            {
                return isset($this->services[$offset]);
            }

            public function offsetGet(mixed $offset): mixed
            {
                return $this->services[$offset] ?? null;
            }

            public function offsetSet(mixed $offset, mixed $value): void
            {
                $this->services[$offset] = $value;
            }

            public function offsetUnset(mixed $offset): void
            {
                unset($this->services[$offset]);
            }
        }
    }
}

namespace Grav\Common\User {
    if (!class_exists(\Grav\Common\User\Authentication::class, false)) {
        /**
         * Minimal Authentication stub mirroring Grav's password hashing helper.
         * create() returns a bcrypt hash; verify() returns an int (1 = match,
         * 0 = no match) matching the contract ApiKeyManager::verifyKey() relies on.
         */
        abstract class Authentication
        {
            public static function create(string $password): string
            {
                return password_hash($password, PASSWORD_BCRYPT);
            }

            public static function verify(string $password, string $hash): int
            {
                return password_verify($password, $hash) ? 1 : 0;
            }
        }
    }
}

namespace Grav\Common\User\Interfaces {
    if (!interface_exists(\Grav\Common\User\Interfaces\UserInterface::class, false)) {
        interface UserInterface
        {
            public function get(string $key, mixed $default = null): mixed;
            public function set(string $key, mixed $value): void;
            public function save(): void;
            public function exists(): bool;
        }
    }

    if (!interface_exists(\Grav\Common\User\Interfaces\UserCollectionInterface::class, false)) {
        interface UserCollectionInterface extends \Traversable
        {
            public function load(string $username): UserInterface;
        }
    }
}

namespace Grav\Common\Page\Interfaces {
    if (!interface_exists(\Grav\Common\Page\Interfaces\PageInterface::class, false)) {
        interface PageInterface
        {
            public function route($var = null): ?string;
            // The structural route, which unlike route() is never rewritten by
            // a `routes.default` alias, and root(), which is how the listings
            // identify Grav's virtual pages-root container. Both are declared
            // on the real PageRoutableInterface (getgrav/grav-plugin-api#34).
            public function rawRoute($var = null): ?string;
            public function root(): bool;
            public function exists(): bool;
            public function slug($var = null): string;
            public function order($var = null): ?int;
            public function path($var = null): ?string;
            public function title($var = null): string;
            // Declared on the real PageContentInterface; the page search matches it.
            public function menu($var = null);
            public function isModule(): bool;
            public function children(): \Traversable;
            public function published(): bool;
            public function visible(): bool;
            public function routable(): bool;
            public function template(): string;
            // Needed by the per-page ACL resolver, which reads a page's own
            // frontmatter and walks up its parents.
            public function header($var = null);
            public function parent(?PageInterface $var = null): ?PageInterface;
            public function language($var = null);
            // Read by PageSerializer for every list row.
            public function folder($var = null);
            public function taxonomy($var = null);
            public function date($var = null);
            public function modified($var = null);
            // Declared on the real PageLegacyInterface; copy reads it.
            public function extension($var = null);
            // Declared on the real PageLegacyInterface; the content file's name,
            // which language files are named after (admin2#201).
            public function name($var = null);
        }
    }
}

namespace Grav\Common\Filesystem {
    if (!class_exists(\Grav\Common\Filesystem\Folder::class, false)) {
        class Folder
        {
            public static function move(string $source, string $target): void
            {
                if (is_dir($source)) {
                    rename($source, $target);
                }
            }

            public static function copy(string $source, string $target): void
            {
                @mkdir($target, 0775, true);
                foreach (scandir($source) ?: [] as $entry) {
                    if ($entry === '.' || $entry === '..') {
                        continue;
                    }
                    is_dir($source . '/' . $entry)
                        ? self::copy($source . '/' . $entry, $target . '/' . $entry)
                        : copy($source . '/' . $entry, $target . '/' . $entry);
                }
            }
        }
    }
}

namespace Grav\Framework\Psr7 {
    if (!class_exists(\Grav\Framework\Psr7\Response::class, false)) {
        /**
         * Minimal PSR-7 Response implementation for testing.
         */
        class Response implements \Psr\Http\Message\ResponseInterface
        {
            /** @var array<string, string[]> */
            private array $headerValues = [];
            /** @var string */
            private string $body;

            public function __construct(
                private int $statusCode = 200,
                array $headers = [],
                string $body = '',
                private string $protocolVersion = '1.1',
                private string $reasonPhrase = '',
            ) {
                foreach ($headers as $name => $value) {
                    $this->headerValues[strtolower($name)] = [
                        'original' => $name,
                        'values' => is_array($value) ? $value : [$value],
                    ];
                }
                $this->body = $body;
            }

            public function getStatusCode(): int { return $this->statusCode; }

            public function withStatus(int $code, string $reasonPhrase = ''): static
            {
                $clone = clone $this;
                $clone->statusCode = $code;
                $clone->reasonPhrase = $reasonPhrase;
                return $clone;
            }

            public function getReasonPhrase(): string { return $this->reasonPhrase; }
            public function getProtocolVersion(): string { return $this->protocolVersion; }
            public function withProtocolVersion(string $version): static
            {
                $clone = clone $this;
                $clone->protocolVersion = $version;
                return $clone;
            }

            public function getHeaders(): array
            {
                $result = [];
                foreach ($this->headerValues as $info) {
                    $result[$info['original']] = $info['values'];
                }
                return $result;
            }

            public function hasHeader(string $name): bool
            {
                return isset($this->headerValues[strtolower($name)]);
            }

            public function getHeader(string $name): array
            {
                return $this->headerValues[strtolower($name)]['values'] ?? [];
            }

            public function getHeaderLine(string $name): string
            {
                return implode(', ', $this->getHeader($name));
            }

            public function withHeader(string $name, $value): static
            {
                $clone = clone $this;
                $clone->headerValues[strtolower($name)] = [
                    'original' => $name,
                    'values' => is_array($value) ? $value : [$value],
                ];
                return $clone;
            }

            public function withAddedHeader(string $name, $value): static
            {
                $clone = clone $this;
                $lower = strtolower($name);
                $existing = $clone->headerValues[$lower]['values'] ?? [];
                $clone->headerValues[$lower] = [
                    'original' => $clone->headerValues[$lower]['original'] ?? $name,
                    'values' => array_merge($existing, is_array($value) ? $value : [$value]),
                ];
                return $clone;
            }

            public function withoutHeader(string $name): static
            {
                $clone = clone $this;
                unset($clone->headerValues[strtolower($name)]);
                return $clone;
            }

            public function getBody(): \Psr\Http\Message\StreamInterface
            {
                $content = $this->body;
                return new class ($content) implements \Psr\Http\Message\StreamInterface {
                    public function __construct(private readonly string $content) {}
                    public function __toString(): string { return $this->content; }
                    public function close(): void {}
                    public function detach() { return null; }
                    public function getSize(): ?int { return strlen($this->content); }
                    public function tell(): int { return 0; }
                    public function eof(): bool { return true; }
                    public function isSeekable(): bool { return false; }
                    public function seek(int $offset, int $whence = SEEK_SET): void {}
                    public function rewind(): void {}
                    public function isWritable(): bool { return false; }
                    public function write(string $string): int { return 0; }
                    public function isReadable(): bool { return true; }
                    public function read(int $length): string { return $this->content; }
                    public function getContents(): string { return $this->content; }
                    public function getMetadata(?string $key = null): mixed { return null; }
                };
            }

            public function withBody(\Psr\Http\Message\StreamInterface $body): static
            {
                $clone = clone $this;
                $clone->body = (string) $body;
                return $clone;
            }
        }
    }
}

namespace RocketTheme\Toolbox\Event {
    if (!class_exists(\RocketTheme\Toolbox\Event\Event::class, false)) {
        /**
         * Minimal Event stub that supports array access for event data.
         */
        class Event implements \ArrayAccess
        {
            private array $data;

            public function __construct(array $data = [])
            {
                $this->data = $data;
            }

            public function offsetExists(mixed $offset): bool
            {
                return array_key_exists($offset, $this->data);
            }

            public function &offsetGet(mixed $offset): mixed
            {
                return $this->data[$offset];
            }

            public function offsetSet(mixed $offset, mixed $value): void
            {
                $this->data[$offset] = $value;
            }

            public function offsetUnset(mixed $offset): void
            {
                unset($this->data[$offset]);
            }

            public function toArray(): array
            {
                return $this->data;
            }
        }
    }
}

namespace Grav\Common\Page {
    if (!class_exists(\Grav\Common\Page\Page::class, false)) {
        /**
         * Minimal Page stub for testing controllers that instantiate Page directly.
         */
        class Page
        {
            private ?string $filePath = null;
            private object $header;
            private string $rawMarkdown = '';
            private string $template = 'default';
            private string $name = 'default.md';
            private ?string $path = null;
            private ?string $route = null;
            private ?string $slug = null;
            private ?int $order = null;
            private ?string $lang = null;

            public function __construct()
            {
                $this->header = new \stdClass();
            }

            public function filePath(?string $path = null): ?string
            {
                if ($path !== null) {
                    $this->filePath = $path;
                    // Derive path (directory) from filePath
                    $this->path = dirname($path);
                }
                return $this->filePath;
            }

            public function header($var = null)
            {
                if ($var !== null) {
                    $this->header = is_array($var) ? (object) $var : $var;
                }
                return $this->header;
            }

            public function rawMarkdown(?string $var = null): string
            {
                if ($var !== null) {
                    $this->rawMarkdown = $var;
                }
                return $this->rawMarkdown;
            }

            public function template(?string $var = null): string
            {
                if ($var !== null) {
                    $this->template = $var;
                }
                return $this->template;
            }

            public function name(?string $var = null): string
            {
                if ($var !== null) {
                    $this->name = $var;
                }
                return $this->name;
            }

            public function path(?string $var = null): ?string
            {
                if ($var !== null) {
                    $this->path = $var;
                }
                return $this->path;
            }

            public function route($var = null): ?string
            {
                if ($var !== null) {
                    $this->route = $var;
                }
                return $this->route;
            }

            public function slug($var = null): ?string
            {
                if ($var !== null) {
                    $this->slug = $var;
                }
                return $this->slug;
            }

            public function order($var = null): ?int
            {
                if ($var !== null) {
                    $this->order = $var;
                }
                return $this->order;
            }

            public function language(?string $var = null): ?string
            {
                if ($var !== null) {
                    $this->lang = $var;
                }
                return $this->lang;
            }

            public function title($var = null): string
            {
                return $this->header->title ?? '';
            }

            public function save($reorder = true): void
            {
                // No-op in tests — the actual file writing is not needed
            }

            public function isModule(): bool
            {
                return false;
            }

            public function children(): \Traversable
            {
                return new \ArrayIterator([]);
            }

            public function translatedLanguages(): array
            {
                return [];
            }

            public function file(): ?object
            {
                return null;
            }

            public function content($var = null): string
            {
                return $this->rawMarkdown;
            }
        }
    }

    if (!class_exists(\Grav\Common\Page\Media::class, false)) {
        /**
         * Minimal Media stub.
         */
        class Media
        {
            public function __construct(private readonly ?string $path = null) {}

            public function all(): array
            {
                return [];
            }
        }
    }
}

namespace Grav\Common\GPM {
    if (!class_exists(\Grav\Common\GPM\GPM::class, false)) {
        /**
         * Minimal GPM stub. Methods are intentionally non-final and present
         * here only so PHPUnit's createMock() can produce a mock subclass.
         * Behavior is supplied per-test via mock expectations.
         */
        class GPM
        {
            public function __construct(bool $refresh = false, $callback = null) {}

            public function getUpdatable(): array { return []; }
            public function isUpdatable(string $slug): bool { return false; }
            public function getGrav() { return null; }
            public function checkPackagesCanBeInstalled(array $slugs): void {}
            public function getDependencies(array $slugs): array { return []; }
        }
    }
}

namespace Grav\Common {
    if (!class_exists(\Grav\Common\Utils::class, false)) {
        /**
         * Minimal Utils stub. Exercised by unit tests via PermissionResolver
         * (arrayFlattenDotNotation) and UsersController's permission filtering
         * (isPositive).
         */
        class Utils
        {
            /** Mirrors core's unicode-safe basename(), which core's Security calls. */
            public static function basename($path, string $suffix = ''): string
            {
                if (!preg_match('/[^\x20-\x7E]/', $path)) {
                    return \basename($path, $suffix);
                }

                return rawurldecode(\basename(str_replace(['%2F', '%5C'], '/', rawurlencode($path)), $suffix));
            }

            public static function arrayFlattenDotNotation(array $array, string $prepend = ''): array
            {
                $results = [];
                foreach ($array as $key => $value) {
                    if (is_array($value) && !empty($value)) {
                        $results = array_merge($results, self::arrayFlattenDotNotation($value, $prepend . $key . '.'));
                    } else {
                        $results[$prepend . $key] = $value;
                    }
                }
                return $results;
            }

            /**
             * Inverse of arrayFlattenDotNotation. Exercised by
             * TranslationOverrideStore, which stores flat keys but writes real
             * nested language files. Mirrors core's behaviour of skipping a
             * segment that would have to treat an existing scalar as an array.
             */
            public static function arrayUnflattenDotNotation($array, $separator = '.')
            {
                $newArray = [];
                foreach ($array as $key => $value) {
                    $dots = explode($separator, (string) $key);
                    if (count($dots) > 1) {
                        $last = &$newArray[$dots[0]];
                        foreach ($dots as $k => $dot) {
                            if ($k === 0) {
                                continue;
                            }
                            if (null !== $last && !is_array($last)) {
                                continue 2;
                            }
                            $last = &$last[$dot];
                        }
                        if (null !== $last && !is_array($last)) {
                            continue;
                        }
                        $last = $value;
                    } else {
                        $newArray[$key] = $value;
                    }
                }
                unset($last);

                return $newArray;
            }

            public static function isPositive($value): bool
            {
                return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);
            }

            /**
             * Exercised by UploadFieldSettings (random_name). The real Utils
             * draws from a larger alphabet; a deterministic-length lowercase
             * alnum string is enough for the upload-pipeline tests.
             */
            public static function generateRandomString($length = 5): string
            {
                $alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
                $out = '';
                for ($i = 0; $i < $length; $i++) {
                    $out .= $alphabet[random_int(0, strlen($alphabet) - 1)];
                }
                return $out;
            }

            /**
             * Exercised by UploadFieldSettings (accept allowlist). Maps the few
             * extensions the tests rely on; everything else is octet-stream,
             * matching the real Utils' fallback when no media type is found.
             */
            public static function getMimeByFilename($filename, $default = 'application/octet-stream'): string
            {
                $ext = strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION));
                return match ($ext) {
                    'png' => 'image/png',
                    'jpg', 'jpeg' => 'image/jpeg',
                    'gif' => 'image/gif',
                    'pdf' => 'application/pdf',
                    'txt' => 'text/plain',
                    default => $default,
                };
            }
        }
    }
}

namespace Grav\Framework\Acl {
    if (!class_exists(\Grav\Framework\Acl\Permissions::class, false)) {
        /**
         * Minimal Permissions stub so PermissionResolver can be constructed.
         * Only resolvedMap() touches getInstances(); resolve() reads only the
         * user's access array, so most unit tests get away with an empty stub.
         */
        class Permissions
        {
            /** @return array<string, object> */
            public function getInstances(): array { return []; }
        }
    }
}

namespace Grav\Common\Data {
    if (!class_exists(\Grav\Common\Data\Data::class, false)) {
        /**
         * Minimal Data stub for config wrapping.
         */
        class Data
        {
            public function __construct(private array $items = []) {}

            public function toArray(): array
            {
                return $this->items;
            }

            public function get(string $key, mixed $default = null): mixed
            {
                return $this->items[$key] ?? $default;
            }

            public function set(string $key, mixed $value): void
            {
                $this->items[$key] = $value;
            }
        }
    }
}

namespace Grav\Common\User\DataUser {
    if (!class_exists(User::class, false)) {
        class User
        {
            public static function isValidUsername(string $username): bool
            {
                return $username !== ''
                    && preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $username) === 1
                    && !str_contains($username, '..')
                    && !str_starts_with($username, '.');
            }
        }
    }
}
