<?php

namespace Spatie\SchemaOrg;

use ArrayAccess;
use BadMethodCallException;
use Closure;
use JsonSerializable;
use ReflectionClass;
use ReflectionNamedType;
use Spatie\SchemaOrg\Exceptions\InvalidType;
use Spatie\SchemaOrg\Exceptions\TypeAlreadyInGraph;
use Spatie\SchemaOrg\Exceptions\TypeNotInGraph;

/**
{% for type in types %}
 * @method self|{{ type.className }} {{ type.className | lcfirst }}(string|\Closure|null $identifier = null, \Closure|null $callback = null)
{% endfor %}
 */
class Graph implements Type, ArrayAccess, JsonSerializable
{
    public const IDENTIFIER_DEFAULT = 'default';

    /** @var Type[] */
    protected $nodes = [];

    /** @var array */
    protected $hidden = [];

    /** @var string|null */
    protected $context;

    /** @var string */
    protected $nonce = '';

    public function __construct(string|array|null $context = null)
    {
        $this->context = $context;
    }

    /**
     * This overloads all \Spatie\SchemaOrg\Schema construction methods.
     * You can call them the same like on the \Spatie\SchemaOrg\Schema class.
     * But you can also use the extended signatures.
     *
     * Graph::organisation(): Organisation
     * Graph::organisation('spatie'): Organisation
     * Graph::organisation(function(Organisation $organisation, Graph $graph) {}): Graph
     * Graph::organisation('spatie', function(Organisation $organisation, Graph $graph) {}): Graph
     *
     * @see \Spatie\SchemaOrg\Schema
     *
     * @param string $method
     * @param array $arguments
     *
     * @return $this|Type
     *
     * @throws \ReflectionException
     * @throws \BadMethodCallException
     */
    public function __call(string $method, array $arguments)
    {
        if (is_callable([Schema::class, $method])) {
            $type = (new ReflectionClass(Schema::class))->getMethod($method)->getReturnType();

            if (! $type instanceof ReflectionNamedType) {
                throw new BadMethodCallException(sprintf('The method "%s" has an invalid return type which does not resolve to "%s".', $method, ReflectionNamedType::class));
            }

            $identifier = self::IDENTIFIER_DEFAULT;

            if (isset($arguments[0])) {
                if (is_string($arguments[0])) {
                    $identifier = $arguments[0];

                    if (isset($arguments[1]) && is_callable($arguments[1])) {
                        $callback = $arguments[1];
                    }
                } elseif (is_callable($arguments[0])) {
                    $callback = $arguments[0];
                }
            }

            $schema = $this->getOrCreate($type->getName(), $identifier);

            if (isset($callback)) {
                call_user_func($callback, $schema, $this);

                return $this;
            }

            return $schema;
        }

        throw new BadMethodCallException(sprintf('The method "%s" does not exist on class "%s".', $method, get_class($this)));
    }

    public function if(bool $condition, Closure $callback)
    {
        if ($condition) {
            $callback($this);
        }

        return $this;
    }

    public function add(Type $schema, string $identifier = self::IDENTIFIER_DEFAULT): self
    {
        $type = get_class($schema);

        if ($this->has($type, $identifier)) {
            throw new TypeAlreadyInGraph(sprintf('The graph already has an item of type "%s" with identifier "%s".', $type, $identifier));
        }

        return $this->set($schema, $identifier);
    }

    public function has(string $type, string $identifier = self::IDENTIFIER_DEFAULT): bool
    {
        return array_key_exists($type, $this->nodes) && array_key_exists($identifier, $this->nodes[$type]);
    }

    public function set(Type $schema, string $identifier = self::IDENTIFIER_DEFAULT)
    {
        $this->nodes[get_class($schema)][$identifier] = $schema;

        return $this;
    }

    public function get(string $type, string $identifier = self::IDENTIFIER_DEFAULT): Type
    {
        if (! $this->has($type, $identifier)) {
            throw new TypeNotInGraph(sprintf('The graph does not have an item of type "%s" with identifier "%s".', $type, $identifier));
        }

        return $this->nodes[$type][$identifier];
    }

    public function getOrCreate(string $type, string $identifier = self::IDENTIFIER_DEFAULT): Type
    {
        if (! is_subclass_of($type, Type::class)) {
            throw new InvalidType(sprintf('The given type "%s" is not an instance of "%s".', $type, Type::class));
        }

        if (! $this->has($type, $identifier)) {
            $this->set(new $type(), $identifier);
        }

        return $this->get($type, $identifier);
    }

    public function hide(string $type, ?string $identifier = self::IDENTIFIER_DEFAULT): self
    {
        // hide all
        if ($identifier === null) {
            $this->hidden[$type] = true;

            return $this;
        }

        // hide single one if nothing configured
        if (! isset($this->hidden[$type])) {
            $this->hidden[$type][$identifier] = true;

            return $this;
        }

        // hide single one only if all are not already hidden
        if ($this->hidden[$type] !== true) {
            $this->hidden[$type][$identifier] = true;

            return $this;
        }

        return $this;
    }

    public function show(string $type, ?string $identifier = self::IDENTIFIER_DEFAULT): self
    {
        // show all
        if ($identifier === null) {
            unset($this->hidden[$type]);

            return $this;
        }

        // show single one if nothing configured
        if (! isset($this->hidden[$type])) {
            $this->hidden[$type][$identifier] = false;

            return $this;
        }

        // ignore if everything is shown
        if ($this->hidden[$type] === false) {
            return $this;
        }

        // show single one if identifier configuration exists
        if (is_array($this->hidden[$type])) {
            $this->hidden[$type][$identifier] = false;

            return $this;
        }

        if ($this->hidden[$type] === true) {
            $this->hidden[$type] = [];

            // keep everything hidden and show only single one
            if (isset($this->nodes[$type])) {
                foreach ($this->nodes[$type] as $id => $node) {
                    $this->hidden[$type][$id] = $id !== $identifier;
                }

                return $this;
            }

            // show single one if no nodes exist
            $this->hidden[$type][$identifier] = false;

            return $this;
        }

        return $this;
    }

    public function setNonce(string $nonce)
    {
        $this->nonce = $nonce;

        return $this;
    }

    public function toArray(): array
    {
        $nodes = $this->getNodes();

        foreach ($this->hidden as $type => $hideAll) {
            if (is_bool($hideAll) && $hideAll) {
                unset($nodes[$type]);

                continue;
            }

            if (is_array($hideAll)) {
                foreach ($hideAll as $identifier => $hide) {
                    if (is_bool($hide) && $hide) {
                        unset($nodes[$type][$identifier]);
                    }
                }
            }
        }

        $nodes = array_reduce($nodes, function (array $carry, array $types) {
            return array_merge($carry, array_values($types));
        }, []);

        return [
            '@context' => $this->getContext(),
            '@graph' => $this->serializeNode(array_values($nodes)),
        ];
    }

    protected function serializeNode($node)
    {
        if (is_array($node)) {
            return array_map([$this, 'serializeNode'], array_values($node));
        }

        if ($node instanceof Type) {
            $node = $node->toArray();
            unset($node['@context']);
        }

        return $node;
    }

    public function getNodes(): array
    {
        return $this->nodes;
    }

    public function getContext(): string|array
    {
        return $this->context ?? 'https://schema.org';
    }

    public function nonceAttr(): string
    {
        if ($this->nonce) {
            $attr = ' nonce="'.$this->nonce.'"';
        } else {
            $attr = '';
        }

        return $attr;
    }

    public function toScript(): string
    {
        return '<script type="application/ld+json"'.$this->nonceAttr().'>'.json_encode($this, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG).'</script>';
    }

    public function jsonSerialize(): mixed
    {
        return $this->toArray();
    }

    public function __toString(): string
    {
        return $this->toScript();
    }

    protected function getTypeAndIdentifier(string $key): array
    {
        if (strpos($key, '.') === false) {
            return [$key, self::IDENTIFIER_DEFAULT];
        }

        return explode('.', $key);
    }

    public function offsetExists($offset): bool
    {
        [$type, $identifier] = $this->getTypeAndIdentifier($offset);

        return $this->has($type, $identifier);
    }

    public function offsetGet($offset): mixed
    {
        [$type, $identifier] = $this->getTypeAndIdentifier($offset);

        return $this->get($type, $identifier);
    }

    public function offsetSet($offset, $value): void
    {
        $identifier = $offset;

        if (strpos($offset, '.') !== false) {
            [$type, $identifier] = $this->getTypeAndIdentifier($offset);
        }

        $this->set($value, $identifier);
    }

    public function offsetUnset($offset): void
    {
        [$type, $identifier] = $this->getTypeAndIdentifier($offset);

        unset($this->nodes[$type][$identifier]);
    }
}
