<?php

namespace Kirby\Cms;

use Closure;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;

/**
 * PageActions
 *
 * @package   Kirby Cms
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
trait PageActions
{
    /**
     * Changes the sorting number.
     * The sorting number must already be correct
     * when the method is called.
     * This only affects this page,
     * siblings will not be resorted.
     *
     * @param int|null $num
     * @return $this|static
     * @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved
     */
    public function changeNum(int $num = null)
    {
        if ($this->isDraft() === true) {
            throw new LogicException('Drafts cannot change their sorting number');
        }

        // don't run the action if everything stayed the same
        if ($this->num() === $num) {
            return $this;
        }

        return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
            $newPage = $oldPage->clone([
                'num'     => $num,
                'dirname' => null,
                'root'    => null
            ]);

            // actually move the page on disk
            if ($oldPage->exists() === true) {
                if (Dir::move($oldPage->root(), $newPage->root()) === true) {
                    // Updates the root path of the old page with the root path
                    // of the moved new page to use fly actions on old page in loop
                    $oldPage->setRoot($newPage->root());
                } else {
                    throw new LogicException('The page directory cannot be moved');
                }
            }

            // overwrite the child in the parent page
            $newPage
                ->parentModel()
                ->children()
                ->set($newPage->id(), $newPage);

            return $newPage;
        });
    }

    /**
     * Changes the slug/uid of the page
     *
     * @param string $slug
     * @param string|null $languageCode
     * @return $this|static
     * @throws \Kirby\Exception\LogicException If the directory cannot be moved
     */
    public function changeSlug(string $slug, string $languageCode = null)
    {
        // always sanitize the slug
        $slug = Str::slug($slug);

        // in multi-language installations the slug for the non-default
        // languages is stored in the text file. The changeSlugForLanguage
        // method takes care of that.
        if ($language = $this->kirby()->language($languageCode)) {
            if ($language->isDefault() === false) {
                return $this->changeSlugForLanguage($slug, $languageCode);
            }
        }

        // if the slug stays exactly the same,
        // nothing needs to be done.
        if ($slug === $this->slug()) {
            return $this;
        }

        $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null];
        return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) {
            $newPage = $oldPage->clone([
                'slug'    => $slug,
                'dirname' => null,
                'root'    => null
            ]);

            if ($oldPage->exists() === true) {
                // remove the lock of the old page
                if ($lock = $oldPage->lock()) {
                    $lock->remove();
                }

                // actually move stuff on disk
                if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
                    throw new LogicException('The page directory cannot be moved');
                }

                // remove from the siblings
                $oldPage->parentModel()->children()->remove($oldPage);

                Dir::remove($oldPage->mediaRoot());
            }

            // overwrite the new page in the parent collection
            if ($newPage->isDraft() === true) {
                $newPage->parentModel()->drafts()->set($newPage->id(), $newPage);
            } else {
                $newPage->parentModel()->children()->set($newPage->id(), $newPage);
            }

            return $newPage;
        });
    }

    /**
     * Change the slug for a specific language
     *
     * @param string $slug
     * @param string|null $languageCode
     * @return static
     * @throws \Kirby\Exception\NotFoundException If the language for the given language code cannot be found
     * @throws \Kirby\Exception\InvalidArgumentException If the slug for the default language is being changed
     */
    protected function changeSlugForLanguage(string $slug, string $languageCode = null)
    {
        $language = $this->kirby()->language($languageCode);

        if (!$language) {
            throw new NotFoundException('The language: "' . $languageCode . '" does not exist');
        }

        if ($language->isDefault() === true) {
            throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language');
        }

        $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $languageCode];
        return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) {
            // remove the slug if it's the same as the folder name
            if ($slug === $page->uid()) {
                $slug = null;
            }

            return $page->save(['slug' => $slug], $languageCode);
        });
    }

    /**
     * Change the status of the current page
     * to either draft, listed or unlisted.
     * If changing to `listed`, you can pass a position for the
     * page in the siblings collection. Siblings will be resorted.
     *
     * @param string $status "draft", "listed" or "unlisted"
     * @param int|null $position Optional sorting number
     * @return static
     * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
     */
    public function changeStatus(string $status, int $position = null)
    {
        switch ($status) {
            case 'draft':
                return $this->changeStatusToDraft();
            case 'listed':
                return $this->changeStatusToListed($position);
            case 'unlisted':
                return $this->changeStatusToUnlisted();
            default:
                throw new InvalidArgumentException('Invalid status: ' . $status);
        }
    }

    /**
     * @return static
     */
    protected function changeStatusToDraft()
    {
        $arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
        $page = $this->commit(
            'changeStatus',
            $arguments,
            fn ($page) => $page->unpublish()
        );

        return $page;
    }

    /**
     * @param int|null $position
     * @return $this|static
     */
    protected function changeStatusToListed(int $position = null)
    {
        // create a sorting number for the page
        $num = $this->createNum($position);

        // don't sort if not necessary
        if ($this->status() === 'listed' && $num === $this->num()) {
            return $this;
        }

        $arguments = ['page' => $this, 'status' => 'listed', 'position' => $num];
        $page = $this->commit('changeStatus', $arguments, function ($page, $status, $position) {
            return $page->publish()->changeNum($position);
        });

        if ($this->blueprint()->num() === 'default') {
            $page->resortSiblingsAfterListing($num);
        }

        return $page;
    }

    /**
     * @return $this|static
     */
    protected function changeStatusToUnlisted()
    {
        if ($this->status() === 'unlisted') {
            return $this;
        }

        $arguments = ['page' => $this, 'status' => 'unlisted', 'position' => null];
        $page = $this->commit('changeStatus', $arguments, function ($page) {
            return $page->publish()->changeNum(null);
        });

        $this->resortSiblingsAfterUnlisting();

        return $page;
    }

    /**
     * Change the position of the page in its siblings
     * collection. Siblings will be resorted. If the page
     * status isn't yet `listed`, it will be changed to it.
     *
     * @param int|null $position
     * @return $this|static
     */
    public function changeSort(int $position = null)
    {
        return $this->changeStatus('listed', $position);
    }

    /**
     * Changes the page template
     *
     * @param string $template
     * @return $this|static
     * @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
     */
    public function changeTemplate(string $template)
    {
        if ($template === $this->intendedTemplate()->name()) {
            return $this;
        }

        return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
            if ($this->kirby()->multilang() === true) {
                $newPage = $this->clone([
                    'template' => $template
                ]);

                foreach ($this->kirby()->languages()->codes() as $code) {
                    if ($oldPage->translation($code)->exists() !== true) {
                        continue;
                    }

                    $content = $oldPage->content($code)->convertTo($template);

                    if (F::remove($oldPage->contentFile($code)) !== true) {
                        throw new LogicException('The old text file could not be removed');
                    }

                    // save the language file
                    $newPage->save($content, $code);
                }

                // return a fresh copy of the object
                $page = $newPage->clone();
            } else {
                $newPage = $this->clone([
                    'content'  => $this->content()->convertTo($template),
                    'template' => $template
                ]);

                if (F::remove($oldPage->contentFile()) !== true) {
                    throw new LogicException('The old text file could not be removed');
                }

                $page = $newPage->save();
            }

            // update the parent collection
            if ($page->isDraft() === true) {
                $page->parentModel()->drafts()->set($page->id(), $page);
            } else {
                $page->parentModel()->children()->set($page->id(), $page);
            }

            return $page;
        });
    }

    /**
     * Change the page title
     *
     * @param string $title
     * @param string|null $languageCode
     * @return static
     */
    public function changeTitle(string $title, string $languageCode = null)
    {
        $arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode];
        return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) {
            $page = $page->save(['title' => $title], $languageCode);

            // flush the parent cache to get children and drafts right
            if ($page->isDraft() === true) {
                $page->parentModel()->drafts()->set($page->id(), $page);
            } else {
                $page->parentModel()->children()->set($page->id(), $page);
            }

            return $page;
        });
    }

    /**
     * Commits a page action, by following these steps
     *
     * 1. checks the action rules
     * 2. sends the before hook
     * 3. commits the store action
     * 4. sends the after hook
     * 5. returns the result
     *
     * @param string $action
     * @param array $arguments
     * @param \Closure $callback
     * @return mixed
     */
    protected function commit(string $action, array $arguments, Closure $callback)
    {
        $old            = $this->hardcopy();
        $kirby          = $this->kirby();
        $argumentValues = array_values($arguments);

        $this->rules()->$action(...$argumentValues);
        $kirby->trigger('page.' . $action . ':before', $arguments);

        $result = $callback(...$argumentValues);

        if ($action === 'create') {
            $argumentsAfter = ['page' => $result];
        } elseif ($action === 'duplicate') {
            $argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old];
        } elseif ($action === 'delete') {
            $argumentsAfter = ['status' => $result, 'page' => $old];
        } else {
            $argumentsAfter = ['newPage' => $result, 'oldPage' => $old];
        }
        $kirby->trigger('page.' . $action . ':after', $argumentsAfter);

        $kirby->cache('pages')->flush();
        return $result;
    }

    /**
     * Copies the page to a new parent
     *
     * @param array $options
     * @return \Kirby\Cms\Page
     * @throws \Kirby\Exception\DuplicateException If the page already exists
     */
    public function copy(array $options = [])
    {
        $slug        = $options['slug']      ?? $this->slug();
        $isDraft     = $options['isDraft']   ?? $this->isDraft();
        $parent      = $options['parent']    ?? null;
        $parentModel = $options['parent']    ?? $this->site();
        $num         = $options['num']       ?? null;
        $children    = $options['children']  ?? false;
        $files       = $options['files']     ?? false;

        // clean up the slug
        $slug = Str::slug($slug);

        if ($parentModel->findPageOrDraft($slug)) {
            throw new DuplicateException([
                'key'  => 'page.duplicate',
                'data' => [
                    'slug' => $slug
                ]
            ]);
        }

        $tmp = new static([
            'isDraft' => $isDraft,
            'num'     => $num,
            'parent'  => $parent,
            'slug'    => $slug,
        ]);

        $ignore = [
            $this->kirby()->locks()->file($this)
        ];

        // don't copy files
        if ($files === false) {
            foreach ($this->files() as $file) {
                $ignore[] = $file->root();

                // append all content files
                array_push($ignore, ...$file->contentFiles());
            }
        }

        Dir::copy($this->root(), $tmp->root(), $children, $ignore);

        $copy = $parentModel->clone()->findPageOrDraft($slug);

        // remove all translated slugs
        if ($this->kirby()->multilang() === true) {
            foreach ($this->kirby()->languages() as $language) {
                if ($language->isDefault() === false && $copy->translation($language)->exists() === true) {
                    $copy = $copy->save(['slug' => null], $language->code());
                }
            }
        }

        // add copy to siblings
        if ($isDraft === true) {
            $parentModel->drafts()->append($copy->id(), $copy);
        } else {
            $parentModel->children()->append($copy->id(), $copy);
        }

        return $copy;
    }

    /**
     * Creates and stores a new page
     *
     * @param array $props
     * @return static
     */
    public static function create(array $props)
    {
        // clean up the slug
        $props['slug']     = Str::slug($props['slug'] ?? $props['content']['title'] ?? null);
        $props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
        $props['isDraft']  = ($props['draft'] ?? true);

        // create a temporary page object
        $page = Page::factory($props);

        // create a form for the page
        $form = Form::for($page, [
            'values' => $props['content'] ?? []
        ]);

        // inject the content
        $page = $page->clone(['content' => $form->strings(true)]);

        // run the hooks and creation action
        $page = $page->commit('create', ['page' => $page, 'input' => $props], function ($page, $props) {

            // always create pages in the default language
            if ($page->kirby()->multilang() === true) {
                $languageCode = $page->kirby()->defaultLanguage()->code();
            } else {
                $languageCode = null;
            }

            // write the content file
            $page = $page->save($page->content()->toArray(), $languageCode);

            // flush the parent cache to get children and drafts right
            if ($page->isDraft() === true) {
                $page->parentModel()->drafts()->append($page->id(), $page);
            } else {
                $page->parentModel()->children()->append($page->id(), $page);
            }

            return $page;
        });

        // publish the new page if a number is given
        if (isset($props['num']) === true) {
            $page = $page->changeStatus('listed', $props['num']);
        }

        return $page;
    }

    /**
     * Creates a child of the current page
     *
     * @param array $props
     * @return static
     */
    public function createChild(array $props)
    {
        $props = array_merge($props, [
            'url'    => null,
            'num'    => null,
            'parent' => $this,
            'site'   => $this->site(),
        ]);

        $modelClass = Page::$models[$props['template']] ?? Page::class;
        return $modelClass::create($props);
    }

    /**
     * Create the sorting number for the page
     * depending on the blueprint settings
     *
     * @param int|null $num
     * @return int
     */
    public function createNum(int $num = null): int
    {
        $mode = $this->blueprint()->num();

        switch ($mode) {
            case 'zero':
                return 0;
            case 'date':
            case 'datetime':
                // the $format needs to produce only digits,
                // so it can be converted to integer below
                $format = $mode === 'date' ? 'Ymd' : 'YmdHi';
                $lang   = $this->kirby()->defaultLanguage() ?? null;
                $field  = $this->content($lang)->get('date');
                $date   = $field->isEmpty() ? 'now' : $field;
                return (int)date($format, strtotime($date));
            case 'default':

                $max = $this
                    ->parentModel()
                    ->children()
                    ->listed()
                    ->merge($this)
                    ->count();

                // default positioning at the end
                if ($num === null) {
                    $num = $max;
                }

                // avoid zeros or negative numbers
                if ($num < 1) {
                    return 1;
                }

                // avoid higher numbers than possible
                if ($num > $max) {
                    return $max;
                }

                return $num;
            default:
                // get instance with default language
                $app = $this->kirby()->clone([], false);
                $app->setCurrentLanguage();

                $template = Str::template($mode, [
                    'kirby' => $app,
                    'page'  => $app->page($this->id()),
                    'site'  => $app->site(),
                ], ['fallback' => '']);

                return (int)$template;
        }
    }

    /**
     * Deletes the page
     *
     * @param bool $force
     * @return bool
     */
    public function delete(bool $force = false): bool
    {
        return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {

            // delete all files individually
            foreach ($page->files() as $file) {
                $file->delete();
            }

            // delete all children individually
            foreach ($page->children() as $child) {
                $child->delete(true);
            }

            // actually remove the page from disc
            if ($page->exists() === true) {

                // delete all public media files
                Dir::remove($page->mediaRoot());

                // delete the content folder for this page
                Dir::remove($page->root());

                // if the page is a draft and the _drafts folder
                // is now empty. clean it up.
                if ($page->isDraft() === true) {
                    $draftsDir = dirname($page->root());

                    if (Dir::isEmpty($draftsDir) === true) {
                        Dir::remove($draftsDir);
                    }
                }
            }

            if ($page->isDraft() === true) {
                $page->parentModel()->drafts()->remove($page);
            } else {
                $page->parentModel()->children()->remove($page);
                $page->resortSiblingsAfterUnlisting();
            }

            return true;
        });
    }

    /**
     * Duplicates the page with the given
     * slug and optionally copies all files
     *
     * @param string|null $slug
     * @param array $options
     * @return \Kirby\Cms\Page
     */
    public function duplicate(string $slug = null, array $options = [])
    {

        // create the slug for the duplicate
        $slug = Str::slug($slug ?? $this->slug() . '-' . Str::slug(t('page.duplicate.appendix')));

        $arguments = [
            'originalPage' => $this,
            'input'        => $slug,
            'options'      => $options
        ];

        return $this->commit('duplicate', $arguments, function ($page, $slug, $options) {
            $page = $this->copy([
                'parent'   => $this->parent(),
                'slug'     => $slug,
                'isDraft'  => true,
                'files'    => $options['files']    ?? false,
                'children' => $options['children'] ?? false,
            ]);

            if (isset($options['title']) === true) {
                $page = $page->changeTitle($options['title']);
            }

            return $page;
        });
    }

    /**
     * @return $this|static
     * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     */
    public function publish()
    {
        if ($this->isDraft() === false) {
            return $this;
        }

        $page = $this->clone([
            'isDraft' => false,
            'root'    => null
        ]);

        // actually do it on disk
        if ($this->exists() === true) {
            if (Dir::move($this->root(), $page->root()) !== true) {
                throw new LogicException('The draft folder cannot be moved');
            }

            // Get the draft folder and check if there are any other drafts
            // left. Otherwise delete it.
            $draftDir = dirname($this->root());

            if (Dir::isEmpty($draftDir) === true) {
                Dir::remove($draftDir);
            }
        }

        // remove the page from the parent drafts and add it to children
        $page->parentModel()->drafts()->remove($page);
        $page->parentModel()->children()->append($page->id(), $page);

        return $page;
    }

    /**
     * Clean internal caches
     * @return $this
     */
    public function purge()
    {
        $this->blueprint    = null;
        $this->children     = null;
        $this->content      = null;
        $this->drafts       = null;
        $this->files        = null;
        $this->inventory    = null;
        $this->translations = null;

        return $this;
    }

    /**
     * @param int|null $position
     * @return bool
     * @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection
     */
    protected function resortSiblingsAfterListing(int $position = null): bool
    {
        // get all siblings including the current page
        $siblings = $this
            ->parentModel()
            ->children()
            ->listed()
            ->append($this)
            ->filter(fn ($page) => $page->blueprint()->num() === 'default');

        // get a non-associative array of ids
        $keys  = $siblings->keys();
        $index = array_search($this->id(), $keys);

        // if the page is not included in the siblings something went wrong
        if ($index === false) {
            throw new LogicException('The page is not included in the sorting index');
        }

        if ($position > count($keys)) {
            $position = count($keys);
        }

        // move the current page number in the array of keys
        // subtract 1 from the num and the position, because of the
        // zero-based array keys
        $sorted = A::move($keys, $index, $position - 1);

        foreach ($sorted as $key => $id) {
            if ($id === $this->id()) {
                continue;
            } elseif ($sibling = $siblings->get($id)) {
                $sibling->changeNum($key + 1);
            }
        }

        $parent = $this->parentModel();
        $parent->children = $parent->children()->sort('num', 'asc');

        return true;
    }

    /**
     * @return bool
     */
    public function resortSiblingsAfterUnlisting(): bool
    {
        $index    = 0;
        $parent   = $this->parentModel();
        $siblings = $parent
            ->children()
            ->listed()
            ->not($this)
            ->filter(fn ($page) => $page->blueprint()->num() === 'default');

        if ($siblings->count() > 0) {
            foreach ($siblings as $sibling) {
                $index++;
                $sibling->changeNum($index);
            }

            $parent->children = $siblings->sort('num', 'asc');
        }

        return true;
    }

    /**
     * Convert a page from listed or
     * unlisted to draft.
     *
     * @return $this|static
     * @throws \Kirby\Exception\LogicException If the folder cannot be moved
     */
    public function unpublish()
    {
        if ($this->isDraft() === true) {
            return $this;
        }

        $page = $this->clone([
            'isDraft' => true,
            'num'     => null,
            'dirname' => null,
            'root'    => null
        ]);

        // actually do it on disk
        if ($this->exists() === true) {
            if (Dir::move($this->root(), $page->root()) !== true) {
                throw new LogicException('The page folder cannot be moved to drafts');
            }
        }

        // remove the page from the parent children and add it to drafts
        $page->parentModel()->children()->remove($page);
        $page->parentModel()->drafts()->append($page->id(), $page);

        $page->resortSiblingsAfterUnlisting();

        return $page;
    }

    /**
     * Updates the page data
     *
     * @param array|null $input
     * @param string|null $languageCode
     * @param bool $validate
     * @return static
     */
    public function update(array $input = null, string $languageCode = null, bool $validate = false)
    {
        if ($this->isDraft() === true) {
            $validate = false;
        }

        $page = parent::update($input, $languageCode, $validate);

        // if num is created from page content, update num on content update
        if ($page->isListed() === true && in_array($page->blueprint()->num(), ['zero', 'default']) === false) {
            $page = $page->changeNum($page->createNum());
        }

        return $page;
    }
}
