<?php

declare(strict_types = 1);

namespace Plugin\Pinboard\Controller;

use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\LocalUser;
use App\Entity\Note;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\Formatting;
use Component\Conversation\Conversation;
use Component\Tag\Entity\NoteTag;
use Datetime;
use DateTimeImmutable;
use DateTimeInterface;
use Functional as F;
use Plugin\Pinboard\Entity\Pin;
use Plugin\Pinboard\Util;
use SimpleXMLElement;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Encoder\XmlEncoder;

class APIv1 extends Controller
{
    public const SOURCE = 'Pinboard API v1';

    private function before()
    {
        $format     = $this->string('format');
        $auth_token = $this->string('auth_token');
        if (\is_null($format)) {
            return new Response(_m('Format must be specified'), status: 400);
        }

        if ($format !== 'json') {
            throw new ServerException(_m('Only JSON is supported'));
        }

        if (\is_null($auth_token)) {
            return new Response('API requires authentication', status: 401);
        }

        if (\is_null($user = Util::validateToken($auth_token))) {
            return new Response('401 Forbidden', status: 401);
        }

        return $user;
    }

    private function respond(array $result, int $status = 200): Response
    {
        $format = $this->string('format');
        if ($format === 'json') {
            return new JsonResponse($result, status: $status);
        } elseif ($format === 'xml') {
            // $encoder = new XmlEncoder();
            // $xml = $encoder->encode($result, 'xml');
            [$tag_names, $keys] = F\zip(...F\map(array_keys($result), fn (string $k) => explode('_', $k)));
            $xml = new SimpleXMLElement('<' . $tag_names[0] . '/>');
            dd($tag_names, $keys, $result, $xml, (string) $xml);
            $xml->addChild();
            dd($xml);
            return new Response(content: $xml, status: $statis);
        }
    }

    private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void
    {
        $note->delete($user->getActor(), self::SOURCE);
        if (!\is_null($note)) {
            DB::remove($pin);
        }
        DB::flush();
    }

    private function parseTags(): array
    {
        $tags = [];
        if (!\is_null($tags_text = $this->string('tags'))) {
            Formatting::toArray($tags_text, $tags, Formatting::SPLIT_BY_BOTH);
        }
        return $tags;
    }

    private function getLatestModified(LocalUser $user): string
    {
        return Cache::get(
            Pin::cacheKeys($user)['last-modified'],
            fn () => DB::dql(
                <<<'EOF'
                    select MAX(p.modified) as max from \Plugin\Pinboard\Entity\Pin p
                    where p.actor_id = :actor_id
                    group by p.modified
                    EOF,
                ['actor_id' => $user->getId()],
            )[0]['max'],
        );
    }

    /**
     * @param Pin[] $pins
     *
     * @return array json for response
     */
    private function formatPins(LocalUser $user, array $pins): array
    {
        return [
            'date'  => self::getLatestModified($user),
            'user'  => $user->getNickname(),
            'posts' => F\map(
                $pins,
                fn (Pin $pin) => [
                    'href'        => $pin->getUrl(),
                    'description' => $pin->getTitle(),
                    'extended'    => $pin->getDescription(),
                    'meta'        => hash('md5', $pin->getModified()->format(DateTimeInterface::ISO8601)),
                    'hash'        => hash('md5', $pin->getUrl() . $pin->getTitle() . $pin->getDescription()), // idk...
                    'time'        => $pin->getModified()->format(DateTimeInterface::ISO8601),
                    'shared'      => $pin->getPublic(),
                    'toread'      => $pin->getUnread(),
                    'tags'        => implode(' ', F\map($pin->getTags(), fn (NoteTag $tag) => $tag->getTag())),
                ],
            ),
        ];
    }

    // ----------------------

    /**
     * Returns the most recent time a bookmark was added, updated or
     * deleted. Use this before calling posts/all to see if the data
     * has changed since the last fetch
     */
    public function posts_update(Request $request)
    {
        $user = self::before();

        return self::respond(['update_time' => self::getLatestModified($user)]);
    }

    /**
     * Add a bookmark
     */
    public function posts_add(Request $request)
    {
        $user = self::before();

        if (\is_null($url = $this->string('url'))) {
            throw new ClientException('URL must be provided');
        }

        if (\is_null($title = $this->string('description'))) { // Logically.
            throw new ClientException('Desciption must be provided');
        }

        $description = $this->string('extended') ?? '';
        $tags        = self::parseTags();
        $modified    = $this->string('dt')    ?? new Datetime;
        $replace     = $this->bool('replace') ?? true;
        $public      = $this->bool('shared')  ?? true;
        $unread      = $this->bool('toread')  ?? false;

        $result_code     = 'something went wrong';
        [$pin, $existed] = Pin::checkExistingAndCreateOrUpdate(
            args: [
                'actor_id'    => $user->getId(),
                'url_hash'    => hash('sha256', $url),
                'url'         => $url,
                'title'       => $title,
                'description' => $description,
                'replace'     => $replace,
                'public'      => $public,
                'unread'      => $unread,
                'modified'    => $modified,
            ],
            find_by_keys: ['actor_id', 'url_hash'],
        );

        if ($existed) {
            if (!$replace) {
                $result_code = 'item already exists';
            } else {
                throw new ServerException('Updating is unimplemented'); // TODO delete old note, create new one
                $this->deleteNoteAndMaybePin($user, $pin->getNote(), pin: null);
            }
        } else {
            DB::persist($note = Note::create([
                'actor_id'     => $user->getId(),
                'content'      => $url,
                'content_type' => 'text/uri-list',
                'rendered'     => Formatting::twigRenderFile('pinboard/render.html.twig', ['url' => $url, 'title' => $title, 'description' => $description]),
                'reply_to'     => null,
                'is_local'     => true,
                'source'       => self::SOURCE,
                'scope'        => $public ? VisibilityScope::EVERYWHERE->value : VisibilityScope::ADDRESSEE->value,
                'language_id'  => $user->getActor()->getTopLanguage()->getId(),
                'type'         => 'page',
                'title'        => $title,
            ]));
            $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
            $pin->setNoteId($note->getId());
            Conversation::assignLocalConversation($note, null);
            DB::persist($pin);
            // TODO handle tags
            DB::flush();
            $result_code = 'done';
        }

        return self::respond(['result_code' => $result_code]);
    }

    /**
     * Delete a bookmark
     */
    public function posts_delete(Request $request)
    {
        $user = self::before($request);
        $url  = $this->string('url');
        if (\is_null($url)) {
            throw new ClientException('URL must be provided');
        }

        $pin = DB::findOneBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)], return_null: true);
        if (\is_null($pin)) {
            return self::respond(['result_code' => 'item not found']);
        } else {
            $this->deleteNoteAndMaybePin($user, $pin->getNote(), $pin);
            return self::respond(['result_code' => 'done']);
        }
    }

    /**
     * Returns one or more posts on a single day matching the
     * arguments. If no date or url is given, date of most recent
     * bookmark will be used
     */
    public function posts_get(Request $request)
    {
        $user = self::before($request);
        $tags = self::parseTags();
        $day  = $this->string('dt');
        $url  = $this->string('url');
        $meta = $this->bool('meta');

        if (!\is_null($tags) && $tags !== []) {
            throw new ClientException(_m('tags attribute not implemented'));
        }

        if (\is_null($url) && \is_null($day)) {
            $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => new DateTimeImmutable('today')]]);
        } elseif (!\is_null($day)) {
            $day  = new DateTimeImmutable($day);
            $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => $day], 'lt' => ['modified' => $day->modify('+1 day')]]);
        } elseif (!\is_null($url)) {
            $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)]);
        } else {
            throw new BugFoundException('Wonky logic in pinboard/posts/get');
        }

        return self::respond(self::formatPins($user, $pins));
    }

    /**
     * Returns a list of the user's most recent posts, filtered by tag
     */
    public function posts_recent(Request $request)
    {
        $user  = self::before($request);
        $tags  = self::parseTags();
        $limit = min($this->int('count') ?? 15, 100);

        if (!\is_null($tags) && $tags !== []) {
            throw new ClientException('tags attribute not implemented');
        }

        $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId()], order_by: ['modified' => 'asc'], limit: $limit);

        return self::respond(self::formatPins($user, $pins));
    }

    /**
     * Returns a list of dates with the number of posts at each date
     */
    public function posts_dates(Request $request)
    {
        return self::respond(['result_code' => 'unimplemented']);
    }

    /**
     * Returns all bookmarks in the user's account
     */
    public function posts_all(Request $request)
    {
        $user       = self::before($request);
        $tags       = self::parseTags();
        $offset     = $this->int('start');
        $limit      = $this->int('results');
        $start_time = $this->string('fromdt');
        $end_time   = $this->string('todt');
        $meta       = $this->bool('meta');

        if (!\is_null($tags) && $tags !== []) {
            throw new ClientException('tags attribute not implemented');
        }

        $criteria = ['actor_id' => $user->getId()];
        if (!\is_null($start_time)) {
            $criteria['gte'] = ['modified' => new DateTimeImmutable($start_time)];
        }
        if (!\is_null($end_time)) {
            $criteria['lt'] = ['modified' => new DateTimeImmutable($end_time)];
        }

        $pins = DB::findBy(Pin::class, $criteria, order_by: ['modified' => 'asc'], offset: $offset, limit: $limit);
        return self::respond(self::formatPins($user, $pins)['posts']);
    }
}