<?php declare(strict_types=1);

namespace Jawira\CaseConverter;

use Jawira\CaseConverter\Glue\AdaCase;
use Jawira\CaseConverter\Glue\CamelCase;
use Jawira\CaseConverter\Glue\CobolCase;
use Jawira\CaseConverter\Glue\DashGluer;
use Jawira\CaseConverter\Glue\DotNotation;
use Jawira\CaseConverter\Glue\Gluer;
use Jawira\CaseConverter\Glue\KebabCase;
use Jawira\CaseConverter\Glue\LowerCase;
use Jawira\CaseConverter\Glue\MacroCase;
use Jawira\CaseConverter\Glue\PascalCase;
use Jawira\CaseConverter\Glue\SentenceCase;
use Jawira\CaseConverter\Glue\SnakeCase;
use Jawira\CaseConverter\Glue\SpaceGluer;
use Jawira\CaseConverter\Glue\TitleCase;
use Jawira\CaseConverter\Glue\TrainCase;
use Jawira\CaseConverter\Glue\UnderscoreGluer;
use Jawira\CaseConverter\Glue\UpperCase;
use Jawira\CaseConverter\Split\DashSplitter;
use Jawira\CaseConverter\Split\DotSplitter;
use Jawira\CaseConverter\Split\SpaceSplitter;
use Jawira\CaseConverter\Split\Splitter;
use Jawira\CaseConverter\Split\UnderscoreSplitter;
use Jawira\CaseConverter\Split\UppercaseSplitter;
use function is_subclass_of;
use function preg_match;

/**
 * Convert string between different naming conventions.
 *
 * Handled formats:
 *
 * - Ada case
 * - Camel case
 * - Cobol case
 * - Kebab case
 * - Lower case
 * - Macro case
 * - Pascal case
 * - Sentence case
 * - Snake case
 * - Title case
 * - Train case
 * - Upper case
 *
 * @method self fromAda() Treat input string as _Ada case_
 * @method self fromCamel() Treat input string as _Camel case_
 * @method self fromCobol() Treat input string as _Cobol case_
 * @method self fromDot() Treat input string as _Dot notation_
 * @method self fromKebab() Treat input string as _Kebab case_
 * @method self fromLower() Treat input string as _Lower case_
 * @method self fromMacro() Treat input string as _Macro case_
 * @method self fromPascal() Treat input string as _Pascal case_
 * @method self fromSentence() Treat input string as _Sentence case_
 * @method self fromSnake() Treat input string as _Snake case_
 * @method self fromTitle() Treat input string as _Title case_
 * @method self fromTrain() Treat input string as _Train case_
 * @method self fromUpper() Treat input string as _Upper case_
 *
 * @method string toAda() Return string in _Ada case_ format
 * @method string toCamel() Return string in _Camel case_ format
 * @method string toCobol() Return string in _Cobol case_ format
 * @method string toDot() Return string in _Dot notation_
 * @method string toKebab() Return string in _Kebab case_ format
 * @method string toLower() Return string in _Lower case_ format
 * @method string toMacro() Return string in _Macro case_ format
 * @method string toPascal() Return string in _Pascal case_ format
 * @method string toSentence() Return string in _Sentence case_ format
 * @method string toSnake() Return string in _Snake case_ format
 * @method string toTitle() Return string in _Title case_ format
 * @method string toTrain() Return string in _Train case_ format
 * @method string toUpper() Return string in _Upper case_ format
 *
 * @see     https://softwareengineering.stackexchange.com/questions/322413/bothered-by-an-unknown-letter-case-name
 * @see     http://www.unicode.org/charts/case/
 * @author  Jawira Portugal <dev@tugal.be>
 */
class Convert
{
    /** @var string Input string to convert */
    protected string $source;

    /** @var string[] Words extracted from input string */
    protected array $words;

    protected bool $forceSimpleCaseMapping;

    /**
     * Constructor method
     *
     * @param string $source String to convert
     *
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    public function __construct(string $source)
    {
        $this->source                 = $source;
        $this->forceSimpleCaseMapping = false;
        $this->fromAuto();
    }

    /**
     * Handle `to*` methods and `from*` methods
     *
     * @param string  $methodName
     * @param mixed[] $arguments
     *
     * @return string|\Jawira\CaseConverter\Convert
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    public function __call(string $methodName, array $arguments)
    {
        $strStartsWith = static fn(string $haystack, string $needle): bool => 0 === mb_strpos($haystack, $needle);
        if ($strStartsWith($methodName, 'from')) {
            $result = $this->handleSplitterMethod($methodName);
        } elseif ($strStartsWith($methodName, 'to')) {
            $result = $this->handleGluerMethod($methodName);
        } else {
            throw new CaseConverterException("Unknown method: $methodName");
        }

        return $result;
    }

    /**
     * Auto-detect naming convention.
     *
     * This is the default method when you don't call a "from*" method.
     *
     * @param bool $digitsAreLowercase When `true`, digits in input string are considered to be equivalent to a lowercase character. When `false` digits are considered to be uppercase.
     * @return \Jawira\CaseConverter\Convert
     */
    public function fromAuto(bool $digitsAreLowercase = true): self
    {
        $splitter = $this->analyse($this->source, $digitsAreLowercase);
        $this->extractWords($splitter);

        return $this;
    }

    /**
     * Returns original input string
     *
     * @return string Original input string
     */
    public function getSource(): string
    {
        return $this->source;
    }

    /**
     * Detected words extracted from original string.
     *
     * @return string[]
     */
    public function toArray(): array
    {
        return $this->words;
    }

    /**
     * Forces to use Simple Case-Mapping
     *
     * Call this method if you want to maintain the behaviour before PHP 7.3
     *
     * @see https://unicode.org/faq/casemap_charprop.html
     * @return \Jawira\CaseConverter\Convert
     */
    public function forceSimpleCaseMapping(): self
    {
        $this->forceSimpleCaseMapping = true;

        return $this;
    }

    /**
     * Detects word separator of $input string and tells you what strategy you should use.
     *
     * @param string $input              String to be analysed
     * @param bool   $digitsAreLowercase When `true`, digits in input string are considered to be equivalent to a lowercase character. When `false` digits are considered to be uppercase.
     *
     * @return \Jawira\CaseConverter\Split\Splitter
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    protected function analyse(string $input, bool $digitsAreLowercase): Splitter
    {
        $strContains = static fn(string $input, string $needle): bool => is_int(mb_strpos($input, $needle));

        switch (true) {
            case $strContains($input, UnderscoreGluer::DELIMITER):
                $splittingStrategy = new UnderscoreSplitter($input);
                break;
            case $strContains($input, DashGluer::DELIMITER):
                $splittingStrategy = new DashSplitter($input);
                break;
            case $strContains($input, SpaceGluer::DELIMITER):
                $splittingStrategy = new SpaceSplitter($input);
                break;
            case $strContains($input, DotNotation::DELIMITER):
                $splittingStrategy = new DotSplitter($input);
                break;
            case $this->isUppercaseWord($input, $digitsAreLowercase):
                $splittingStrategy = new UnderscoreSplitter($input);
                break;
            default:
                $splittingStrategy = new UppercaseSplitter($input);
                break;
        }

        return $splittingStrategy;
    }

    /**
     * Returns true if $input string is a single word composed only by uppercase characters.
     *
     * ```
     * isUppercaseWord('BRUSSELS'); // true
     * isUppercaseWord('Brussels'); // false
     * ```
     *
     * @see     https://www.regular-expressions.info/unicode.html#category
     *
     * @param string $input String to be tested.
     *
     * @return bool
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    protected function isUppercaseWord(string $input, bool $digitsAreLowercase): bool
    {
        $pattern = $digitsAreLowercase ? '#^\p{Lu}+$#u' : '#^[\p{Lu}\p{Nd}]+$#u';
        $match   = preg_match($pattern, $input);

        if (false === $match) {
            throw new CaseConverterException('Error executing regex'); // @codeCoverageIgnore
        }

        return 1 === $match;
    }

    /**
     * Main function, receives input string and then it stores extracted words into an array.
     *
     * @param \Jawira\CaseConverter\Split\Splitter $splitter
     *
     * @return $this
     */
    protected function extractWords(Splitter $splitter): self
    {
        $this->words = $splitter->split();

        return $this;
    }

    /**
     * Methods to explicitly define naming conventions for input string
     *
     * @param string $methodName
     *
     * @return $this
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    protected function handleSplitterMethod(string $methodName): self
    {
        switch ($methodName) {
            case 'fromCamel':
            case 'fromPascal':
                $splitterName = UppercaseSplitter::class;
                break;
            case 'fromSnake':
            case 'fromAda':
            case 'fromMacro':
                $splitterName = UnderscoreSplitter::class;
                break;
            case 'fromKebab':
            case 'fromTrain':
            case 'fromCobol':
                $splitterName = DashSplitter::class;
                break;
            case 'fromLower':
            case 'fromUpper':
            case 'fromTitle':
            case 'fromSentence':
                $splitterName = SpaceSplitter::class;
                break;
            case 'fromDot':
                $splitterName = DotSplitter::class;
                break;
            default:
                throw new CaseConverterException("Unknown method: $methodName");
        }

        $splitter = $this->createSplitter($splitterName, $this->source);
        $this->extractWords($splitter);

        return $this;
    }

    /**
     * @param string $className Class name in string format
     * @param string $source    Input string to be split
     *
     * @return \Jawira\CaseConverter\Split\Splitter
     */
    protected function createSplitter(string $className, string $source): Splitter
    {
        assert(is_subclass_of($className, Splitter::class));

        return new $className($source);
    }

    /**
     * Handles all methods starting by `to*`
     *
     * @param string $methodName
     *
     * @return string
     * @throws \Jawira\CaseConverter\CaseConverterException
     */
    protected function handleGluerMethod(string $methodName): string
    {

        switch ($methodName) {
            case 'toAda':
                $className = AdaCase::class;
                break;
            case 'toCamel':
                $className = CamelCase::class;
                break;
            case 'toCobol':
                $className = CobolCase::class;
                break;
            case 'toKebab':
                $className = KebabCase::class;
                break;
            case 'toLower':
                $className = LowerCase::class;
                break;
            case 'toMacro':
                $className = MacroCase::class;
                break;
            case 'toPascal':
                $className = PascalCase::class;
                break;
            case 'toSentence':
                $className = SentenceCase::class;
                break;
            case 'toSnake':
                $className = SnakeCase::class;
                break;
            case 'toTitle':
                $className = TitleCase::class;
                break;
            case 'toTrain':
                $className = TrainCase::class;
                break;
            case 'toUpper':
                $className = UpperCase::class;
                break;
            case 'toDot':
                $className = DotNotation::class;
                break;
            default:
                throw new CaseConverterException("Unknown method: $methodName");
        }

        $gluer = $this->createGluer($className, $this->words, $this->forceSimpleCaseMapping);

        return $gluer->glue();
    }

    /**
     * @param string   $className              Class name in string format
     * @param string[] $words                  Words to glue
     * @param bool     $forceSimpleCaseMapping Should _Simple Case-Mapping_ be forced?
     *
     * @return \Jawira\CaseConverter\Glue\Gluer
     */
    protected function createGluer(string $className, array $words, bool $forceSimpleCaseMapping): Gluer
    {
        assert(is_subclass_of($className, Gluer::class));

        return new $className($words, $forceSimpleCaseMapping);
    }
}
