<?php

namespace Mcamara\LaravelLocalization;

use Illuminate\Config\Repository;
use Illuminate\Contracts\Routing\UrlRoutable;
use Mcamara\LaravelLocalization\Exceptions\SupportedLocalesNotDefined;
use Mcamara\LaravelLocalization\Exceptions\UnsupportedLocaleException;

class LaravelLocalization
{
    /**
     * The env key that the forced locale for routing is stored in.
     */
    const ENV_ROUTE_KEY = 'ROUTING_LOCALE';

    /**
     * Config repository.
     *
     * @var \Illuminate\Config\Repository
     */
    protected $configRepository;

    /**
     * Illuminate view Factory.
     *
     * @var \Illuminate\View\Factory
     */
    protected $view;

    /**
     * Illuminate translator class.
     *
     * @var \Illuminate\Translation\Translator
     */
    protected $translator;

    /**
     * Illuminate router class.
     *
     * @var \Illuminate\Routing\Router
     */
    protected $router;

    /**
     * Illuminate request class.
     *
     * @var \Illuminate\Routing\Request
     */
    protected $request;

    /**
     * Illuminate url class.
     *
     * @var \Illuminate\Routing\UrlGenerator
     */
    protected $url;

    /**
     * Illuminate request class.
     *
     * @var Illuminate\Foundation\Application
     */
    protected $app;

    /**
     * Illuminate request class.
     *
     * @var string
     */
    protected $baseUrl;

    /**
     * Default locale.
     *
     * @var string
     */
    protected $defaultLocale;

    /**
     * Supported Locales.
     *
     * @var array
     */
    protected $supportedLocales;

    /**
     * Current locale.
     *
     * @var string
     */
    protected $currentLocale = false;

    /**
     * An array that contains all routes that should be translated.
     *
     * @var array
     */
    protected $translatedRoutes = [];

    /**
     * Name of the translation key of the current route, it is used for url translations.
     *
     * @var string
     */
    protected $routeName;

    /**
     * An array that contains all translated routes by url
     *
     * @var array
     */
    protected $cachedTranslatedRoutesByUrl = [];

    /**
     * Creates new instance.
     *
     * @throws UnsupportedLocaleException
     */
    public function __construct()
    {
        $this->app = app();

        $this->configRepository = $this->app['config'];
        $this->view = $this->app['view'];
        $this->translator = $this->app['translator'];
        $this->router = $this->app['router'];
        $this->request = $this->app['request'];
        $this->url = $this->app['url'];

        // set default locale
        $this->defaultLocale = $this->configRepository->get('app.locale');
        $supportedLocales = $this->getSupportedLocales();

        if (empty($supportedLocales[$this->defaultLocale])) {
            throw new UnsupportedLocaleException('Laravel default locale is not in the supportedLocales array.');
        }
    }

    /**
     * Set and return current locale.
     *
     * @param string $locale Locale to set the App to (optional)
     *
     * @return string Returns locale (if route has any) or null (if route does not have a locale)
     */
    public function setLocale($locale = null)
    {
        if (empty($locale) || !\is_string($locale)) {
            // If the locale has not been passed through the function
            // it tries to get it from the first segment of the url
            $locale = $this->request->segment(1);

            // If the locale is determined by env, use that
            // Note that this is how per-locale route caching is performed.
            if ( ! $locale) {
                $locale = $this->getForcedLocale();
            }
        }

        if (!empty($this->supportedLocales[$locale])) {
            $this->currentLocale = $locale;
        } else {
            // if the first segment/locale passed is not valid
            // the system would ask which locale have to take
            // it could be taken by the browser
            // depending on your configuration

            $locale = null;

            // if we reached this point and hideDefaultLocaleInURL is true
            // we have to assume we are routing to a defaultLocale route.
            if ($this->hideDefaultLocaleInURL()) {
                $this->currentLocale = $this->defaultLocale;
            }
            // but if hideDefaultLocaleInURL is false, we have
            // to retrieve it from the browser...
            else {
                $this->currentLocale = $this->getCurrentLocale();
            }
        }

        $this->app->setLocale($this->currentLocale);

        // Regional locale such as de_DE, so formatLocalized works in Carbon
        $regional = $this->getCurrentLocaleRegional();
        $suffix = $this->configRepository->get('laravellocalization.utf8suffix');
        if ($regional) {
            setlocale(LC_TIME, $regional . $suffix);
            setlocale(LC_MONETARY, $regional . $suffix);
        }

        return $locale;
    }

    /**
     * Check if $locale is default locale and supposed to be hidden in url
     *
     * @param string $locale Locale to be checked
     *
     * @return boolean Returns true if above requirement are met, otherwise false
     */

     public function isHiddenDefault($locale)
     {
       return  ($this->getDefaultLocale() === $locale && $this->hideDefaultLocaleInURL());
     }

    /**
     * Set and return supported locales.
     *
     * @param array $locales Locales that the App supports
     */
    public function setSupportedLocales($locales)
    {
        $this->supportedLocales = $locales;
    }

    /**
     * Returns an URL adapted to $locale or current locale.
     *
     * @param string      $url    URL to adapt. If not passed, the current url would be taken.
     * @param string|bool $locale Locale to adapt, false to remove locale
     *
     * @throws UnsupportedLocaleException
     *
     * @return string URL translated
     */
    public function localizeURL($url = null, $locale = null)
    {
        return $this->getLocalizedURL($locale, $url);
    }

    /**
     * Returns an URL adapted to $locale.
     *
     *
     * @param string|bool  $locale     Locale to adapt, false to remove locale
     * @param string|false $url        URL to adapt in the current language. If not passed, the current url would be taken.
     * @param array        $attributes Attributes to add to the route, if empty, the system would try to extract them from the url.
     * @param bool         $forceDefaultLocation Force to show default location even hideDefaultLocaleInURL set as TRUE
     *
     * @throws SupportedLocalesNotDefined
     * @throws UnsupportedLocaleException
     *
     * @return string|false URL translated, False if url does not exist
     */
    public function getLocalizedURL($locale = null, $url = null, $attributes = [], $forceDefaultLocation = false)
    {
        if ($locale === null) {
            $locale = $this->getCurrentLocale();
        }

        if (!$this->checkLocaleInSupportedLocales($locale)) {
            throw new UnsupportedLocaleException('Locale \''.$locale.'\' is not in the list of supported locales.');
        }

        if (empty($attributes)) {
            $attributes = $this->extractAttributes($url, $locale);
        }
        $urlQuery = parse_url($url, PHP_URL_QUERY);
        $urlQuery = $urlQuery ? '?'.$urlQuery : '';

        if (empty($url)) {
            if (!empty($this->routeName)) {
                return $this->getURLFromRouteNameTranslated($locale, $this->routeName, $attributes, $forceDefaultLocation);
            }

            $url = $this->request->fullUrl();
        } else {
            $url = $this->url->to($url);
            $url = preg_replace('/'. preg_quote($urlQuery, '/') . '$/', '', $url);
        }

        if ($locale && $translatedRoute = $this->findTranslatedRouteByUrl($url, $attributes, $this->currentLocale)) {
            return $this->getURLFromRouteNameTranslated($locale, $translatedRoute, $attributes, $forceDefaultLocation).$urlQuery;
        }

        $base_path = $this->request->getBaseUrl();
        $parsed_url = parse_url($url);
        $url_locale = $this->getDefaultLocale();

        if (!$parsed_url || empty($parsed_url['path'])) {
            $path = $parsed_url['path'] = '';
        } else {
            $parsed_url['path'] = str_replace($base_path, '', '/'.ltrim($parsed_url['path'], '/'));
            $path = $parsed_url['path'];
            foreach ($this->getSupportedLocales() as $localeCode => $lang) {
                $parsed_url['path'] = preg_replace('%^/?'.$localeCode.'/%', '$1', $parsed_url['path']);
                if ($parsed_url['path'] !== $path) {
                    $url_locale = $localeCode;
                    break;
                }

                $parsed_url['path'] = preg_replace('%^/?'.$localeCode.'$%', '$1', $parsed_url['path']);
                if ($parsed_url['path'] !== $path) {
                    $url_locale = $localeCode;
                    break;
                }
            }
        }

        $parsed_url['path'] = ltrim($parsed_url['path'], '/');

        if ($translatedRoute = $this->findTranslatedRouteByPath($parsed_url['path'], $url_locale)) {
            return $this->getURLFromRouteNameTranslated($locale, $translatedRoute, $attributes, $forceDefaultLocation).$urlQuery;
        }

	    if (!empty($locale)) {
            if ($forceDefaultLocation || $locale != $this->getDefaultLocale() || !$this->hideDefaultLocaleInURL()) {
                $parsed_url['path'] = $locale.'/'.ltrim($parsed_url['path'], '/');
            }
        }
        $parsed_url['path'] = ltrim(ltrim($base_path, '/').'/'.$parsed_url['path'], '/');

        //Make sure that the pass path is returned with a leading slash only if it come in with one.
        if (starts_with($path, '/') === true) {
            $parsed_url['path'] = '/'.$parsed_url['path'];
        }
        $parsed_url['path'] = rtrim($parsed_url['path'], '/');

        $url = $this->unparseUrl($parsed_url);

        if ($this->checkUrl($url)) {
            return $url.$urlQuery;
        }

        return $this->createUrlFromUri($url).$urlQuery;
    }

    /**
     * Returns an URL adapted to the route name and the locale given.
     *
     *
     * @param string|bool $locale       Locale to adapt
     * @param string      $transKeyName Translation key name of the url to adapt
     * @param array       $attributes   Attributes for the route (only needed if transKeyName needs them)
     * @param bool        $forceDefaultLocation Force to show default location even hideDefaultLocaleInURL set as TRUE
     *
     * @throws SupportedLocalesNotDefined
     * @throws UnsupportedLocaleException
     *
     * @return string|false URL translated
     */
    public function getURLFromRouteNameTranslated($locale, $transKeyName, $attributes = [], $forceDefaultLocation = false)
    {
        if (!$this->checkLocaleInSupportedLocales($locale)) {
            throw new UnsupportedLocaleException('Locale \''.$locale.'\' is not in the list of supported locales.');
        }

        if (!\is_string($locale)) {
            $locale = $this->getDefaultLocale();
        }

        $route = '';

        if ($forceDefaultLocation || !($locale === $this->defaultLocale && $this->hideDefaultLocaleInURL())) {
            $route = '/'.$locale;
        }
        if (\is_string($locale) && $this->translator->has($transKeyName, $locale)) {
            $translation = $this->translator->trans($transKeyName, [], $locale);
            $route .= '/'.$translation;

            $route = $this->substituteAttributesInRoute($attributes, $route);
        }

        if (empty($route)) {
            // This locale does not have any key for this route name
            return false;
        }

        return rtrim($this->createUrlFromUri($route), '/');
    }

    /**
     * It returns an URL without locale (if it has it)
     * Convenience function wrapping getLocalizedURL(false).
     *
     * @param string|false $url URL to clean, if false, current url would be taken
     *
     * @return string URL with no locale in path
     */
    public function getNonLocalizedURL($url = null)
    {
        return $this->getLocalizedURL(false, $url);
    }

    /**
     * Returns default locale.
     *
     * @return string
     */
    public function getDefaultLocale()
    {
        return $this->defaultLocale;
    }

    /**
     * Return an array of all supported Locales.
     *
     * @throws SupportedLocalesNotDefined
     *
     * @return array
     */
    public function getSupportedLocales()
    {
        if (!empty($this->supportedLocales)) {
            return $this->supportedLocales;
        }

        $locales = $this->configRepository->get('laravellocalization.supportedLocales');

        if (empty($locales) || !\is_array($locales)) {
            throw new SupportedLocalesNotDefined();
        }

        $this->supportedLocales = $locales;

        return $locales;
    }

    /**
     * Return an array of all supported Locales but in the order the user
     * has specified in the config file. Useful for the language selector.
     *
     * @return array
     */
    public function getLocalesOrder()
    {
        $locales = $this->getSupportedLocales();

        $order = $this->configRepository->get('laravellocalization.localesOrder');

        uksort($locales, function ($a, $b) use ($order) {
            $pos_a = array_search($a, $order);
            $pos_b = array_search($b, $order);
            return $pos_a - $pos_b;
        });

        return $locales;
    }

    /**
     * Returns current locale name.
     *
     * @return string current locale name
     */
    public function getCurrentLocaleName()
    {
        return $this->supportedLocales[$this->getCurrentLocale()]['name'];
    }

    /**
     * Returns current locale native name.
     *
     * @return string current locale native name
     */
    public function getCurrentLocaleNative()
    {
        return $this->supportedLocales[$this->getCurrentLocale()]['native'];
    }

    /**
     * Returns current locale direction.
     *
     * @return string current locale direction
     */
    public function getCurrentLocaleDirection()
    {
        if (!empty($this->supportedLocales[$this->getCurrentLocale()]['dir'])) {
            return $this->supportedLocales[$this->getCurrentLocale()]['dir'];
        }

        switch ($this->getCurrentLocaleScript()) {
            // Other (historic) RTL scripts exist, but this list contains the only ones in current use.
            case 'Arab':
            case 'Hebr':
            case 'Mong':
            case 'Tfng':
            case 'Thaa':
            return 'rtl';
            default:
            return 'ltr';
        }
    }

    /**
     * Returns current locale script.
     *
     * @return string current locale script
     */
    public function getCurrentLocaleScript()
    {
        return $this->supportedLocales[$this->getCurrentLocale()]['script'];
    }

    /**
     * Returns current language's native reading.
     *
     * @return string current language's native reading
     */
    public function getCurrentLocaleNativeReading()
    {
        return $this->supportedLocales[$this->getCurrentLocale()]['native'];
    }

    /**
     * Returns current language.
     *
     * @return string current language
     */
    public function getCurrentLocale()
    {
        if ($this->currentLocale) {
            return $this->currentLocale;
        }

        if ($this->useAcceptLanguageHeader() && !$this->app->runningInConsole()) {
            $negotiator = new LanguageNegotiator($this->defaultLocale, $this->getSupportedLocales(), $this->request);

            return $negotiator->negotiateLanguage();
        }

        // or get application default language
        return $this->configRepository->get('app.locale');
    }

    /**
     * Returns current regional.
     *
     * @return string current regional
     */
    public function getCurrentLocaleRegional()
    {
        // need to check if it exists, since 'regional' has been added
        // after version 1.0.11 and existing users will not have it
        if (isset($this->supportedLocales[$this->getCurrentLocale()]['regional'])) {
            return $this->supportedLocales[$this->getCurrentLocale()]['regional'];
        } else {
            return;
        }
    }

    /**
     * Returns supported languages language key.
     *
     * @return array keys of supported languages
     */
    public function getSupportedLanguagesKeys()
    {
        return array_keys($this->supportedLocales);
    }

    /**
     * Check if Locale exists on the supported locales array.
     *
     * @param string|bool $locale string|bool Locale to be checked
     *
     * @throws SupportedLocalesNotDefined
     *
     * @return bool is the locale supported?
     */
    public function checkLocaleInSupportedLocales($locale)
    {
        $locales = $this->getSupportedLocales();
        if ($locale !== false && empty($locales[$locale])) {
            return false;
        }

        return true;
    }

    /**
     * Change route attributes for the ones in the $attributes array.
     *
     * @param $attributes array Array of attributes
     * @param string $route string route to substitute
     *
     * @return string route with attributes changed
     */
    protected function substituteAttributesInRoute($attributes, $route)
    {
        foreach ($attributes as $key => $value) {
            if ($value instanceOf UrlRoutable) {
                $value = $value->getRouteKey();
            }
            $route = str_replace(array('{'.$key.'}', '{'.$key.'?}'), $value, $route);
        }

        // delete empty optional arguments that are not in the $attributes array
        $route = preg_replace('/\/{[^)]+\?}/', '', $route);

        return $route;
    }

    /**
     * Returns translated routes.
     *
     * @return array translated routes
     */
    protected function getTranslatedRoutes()
    {
        return $this->translatedRoutes;
    }

    /**
     * Set current route name.
     *
     * @param string $routeName current route name
     */
    public function setRouteName($routeName)
    {
        $this->routeName = $routeName;
    }

    /**
     * Translate routes and save them to the translated routes array (used in the localize route filter).
     *
     * @param string $routeName Key of the translated string
     *
     * @return string Translated string
     */
    public function transRoute($routeName)
    {
        if (!\in_array($routeName, $this->translatedRoutes)) {
            $this->translatedRoutes[] = $routeName;
        }

        return $this->translator->trans($routeName);
    }

    /**
     * Returns the translation key for a given path.
     *
     * @param string $path Path to get the key translated
     *
     * @return string|false Key for translation, false if not exist
     */
    public function getRouteNameFromAPath($path)
    {
        $attributes = $this->extractAttributes($path);

        $path = parse_url($path)['path'];
        $path = trim(str_replace('/'.$this->currentLocale.'/', '', $path), "/");

        foreach ($this->translatedRoutes as $route) {
            if (trim($this->substituteAttributesInRoute($attributes, $this->translator->trans($route)), '/') === $path) {
                return $route;
            }
        }

        return false;
    }

    /**
     * Returns the translated route for the path and the url given.
     *
     * @param string $path       Path to check if it is a translated route
     * @param string $url_locale Language to check if the path exists
     *
     * @return string|false Key for translation, false if not exist
     */
    protected function findTranslatedRouteByPath($path, $url_locale)
    {
        // check if this url is a translated url
        foreach ($this->translatedRoutes as $translatedRoute) {
            if ($this->translator->trans($translatedRoute, [], $url_locale) == rawurldecode($path)) {
                return $translatedRoute;
            }
        }

        return false;
    }

    /**
     * Returns the translated route for an url and the attributes given and a locale.
     *
     *
     * @param string|false|null $url        Url to check if it is a translated route
     * @param array             $attributes Attributes to check if the url exists in the translated routes array
     * @param string            $locale     Language to check if the url exists
     *
     * @throws SupportedLocalesNotDefined
     * @throws UnsupportedLocaleException
     *
     * @return string|false Key for translation, false if not exist
     */
    protected function findTranslatedRouteByUrl($url, $attributes, $locale)
    {
        if (empty($url)) {
            return false;
        }

        if (isset($this->cachedTranslatedRoutesByUrl[$locale][$url])) {
            return $this->cachedTranslatedRoutesByUrl[$locale][$url];
        }

        // check if this url is a translated url
        foreach ($this->translatedRoutes as $translatedRoute) {
            $routeName = $this->getURLFromRouteNameTranslated($locale, $translatedRoute, $attributes);

            // We can ignore extra url parts and compare only their url_path (ignore arguments that are not attributes)
            if (parse_url($this->getNonLocalizedURL($routeName), PHP_URL_PATH) == parse_url($this->getNonLocalizedURL($url), PHP_URL_PATH)) {
                $this->cachedTranslatedRoutesByUrl[$locale][$url] = $translatedRoute;

                return $translatedRoute;
            }
        }

        return false;
    }

    /**
     * Returns true if the string given is a valid url.
     *
     * @param string $url String to check if it is a valid url
     *
     * @return bool Is the string given a valid url?
     */
    protected function checkUrl($url)
    {
        return filter_var($url, FILTER_VALIDATE_URL);
    }

    /**
     * Returns the config repository for this instance.
     *
     * @return Repository Configuration repository
     */
    public function getConfigRepository()
    {
        return $this->configRepository;
    }

    /**
     * Returns the translation key for a given path.
     *
     * @return bool Returns value of useAcceptLanguageHeader in config.
     */
    protected function useAcceptLanguageHeader()
    {
        return $this->configRepository->get('laravellocalization.useAcceptLanguageHeader');
    }

    public function hideUrlAndAcceptHeader()
    {
      return $this->hideDefaultLocaleInURL() && $this->useAcceptLanguageHeader();
    }

    /**
     * Returns the translation key for a given path.
     *
     * @return bool Returns value of hideDefaultLocaleInURL in config.
     */
    public function hideDefaultLocaleInURL()
    {
        return $this->configRepository->get('laravellocalization.hideDefaultLocaleInURL');
    }

    /**
     * Create an url from the uri.
     *
     * @param string $uri Uri
     *
     * @return string Url for the given uri
     */
    public function createUrlFromUri($uri)
    {
        $uri = ltrim($uri, '/');

        if (empty($this->baseUrl)) {
            return app('url')->to($uri);
        }

        return $this->baseUrl.$uri;
    }

    /**
     * Sets the base url for the site.
     *
     * @param string $url Base url for the site
     */
    public function setBaseUrl($url)
    {
        if (substr($url, -1) != '/') {
            $url .= '/';
        }

        $this->baseUrl = $url;
    }

    /**
     * Returns serialized translated routes for caching purposes.
     *
     * @return string
     */
    public function getSerializedTranslatedRoutes()
    {
        return base64_encode(serialize($this->translatedRoutes));
    }

    /**
     * Sets the translated routes list.
     * Only useful from a cached routes context.
     *
     * @param string $serializedRoutes
     */
    public function setSerializedTranslatedRoutes($serializedRoutes)
    {
        if ( ! $serializedRoutes) {
            return;
        }

        $this->translatedRoutes = unserialize(base64_decode($serializedRoutes));
    }

    /**
     * Extract attributes for current url.
     *
     * @param bool|false|null|string $url    to extract attributes, if not present, the system will look for attributes in the current call
     * @param string                 $locale
     *
     * @return array Array with attributes
     */
    protected function extractAttributes($url = false, $locale = '')
    {
        if (!empty($url)) {
            $attributes = [];
            $parse = parse_url($url);
            if (isset($parse['path'])) {
                $parse['path'] = trim(str_replace('/'.$this->currentLocale.'/', '', $parse['path']), "/");
                $url = explode('/', trim($parse['path'], '/'));
            } else {
                $url = [];
            }

            foreach ($this->router->getRoutes() as $route) {
                $attributes = [];
                $path = method_exists($route, 'uri') ? $route->uri() : $route->getUri();

                if (!preg_match("/{[\w]+\??}/", $path)) {
                    continue;
                }

                $path = explode('/', $path);
                $i = 0;

                // The system's route can't be smaller
                // only the $url can be missing segments (optional parameters)
                // We can assume it's the wrong route
                if (count($path) < count($url)) {
                    continue;
                }

                $match = true;
                foreach ($path as $j => $segment) {
                    if (isset($url[$i])) {
                        if ($segment === $url[$i]) {
                            $i++;
                            continue;
                        } elseif (preg_match("/{[\w]+}/", $segment)) {
                            // must-have parameters
                            $attribute_name = preg_replace(['/}/', '/{/', "/\?/"], '', $segment);
                            $attributes[$attribute_name] = $url[$i];
                            $i++;
                            continue;
                        } elseif (preg_match("/{[\w]+\?}/", $segment)) {
                            // optional parameters
                            if (!isset($path[$j + 1]) || $path[$j + 1] !== $url[$i]) {
                                // optional parameter taken
                                $attribute_name = preg_replace(['/}/', '/{/', "/\?/"], '', $segment);
                                $attributes[$attribute_name] = $url[$i];
                                $i++;
                                continue;
                            } else {
                                $match = false;
                                break;
                            }
                        } else {
                            // As soon as one segment doesn't match, then we have the wrong route
                            $match = false;
                            break;
                        }
                    } elseif (preg_match("/{[\w]+\?}/", $segment)) {
                        $attribute_name = preg_replace(['/}/', '/{/', "/\?/"], '', $segment);
                        $attributes[$attribute_name] = null;
                        $i++;
                    } else {
                        // no optional parameters but no more $url given
                        // this route does not match the url
                        $match = false;
                        break;
                    }
                }

                if (isset($url[$i + 1])) {
                    $match = false;
                }

                if ($match) {
                    return $attributes;
                }
            }
        } else {
            if (!$this->router->current()) {
                return [];
            }

            $attributes = $this->normalizeAttributes($this->router->current()->parameters());
            $response = event('routes.translation', [$locale, $attributes]);

            if (!empty($response)) {
                $response = array_shift($response);
            }

            if (\is_array($response)) {
                $attributes = array_merge($attributes, $response);
            }
        }

        return $attributes;
    }

    /**
     * Build URL using array data from parse_url.
     *
     * @param array|false $parsed_url Array of data from parse_url function
     *
     * @return string Returns URL as string.
     */
    protected function unparseUrl($parsed_url)
    {
        if (empty($parsed_url)) {
            return '';
        }

        $url = '';
        $url .= isset($parsed_url['scheme']) ? $parsed_url['scheme'].'://' : '';
        $url .= $parsed_url['host'] ?? '';
        $url .= isset($parsed_url['port']) ? ':'.$parsed_url['port'] : '';
        $user = $parsed_url['user'] ?? '';
        $pass = isset($parsed_url['pass']) ? ':'.$parsed_url['pass'] : '';
        $url .= $user.(($user || $pass) ? "$pass@" : '');

        if (!empty($url)) {
            $url .= isset($parsed_url['path']) ? '/'.ltrim($parsed_url['path'], '/') : '';
        } else {
            $url .= $parsed_url['path'] ?? '';
        }

        $url .= isset($parsed_url['query']) ? '?'.$parsed_url['query'] : '';
        $url .= isset($parsed_url['fragment']) ? '#'.$parsed_url['fragment'] : '';

        return $url;
    }

    /**
    * Normalize attributes gotten from request parameters.
    *
    * @param      array  $attributes  The attributes
    * @return     array  The normalized attributes
    */
     protected function normalizeAttributes($attributes)
     {
         if (array_key_exists('data', $attributes) && \is_array($attributes['data']) && ! \count($attributes['data'])) {
             $attributes['data'] = null;
             return $attributes;
         }
         return $attributes;
     }

    /**
     * Returns the forced environment set route locale.
     *
     * @return string|null
     */
    protected function getForcedLocale()
    {
        return env(static::ENV_ROUTE_KEY, function () {
            $value = getenv(static::ENV_ROUTE_KEY);

            if ($value !== false) {
                return $value;
            }
        });
    }
}


<?php

namespace Mcamara\LaravelLocalization\Traits;

use Illuminate\Support\Facades\Log;

/**
 * LoadsTranslatedCachedRoutes
 *
 * Add this trait to your App\RouteServiceProvider to load
 * translated cached routes for the active locale, instead
 * of the default locale's routes (irrespective of active).
 */
trait LoadsTranslatedCachedRoutes
{
    /**
     * Load the cached routes for the application.
     *
     * @return void
     */
    protected function loadCachedRoutes()
    {
        $localization = $this->getLaravelLocalization();

        $localization->setLocale();

        $locale = $localization->getCurrentLocale();

        $localeKeys = $localization->getSupportedLanguagesKeys();

        // First, try to load the routes specifically cached for this locale
        // if they do not exist, write a warning to the log and load the default
        // routes instead. Note that this is guaranteed to exist, because the
        // 'cached routes' check in the Application checks its existence.

        $path = $this->makeLocaleRoutesPath($locale, $localeKeys);

        if ( ! file_exists($path)) {

            Log::warning("Routes cached, but no cached routes found for locale '{$locale}'!");

            $path = $this->getDefaultCachedRoutePath();
        }

        $this->app->booted(function () use ($path) {
            require $path;
        });
    }

    /**
     * Returns the path to the cached routes file for a given locale.
     *
     * @param string   $locale
     * @param string[] $localeKeys
     * @return string
     */
    protected function makeLocaleRoutesPath($locale, $localeKeys)
    {
        $path = $this->getDefaultCachedRoutePath();

        $localeSegment = request()->segment(1);
        if ( ! $localeSegment || ! in_array($localeSegment, $localeKeys)) {
            return $path;
        }

        return substr($path, 0, -4) . '_' . $locale . '.php';
    }

    /**
     * Returns the path to the standard cached routes file.
     *
     * @return string
     */
    protected function getDefaultCachedRoutePath()
    {
        return $this->app->getCachedRoutesPath();
    }

    /**
     * @return \Mcamara\LaravelLocalization\LaravelLocalization
     */
    protected function getLaravelLocalization()
    {
        return app('laravellocalization');
    }
}


<?php namespace Mcamara\LaravelLocalization\Middleware;

use Closure;
use Illuminate\Http\RedirectResponse;

class LocaleCookieRedirect extends LaravelLocalizationMiddlewareBase
{

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     *
     * @return mixed
     */
     public function handle($request, Closure $next) {
         // If the URL of the request is in exceptions.
         if ($this->shouldIgnore($request)) {
             return $next($request);
         }

         $params = explode('/', $request->path());
         $locale = $request->cookie('locale', false);

         if (\count($params) > 0 && app('laravellocalization')->checkLocaleInSupportedLocales($params[0])) {
            return $next($request)->withCookie(cookie()->forever('locale', $params[0]));
         }

         if ($locale && app('laravellocalization')->checkLocaleInSupportedLocales($locale) && !(app('laravellocalization')->isHiddenDefault($locale))) {
           $redirection = app('laravellocalization')->getLocalizedURL($locale);
           $redirectResponse = new RedirectResponse($redirection, 302, ['Vary' => 'Accept-Language']);

           return $redirectResponse->withCookie(cookie()->forever('locale', $params[0]));
         }

         return $next($request);
     }
}


<?php

namespace Mcamara\LaravelLocalization\Commands;

use Mcamara\LaravelLocalization\LaravelLocalization;
use Mcamara\LaravelLocalization\Traits\TranslatedRouteCommandContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Console\RouteListCommand;
use Symfony\Component\Console\Input\InputArgument;

class RouteTranslationsListCommand extends RouteListCommand
{
    use TranslatedRouteCommandContext;

    /**
     * @var string
     */
    protected $name = 'route:trans:list';

    /**
     * @var string
     */
    protected $description = 'List all registered routes for specific locales';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        if (count($this->routes) == 0) {
            $this->error("Your application doesn't have any routes.");
            return;
        }

        $locale = $this->argument('locale');

        if ( ! $this->isSupportedLocale($locale)) {
            $this->error("Unsupported locale: '{$locale}'.");
            return;
        }

        $this->routes = $this->getFreshApplicationRoutes($locale);

        $this->displayRoutes($this->getRoutes());
    }

    /**
     * Boot a fresh copy of the application and get the routes.
     *
     * @param string $locale
     * @return \Illuminate\Routing\RouteCollection
     */
    protected function getFreshApplicationRoutes($locale)
    {
        $app = require $this->getBootstrapPath() . '/app.php';

        $key = LaravelLocalization::ENV_ROUTE_KEY;

        putenv("{$key}={$locale}");

        $app->make(Kernel::class)->bootstrap();

        putenv("{$key}=");

        return $app['router']->getRoutes();
    }

    /**
     * Get the console command arguments.
     *
     * @return array
     */
    protected function getArguments()
    {
        return [
            ['locale', InputArgument::REQUIRED, 'The locale to list routes for.'],
        ];
    }
}


<?php

namespace Mcamara\LaravelLocalization\Middleware;

use Closure;

class LaravelLocalizationRoutes extends LaravelLocalizationMiddlewareBase
{
    /**
     * Handle an incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Closure                 $next
     *
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // If the URL of the request is in exceptions.
        if ($this->shouldIgnore($request)) {
            return $next($request);
        }

        $app = app();

        $routeName = $app['laravellocalization']->getRouteNameFromAPath($request->getUri());

        $app['laravellocalization']->setRouteName($routeName);

        return $next($request);
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception\NotSupported;
use Kafka\Exception\Protocol as ProtocolException;
use function substr;

class Heartbeat extends Protocol
{
    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    public function encode(array $payloads = []): string
    {
        if (! isset($payloads['group_id'])) {
            throw new ProtocolException('given heartbeat data invalid. `group_id` is undefined.');
        }

        if (! isset($payloads['generation_id'])) {
            throw new ProtocolException('given heartbeat data invalid. `generation_id` is undefined.');
        }

        if (! isset($payloads['member_id'])) {
            throw new ProtocolException('given heartbeat data invalid. `member_id` is undefined.');
        }

        $header = $this->requestHeader('kafka-php', self::HEART_BEAT_REQUEST, self::HEART_BEAT_REQUEST);
        $data   = self::encodeString($payloads['group_id'], self::PACK_INT16);
        $data  .= self::pack(self::BIT_B32, (string) $payloads['generation_id']);
        $data  .= self::encodeString($payloads['member_id'], self::PACK_INT16);

        $data = self::encodeString($header . $data, self::PACK_INT32);

        return $data;
    }

    /**
     * @return mixed[]
     */
    public function decode(string $data): array
    {
        $offset    = 0;
        $errorCode = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset   += 2;

        return ['errorCode' => $errorCode];
    }
}


<?php
declare(strict_types=1);

namespace Kafka;

use Psr\Log\LogLevel;
use Psr\Log\NullLogger;

trait LoggerTrait
{
    /**
     * System is unusable.
     *
     * @param mixed[] $context
     *
     */
    public function emergency(string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    /**
     * Action must be taken immediately.
     *
     * Example: Entire website down, database unavailable, etc. This should
     * trigger the SMS alerts and wake you up.
     *
     * @param mixed[] $context
     *
     */
    public function alert(string $message, array $context = []): void
    {
        $this->log(LogLevel::ALERT, $message, $context);
    }

    /**
     * Critical conditions.
     *
     * Example: Application component unavailable, unexpected exception.
     *
     * @param mixed[] $context
     *
     */
    public function critical(string $message, array $context = []): void
    {
        $this->log(LogLevel::CRITICAL, $message, $context);
    }

    /**
     * Runtime errors that do not require immediate action but should typically
     * be logged and monitored.
     *
     * @param mixed[] $context
     *
     */
    public function error(string $message, array $context = []): void
    {
        $this->log(LogLevel::ERROR, $message, $context);
    }

    /**
     * Exceptional occurrences that are not errors.
     *
     * Example: Use of deprecated APIs, poor use of an API, undesirable things
     * that are not necessarily wrong.
     *
     * @param mixed[] $context
     *
     */
    public function warning(string $message, array $context = []): void
    {
        $this->log(LogLevel::WARNING, $message, $context);
    }

    /**
     * Normal but significant events.
     *
     * @param mixed[] $context
     *
     */
    public function notice(string $message, array $context = []): void
    {
        $this->log(LogLevel::NOTICE, $message, $context);
    }

    /**
     * Interesting events.
     *
     * Example: User logs in, SQL logs.
     *
     * @param mixed[] $context
     *
     */
    public function info(string $message, array $context = []): void
    {
        $this->log(LogLevel::INFO, $message, $context);
    }

    /**
     * Detailed debug information.
     *
     * @param mixed[] $context
     *
     */
    public function debug(string $message, array $context = []): void
    {
        $this->log(LogLevel::DEBUG, $message, $context);
    }

    /**
     * Logs with an arbitrary level.
     *
     * @param mixed   $level
     * @param string  $message
     * @param mixed[] $context
     *
     */
    public function log($level, $message, array $context = []): void
    {
        if ($this->logger === null) {
            $this->logger = new NullLogger();
        }
        $this->logger->log($level, $message, $context);
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Producer;

use Amp\Loop;
use Kafka\Broker;
use Kafka\Exception;
use Kafka\LoggerTrait;
use Kafka\ProducerConfig;
use Kafka\Protocol;
use Psr\Log\LoggerAwareTrait;
use function array_keys;
use function count;
use function explode;
use function in_array;
use function json_encode;
use function shuffle;
use function sprintf;
use function substr;
use function trim;

class Process
{
    use LoggerAwareTrait;
    use LoggerTrait;

    /**
     * @var callable|null
     */
    protected $producer;

    /**
     * @var bool
     */
    protected $isRunning = true;

    /**
     * @var callable|null
     */
    protected $success;

    /**
     * @var callable|null
     */
    protected $error;

    /**
     * @var State
     */
    private $state;

    /** @var RecordValidator */
    private $recordValidator;

    public function __construct(?callable $producer = null, ?RecordValidator $recordValidator = null)
    {
        $this->producer        = $producer;
        $this->recordValidator = $recordValidator ?? new RecordValidator();
    }

    public function init(): void
    {
        $config = $this->getConfig();
        Protocol::init($config->getBrokerVersion(), $this->logger);

        $broker = $this->getBroker();
        $broker->setConfig($config);
        $broker->setProcess(
            function (string $data, int $fd): void {
                $this->processRequest($data, $fd);
            }
        );

        $this->state = State::getInstance();

        if ($this->logger) {
            $this->state->setLogger($this->logger);
        }

        $this->state->setCallback(
            [
                State::REQUEST_METADATA => [$this, 'syncMeta'],
                State::REQUEST_PRODUCE  => [$this, 'produce'],
            ]
        );

        $this->state->init();

        if (! empty($broker->getTopics())) {
            $this->state->succRun(State::REQUEST_METADATA);
        }
    }

    public function start(): void
    {
        $this->init();
        $this->state->start();

        $config = $this->getConfig();

        if ($config->getIsAsyn()) {
            return;
        }

        Loop::repeat(
            $config->getRequestTimeout(),
            function (string $watcherId): void {
                if ($this->error !== null) {
                    ($this->error)(1000);
                }

                Loop::cancel($watcherId);
                Loop::stop();
            }
        );
    }

    public function stop(): void
    {
        $this->isRunning = false;
    }

    public function setSuccess(callable $success): void
    {
        $this->success = $success;
    }

    public function setError(callable $error): void
    {
        $this->error = $error;
    }

    /**
     * @throws \Kafka\Exception
     */
    public function syncMeta(): void
    {
        $this->debug('Start sync metadata request');

        $brokerList = ProducerConfig::getInstance()->getMetadataBrokerList();
        $brokerHost = [];

        foreach (explode(',', $brokerList) as $key => $val) {
            if (trim($val)) {
                $brokerHost[] = $val;
            }
        }

        if (count($brokerHost) === 0) {
            throw new Exception('No valid broker configured');
        }

        shuffle($brokerHost);
        $broker = $this->getBroker();

        foreach ($brokerHost as $host) {
            $socket = $broker->getMetaConnect($host);

            if ($socket !== null) {
                $params = [];
                $this->debug('Start sync metadata request params:' . json_encode($params));
                $requestData = Protocol::encode(Protocol::METADATA_REQUEST, $params);
                $socket->write($requestData);

                return;
            }
        }

        throw Exception\ConnectionException::fromBrokerList($brokerList);
    }

    /**
     * process Request
     *
     * @throws \Kafka\Exception
     */
    protected function processRequest(string $data, int $fd): void
    {
        $correlationId = Protocol\Protocol::unpack(Protocol\Protocol::BIT_B32, substr($data, 0, 4));
        switch ($correlationId) {
            case Protocol::METADATA_REQUEST:
                $result = Protocol::decode(Protocol::METADATA_REQUEST, substr($data, 4));

                if (! isset($result['brokers'], $result['topics'])) {
                    $this->error('Get metadata is fail, brokers or topics is null.');
                    $this->state->failRun(State::REQUEST_METADATA);
                    break;
                }

                $broker   = $this->getBroker();
                $isChange = $broker->setData($result['topics'], $result['brokers']);
                $this->state->succRun(State::REQUEST_METADATA, $isChange);

                break;
            case Protocol::PRODUCE_REQUEST:
                $result = Protocol::decode(Protocol::PRODUCE_REQUEST, substr($data, 4));
                $this->succProduce($result, $fd);
                break;
            default:
                $this->error('Error request, correlationId:' . $correlationId);
        }
    }

    /**
     * @return int[]
     */
    public function produce(): array
    {
        $context = [];
        $broker  = $this->getBroker();
        $config  = $this->getConfig();

        $requiredAck = $config->getRequiredAck();
        $timeout     = $config->getTimeout();
        $compression = $config->getCompression();

        // get send message
        // data struct
        //  topic:
        //  partId:
        //  key:
        //  value:
        $data = ($this->producer)();

        if (empty($data)) {
            return $context;
        }

        $sendData = $this->convertRecordSet($data);

        foreach ($sendData as $brokerId => $topicList) {
            $connect = $broker->getDataConnect((string) $brokerId);

            if ($connect === null) {
                return $context;
            }

            $params = [
                'required_ack' => $requiredAck,
                'timeout'      => $timeout,
                'data'         => $topicList,
                'compression'  => $compression,
            ];

            $this->debug('Send message start, params:' . json_encode($params));
            $requestData = Protocol::encode(Protocol::PRODUCE_REQUEST, $params);

            if ($requiredAck === 0) { // If it is 0 the server will not send any response
                $this->state->succRun(State::REQUEST_PRODUCE);
            } else {
                $connect->write($requestData);
                $context[] = (int) $connect->getSocket();
            }
        }

        return $context;
    }

    /**
     * @param mixed[] $result
     */
    protected function succProduce(array $result, int $fd): void
    {
        $msg = sprintf('Send message sucess, result: %s', json_encode($result));
        $this->debug($msg);

        if ($this->success !== null) {
            ($this->success)($result);
        }

        $this->state->succRun(State::REQUEST_PRODUCE, $fd);
    }

    protected function stateConvert(int $errorCode): bool
    {
        $this->error(Protocol::getError($errorCode));

        if ($this->error !== null) {
            ($this->error)($errorCode);
        }

        $recoverCodes = [
            Protocol::UNKNOWN_TOPIC_OR_PARTITION,
            Protocol::INVALID_REQUIRED_ACKS,
            Protocol::RECORD_LIST_TOO_LARGE,
            Protocol::NOT_ENOUGH_REPLICAS_AFTER_APPEND,
            Protocol::NOT_ENOUGH_REPLICAS,
            Protocol::NOT_LEADER_FOR_PARTITION,
            Protocol::BROKER_NOT_AVAILABLE,
            Protocol::GROUP_LOAD_IN_PROGRESS,
            Protocol::GROUP_COORDINATOR_NOT_AVAILABLE,
            Protocol::NOT_COORDINATOR_FOR_GROUP,
            Protocol::INVALID_TOPIC,
            Protocol::INCONSISTENT_GROUP_PROTOCOL,
            Protocol::INVALID_GROUP_ID,
        ];

        if (in_array($errorCode, $recoverCodes, true)) {
            $this->state->recover();

            return false;
        }

        return true;
    }

    /**
     * @param mixed[] $recordSet
     *
     * @return mixed[]
     */
    protected function convertRecordSet(array $recordSet): array
    {
        $sendData = [];
        $broker   = $this->getBroker();
        $topics   = $broker->getTopics();

        foreach ($recordSet as $record) {
            $this->recordValidator->validate($record, $topics);

            $topicMeta = $topics[$record['topic']];
            $partNums  = array_keys($topicMeta);
            shuffle($partNums);

            $partId = ! isset($record['partId'], $topicMeta[$record['partId']]) ? $partNums[0] : $record['partId'];

            $brokerId  = $topicMeta[$partId];
            $topicData = [];
            if (isset($sendData[$brokerId][$record['topic']])) {
                $topicData = $sendData[$brokerId][$record['topic']];
            }

            $partition = [];
            if (isset($topicData['partitions'][$partId])) {
                $partition = $topicData['partitions'][$partId];
            }

            $partition['partition_id'] = $partId;
            if (trim($record['key'] ?? '') !== '') {
                $partition['messages'][] = ['value' => $record['value'], 'key' => $record['key']];
            } else {
                $partition['messages'][] = $record['value'];
            }

            $topicData['partitions'][$partId]      = $partition;
            $topicData['topic_name']               = $record['topic'];
            $sendData[$brokerId][$record['topic']] = $topicData;
        }

        return $sendData;
    }

    private function getConfig(): ProducerConfig
    {
        return ProducerConfig::getInstance();
    }

    private function getBroker(): Broker
    {
        return Broker::getInstance();
    }
}


<?php
declare(strict_types=1);

namespace Kafka;

use Amp\Loop;
use Kafka\Protocol\Protocol;
use function fclose;
use function feof;
use function fread;
use function fwrite;
use function is_resource;
use function stream_set_blocking;
use function stream_set_read_buffer;
use function strlen;
use function substr;

class Socket extends CommonSocket
{
    /**
     * @var string
     */
    private $readWatcherId = '';

    /**
     * @var string
     */
    private $writeWatcherId = '';

    /**
     * @var string
     */
    private $writeBuffer = '';

    /**
     * @var string
     */
    private $readBuffer = '';

    /**
     * @var int
     */
    private $readNeedLength = 0;

    /**
     * @var callable|null
     */
    private $onReadable;

    public function connect(): void
    {
        if (! $this->isSocketDead()) {
            return;
        }

        $this->createStream();

        stream_set_blocking($this->stream, false);
        stream_set_read_buffer($this->stream, 0);

        $this->readWatcherId = Loop::onReadable(
            $this->stream,
            function (): void {
                do {
                    if ($this->isSocketDead()) {
                        $this->reconnect();
                        return;
                    }

                    $newData = @fread($this->stream, self::READ_MAX_LENGTH);

                    if ($newData) {
                        $this->read($newData);
                    }
                } while ($newData);
            }
        );

        $this->writeWatcherId = Loop::onWritable(
            $this->stream,
            function (): void {
                $this->write();
            },
            ['enable' => false] // <-- let's initialize the watcher as "disabled"
        );
    }

    public function reconnect(): void
    {
        $this->close();
        $this->connect();
    }

    public function setOnReadable(callable $read): void
    {
        $this->onReadable = $read;
    }

    public function close(): void
    {
        Loop::cancel($this->readWatcherId);
        Loop::cancel($this->writeWatcherId);

        if (is_resource($this->stream)) {
            fclose($this->stream);
        }

        $this->readBuffer     = '';
        $this->writeBuffer    = '';
        $this->readNeedLength = 0;
    }

    public function isResource(): bool
    {
        return is_resource($this->stream);
    }

    /**
     * Read from the socket at most $len bytes.
     *
     * This method will not wait for all the requested data, it will return as
     * soon as any data is received.
     *
     * @param string|int $data
     */
    public function read($data): void
    {
        $this->readBuffer .= (string) $data;

        do {
            if ($this->readNeedLength === 0) { // response start
                if (strlen($this->readBuffer) < 4) {
                    return;
                }

                $dataLen              = Protocol::unpack(Protocol::BIT_B32, substr($this->readBuffer, 0, 4));
                $this->readNeedLength = $dataLen;
                $this->readBuffer     = substr($this->readBuffer, 4);
            }

            if (strlen($this->readBuffer) < $this->readNeedLength) {
                return;
            }

            $data = (string) substr($this->readBuffer, 0, $this->readNeedLength);

            $this->readBuffer     = substr($this->readBuffer, $this->readNeedLength);
            $this->readNeedLength = 0;

            ($this->onReadable)($data, (int) $this->stream);
        } while (strlen($this->readBuffer));
    }

    /**
     * Write to the socket.
     *
     * @throws Loop\InvalidWatcherError
     */
    public function write(?string $data = null): void
    {
        if ($data !== null) {
            $this->writeBuffer .= $data;
        }

        $bytesToWrite = strlen($this->writeBuffer);
        $bytesWritten = @fwrite($this->stream, $this->writeBuffer);

        if ($bytesToWrite === $bytesWritten) {
            Loop::disable($this->writeWatcherId);
        } elseif ($bytesWritten >= 0) {
            Loop::enable($this->writeWatcherId);
        } elseif ($this->isSocketDead()) {
            $this->reconnect();
        }

        $this->writeBuffer = substr($this->writeBuffer, $bytesWritten);
    }

    /**
     * check the stream is close
     */
    protected function isSocketDead(): bool
    {
        return ! is_resource($this->stream) || @feof($this->stream);
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception\NotSupported;
use Kafka\Exception\Protocol as ProtocolException;
use Lcobucci\Clock\Clock;
use Lcobucci\Clock\SystemClock;
use function crc32;
use function is_array;
use function substr;

class Produce extends Protocol
{
    /**
     * Specifies the mask for the compression code. 3 bits to hold the compression codec.
     * 0 is reserved to indicate no compression
     */
    public const COMPRESSION_CODEC_MASK = 0x07;

    /**
     * Specify the mask of timestamp type: 0 for CreateTime, 1 for LogAppendTime.
     */
    private const TIMESTAMP_TYPE_MASK = 0x08;

    private const TIMESTAMP_NONE            = -1;
    private const TIMESTAMP_CREATE_TIME     = 0;
    private const TIMESTAMP_LOG_APPEND_TIME = 1;

    /**
     * @var Clock
     */
    private $clock;

    public function __construct(string $version = self::DEFAULT_BROKER_VERION, ?Clock $clock = null)
    {
        parent::__construct($version);

        $this->clock = $clock ?: new SystemClock();
    }

    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    public function encode(array $payloads = []): string
    {
        if (! isset($payloads['data'])) {
            throw new ProtocolException('given procude data invalid. `data` is undefined.');
        }

        $header = $this->requestHeader('kafka-php', 0, self::PRODUCE_REQUEST);
        $data   = self::pack(self::BIT_B16, (string) ($payloads['required_ack'] ?? 0));
        $data  .= self::pack(self::BIT_B32, (string) ($payloads['timeout'] ?? 100));
        $data  .= self::encodeArray(
            $payloads['data'],
            [$this, 'encodeProduceTopic'],
            $payloads['compression'] ?? self::COMPRESSION_NONE
        );

        return self::encodeString($header . $data, self::PACK_INT32);
    }

    /**
     * @return mixed[]
     *
     * @throws ProtocolException
     */
    public function decode(string $data): array
    {
        $offset       = 0;
        $version      = $this->getApiVersion(self::PRODUCE_REQUEST);
        $ret          = $this->decodeArray(substr($data, $offset), [$this, 'produceTopicPair'], $version);
        $offset      += $ret['length'];
        $throttleTime = 0;

        if ($version === self::API_VERSION2) {
            $throttleTime = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        }

        return ['throttleTime' => $throttleTime, 'data' => $ret['data']];
    }

    /**
     * encode message set
     * N.B., MessageSets are not preceded by an int32 like other array elements
     * in the protocol.
     *
     * @param string[]|string[][] $messages
     *
     * @throws NotSupported
     */
    protected function encodeMessageSet(array $messages, int $compression = self::COMPRESSION_NONE): string
    {
        $data = '';
        $next = 0;

        foreach ($messages as $message) {
            $encodedMessage = $this->encodeMessage($message);

            $data .= self::pack(self::BIT_B64, (string) $next)
                   . self::encodeString($encodedMessage, self::PACK_INT32);

            ++$next;
        }

        if ($compression === self::COMPRESSION_NONE) {
            return $data;
        }

        return self::pack(self::BIT_B64, '0')
             . self::encodeString($this->encodeMessage($data, $compression), self::PACK_INT32);
    }

    /**
     * @param string[]|string $message
     *
     * @throws NotSupported
     */
    protected function encodeMessage($message, int $compression = self::COMPRESSION_NONE): string
    {
        $magic      = $this->computeMagicByte();
        $attributes = $this->computeAttributes($magic, $compression, $this->computeTimestampType($magic));

        $data  = self::pack(self::BIT_B8, (string) $magic);
        $data .= self::pack(self::BIT_B8, (string) $attributes);

        if ($magic >= self::MESSAGE_MAGIC_VERSION1) {
            $data .= self::pack(self::BIT_B64, $this->clock->now()->format('Uv'));
        }

        $key = '';

        if (is_array($message)) {
            $key     = $message['key'];
            $message = $message['value'];
        }

        // message key
        $data .= self::encodeString($key, self::PACK_INT32);

        // message value
        $data .= self::encodeString($message, self::PACK_INT32, $compression);

        $crc = (string) crc32($data);

        // int32 -- crc code  string data
        $message = self::pack(self::BIT_B32, $crc) . $data;

        return $message;
    }

    private function computeMagicByte(): int
    {
        if ($this->getApiVersion(self::PRODUCE_REQUEST) === self::API_VERSION2) {
            return self::MESSAGE_MAGIC_VERSION1;
        }

        return self::MESSAGE_MAGIC_VERSION0;
    }

    public function computeTimestampType(int $magic): int
    {
        if ($magic === self::MESSAGE_MAGIC_VERSION0) {
            return self::TIMESTAMP_NONE;
        }

        return self::TIMESTAMP_CREATE_TIME;
    }

    private function computeAttributes(int $magic, int $compression, int $timestampType): int
    {
        $attributes = 0;

        if ($compression !== self::COMPRESSION_NONE) {
            $attributes |= self::COMPRESSION_CODEC_MASK & $compression;
        }

        if ($magic === self::MESSAGE_MAGIC_VERSION0) {
            return $attributes;
        }

        if ($timestampType === self::TIMESTAMP_LOG_APPEND_TIME) {
            $attributes |= self::TIMESTAMP_TYPE_MASK;
        }

        return $attributes;
    }

    /**
     * encode signal part
     *
     * @param mixed[] $values
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    protected function encodeProducePartition(array $values, int $compression): string
    {
        if (! isset($values['partition_id'])) {
            throw new ProtocolException('given produce data invalid. `partition_id` is undefined.');
        }

        if (! isset($values['messages']) || empty($values['messages'])) {
            throw new ProtocolException('given produce data invalid. `messages` is undefined.');
        }

        $data  = self::pack(self::BIT_B32, (string) $values['partition_id']);
        $data .= self::encodeString(
            $this->encodeMessageSet((array) $values['messages'], $compression),
            self::PACK_INT32
        );

        return $data;
    }

    /**
     * encode signal topic
     *
     * @param mixed[] $values
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    protected function encodeProduceTopic(array $values, int $compression): string
    {
        if (! isset($values['topic_name'])) {
            throw new ProtocolException('given produce data invalid. `topic_name` is undefined.');
        }

        if (! isset($values['partitions']) || empty($values['partitions'])) {
            throw new ProtocolException('given produce data invalid. `partitions` is undefined.');
        }

        $topic      = self::encodeString($values['topic_name'], self::PACK_INT16);
        $partitions = self::encodeArray($values['partitions'], [$this, 'encodeProducePartition'], $compression);

        return $topic . $partitions;
    }

    /**
     * decode produce topic pair response
     *
     * @return mixed[]
     *
     * @throws ProtocolException
     */
    protected function produceTopicPair(string $data, int $version): array
    {
        $offset    = 0;
        $topicInfo = $this->decodeString($data, self::BIT_B16);
        $offset   += $topicInfo['length'];
        $ret       = $this->decodeArray(substr($data, $offset), [$this, 'producePartitionPair'], $version);
        $offset   += $ret['length'];

        return [
            'length' => $offset,
            'data'   => [
                'topicName'  => $topicInfo['data'],
                'partitions' => $ret['data'],
            ],
        ];
    }

    /**
     * decode produce partition pair response
     *
     * @return mixed[]
     *
     * @throws ProtocolException
     */
    protected function producePartitionPair(string $data, int $version): array
    {
        $offset          = 0;
        $partitionId     = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset         += 4;
        $errorCode       = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset         += 2;
        $partitionOffset = self::unpack(self::BIT_B64, substr($data, $offset, 8));
        $offset         += 8;
        $timestamp       = 0;

        if ($version === self::API_VERSION2) {
            $timestamp = self::unpack(self::BIT_B64, substr($data, $offset, 8));
            $offset   += 8;
        }

        return [
            'length' => $offset,
            'data'   => [
                'partition' => $partitionId,
                'errorCode' => $errorCode,
                'offset'    => $offset,
                'timestamp' => $timestamp,
            ],
        ];
    }
}


<?php
declare(strict_types=1);

namespace Kafka;

use Amp\Loop;
use Kafka\Producer\Process;
use Kafka\Producer\SyncProcess;
use Psr\Log\LoggerAwareTrait;
use function is_array;

class Producer
{
    use LoggerAwareTrait;
    use LoggerTrait;

    /**
     * @var Process|SyncProcess
     */
    private $process;

    public function __construct(?callable $producer = null)
    {
        $this->process = $producer === null ? new SyncProcess() : new Process($producer);
    }

    /**
     * @param mixed[]|bool $data
     *
     * @return mixed[]|null
     *
     * @throws \Kafka\Exception
     */
    public function send($data = true): ?array
    {
        if ($this->logger) {
            $this->process->setLogger($this->logger);
        }

        if (is_array($data)) {
            return $this->sendSynchronously($data);
        }

        $this->sendAsynchronously($data);

        return null;
    }

    /**
     * @param mixed[] $data
     *
     * @return mixed[]
     *
     * @throws \Kafka\Exception
     */
    private function sendSynchronously(array $data): array
    {
        if (! $this->process instanceof SyncProcess) {
            throw new Exception('An asynchronous process is not able to send messages synchronously');
        }

        return $this->process->send($data);
    }

    /**
     * @throws \Kafka\Exception
     */
    private function sendAsynchronously(bool $startLoop): void
    {
        if ($this->process instanceof SyncProcess) {
            throw new Exception('A synchronous process is not able to send messages asynchronously');
        }

        $this->process->start();

        if ($startLoop) {
            Loop::run();
        }
    }

    public function syncMeta(): void
    {
        $this->process->syncMeta();
    }

    public function success(callable $success): void
    {
        if ($this->process instanceof SyncProcess) {
            throw new Exception('Success callback can only be configured for asynchronous process');
        }

        $this->process->setSuccess($success);
    }

    public function error(callable $error): void
    {
        if ($this->process instanceof SyncProcess) {
            throw new Exception('Error callback can only be configured for asynchronous process');
        }

        $this->process->setError($error);
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception\NotSupported;
use Kafka\Exception\Protocol as ProtocolException;
use function substr;

class FetchOffset extends Protocol
{
    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    public function encode(array $payloads = []): string
    {
        if (! isset($payloads['data'])) {
            throw new ProtocolException('given fetch offset data invalid. `data` is undefined.');
        }

        if (! isset($payloads['group_id'])) {
            throw new ProtocolException('given fetch offset data invalid. `group_id` is undefined.');
        }

        $header = $this->requestHeader('kafka-php', self::OFFSET_FETCH_REQUEST, self::OFFSET_FETCH_REQUEST);
        $data   = self::encodeString($payloads['group_id'], self::PACK_INT16);
        $data  .= self::encodeArray($payloads['data'], [$this, 'encodeOffsetTopic']);
        $data   = self::encodeString($header . $data, self::PACK_INT32);

        return $data;
    }

    /**
     * @return mixed[]
     */
    public function decode(string $data): array
    {
        $offset  = 0;
        $topics  = $this->decodeArray(substr($data, $offset), [$this, 'offsetTopic']);
        $offset += $topics['length'];

        return $topics['data'];
    }

    protected function encodeOffsetPartition(int $values): string
    {
        return self::pack(self::BIT_B32, (string) $values);
    }

    /**
     * @param mixed[] $values
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    protected function encodeOffsetTopic(array $values): string
    {
        if (! isset($values['topic_name'])) {
            throw new ProtocolException('given fetch offset data invalid. `topic_name` is undefined.');
        }

        if (! isset($values['partitions']) || empty($values['partitions'])) {
            throw new ProtocolException('given fetch offset data invalid. `partitions` is undefined.');
        }

        $topic      = self::encodeString($values['topic_name'], self::PACK_INT16);
        $partitions = self::encodeArray($values['partitions'], [$this, 'encodeOffsetPartition']);

        return $topic . $partitions;
    }

    /**
     * @return mixed[]
     * @throws ProtocolException
     */
    protected function offsetTopic(string $data): array
    {
        $offset    = 0;
        $topicInfo = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset   += $topicInfo['length'];

        $partitions = $this->decodeArray(substr($data, $offset), [$this, 'offsetPartition']);
        $offset    += $partitions['length'];

        return [
            'length' => $offset,
            'data'   => [
                'topicName'  => $topicInfo['data'],
                'partitions' => $partitions['data'],
            ],
        ];
    }

    /**
     * @return mixed[]
     * @throws ProtocolException
     */
    protected function offsetPartition(string $data): array
    {
        $offset = 0;

        $partitionId = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset     += 4;

        $roffset = self::unpack(self::BIT_B64, substr($data, $offset, 8));
        $offset += 8;

        $metadata  = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset   += $metadata['length'];
        $errorCode = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset   += 2;

        return [
            'length' => $offset,
            'data'   => [
                'partition' => $partitionId,
                'errorCode' => $errorCode,
                'metadata'  => $metadata['data'],
                'offset'    => $roffset,
            ],
        ];
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception\NotSupported;
use function substr;

class DescribeGroups extends Protocol
{
    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     */
    public function encode(array $payloads = []): string
    {
        $header = $this->requestHeader('kafka-php', self::DESCRIBE_GROUPS_REQUEST, self::DESCRIBE_GROUPS_REQUEST);
        $data   = self::encodeArray($payloads, [$this, 'encodeString'], self::PACK_INT16);

        return self::encodeString($header . $data, self::PACK_INT32);
    }

    /**
     * @return mixed[]
     */
    public function decode(string $data): array
    {
        $offset  = 0;
        $groups  = $this->decodeArray(substr($data, $offset), [$this, 'describeGroup']);
        $offset += $groups['length'];

        return $groups['data'];
    }

    /**
     * @return mixed[]
     */
    protected function describeGroup(string $data): array
    {
        $offset       = 0;
        $errorCode    = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset      += 2;
        $groupId      = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset      += $groupId['length'];
        $state        = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset      += $state['length'];
        $protocolType = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset      += $protocolType['length'];
        $protocol     = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset      += $protocol['length'];

        $members = $this->decodeArray(substr($data, $offset), [$this, 'describeMember']);
        $offset += $members['length'];

        return [
            'length' => $offset,
            'data'   => [
                'errorCode'    => $errorCode,
                'groupId'      => $groupId['data'],
                'state'        => $state['data'],
                'protocolType' => $protocolType['data'],
                'protocol'     => $protocol['data'],
                'members'      => $members['data'],
            ],
        ];
    }

    /**
     * @return mixed[]
     */
    protected function describeMember(string $data): array
    {
        $offset     = 0;
        $memberId   = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset    += $memberId['length'];
        $clientId   = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset    += $clientId['length'];
        $clientHost = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset    += $clientHost['length'];
        $metadata   = $this->decodeString(substr($data, $offset), self::BIT_B32);
        $offset    += $metadata['length'];
        $assignment = $this->decodeString(substr($data, $offset), self::BIT_B32);
        $offset    += $assignment['length'];

        $memberAssignment        = $assignment['data'];
        $memberAssignmentOffset  = 0;
        $version                 = self::unpack(self::BIT_B16_SIGNED, substr($memberAssignment, $memberAssignmentOffset, 2));
        $memberAssignmentOffset += 2;
        $partitionAssignments    = $this->decodeArray(substr($memberAssignment, $memberAssignmentOffset), [$this, 'describeResponsePartition']);
        $memberAssignmentOffset += $partitionAssignments['length'];
        $userData                = $this->decodeString(substr($memberAssignment, $memberAssignmentOffset), self::BIT_B32);

        $metaData     = $metadata['data'];
        $metaOffset   = 0;
        $version      = self::unpack(self::BIT_B16, substr($metaData, $metaOffset, 2));
        $metaOffset  += 2;
        $topics       = $this->decodeArray(substr($metaData, $metaOffset), [$this, 'decodeString'], self::BIT_B16);
        $metaOffset  += $topics['length'];
        $metaUserData = $this->decodeString(substr($metaData, $metaOffset), self::BIT_B32);

        return [
            'length' => $offset,
            'data'   => [
                'memberId'   => $memberId['data'],
                'clientId'   => $clientId['data'],
                'clientHost' => $clientHost['data'],
                'metadata'   => [
                    'version'  => $version,
                    'topics'   => $topics['data'],
                    'userData' => $metaUserData['data'],
                ],
                'assignment' => [
                    'version'    => $version,
                    'partitions' => $partitionAssignments['data'],
                    'userData'   => $userData['data'],
                ],
            ],
        ];
    }

    /**
     * @return mixed[]
     */
    protected function describeResponsePartition(string $data): array
    {
        $offset     = 0;
        $topicName  = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset    += $topicName['length'];
        $partitions = $this->decodePrimitiveArray(substr($data, $offset), self::BIT_B32);
        $offset    += $partitions['length'];

        return [
            'length' => $offset,
            'data'   => [
                'topicName'  => $topicName['data'],
                'partitions' => $partitions['data'],
            ],
        ];
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Producer;

use Kafka\Broker;
use Kafka\Exception;
use Kafka\LoggerTrait;
use Kafka\ProducerConfig;
use Kafka\Protocol\Protocol;
use Psr\Log\LoggerAwareTrait;
use function array_keys;
use function count;
use function explode;
use function json_encode;
use function shuffle;
use function substr;
use function trim;

class SyncProcess
{
    use LoggerAwareTrait;
    use LoggerTrait;

    /** @var RecordValidator */
    private $recordValidator;

    public function __construct(?RecordValidator $recordValidator = null)
    {
        $this->recordValidator = $recordValidator ?? new RecordValidator();

        $config = $this->getConfig();
        \Kafka\Protocol::init($config->getBrokerVersion(), $this->logger);

        $broker = $this->getBroker();
        $broker->setConfig($config);

        $this->syncMeta();
    }

    /**
     * @param mixed[][] $recordSet
     *
     * @return mixed[]
     *
     * @throws \Kafka\Exception
     */
    public function send(array $recordSet): array
    {
        $broker = $this->getBroker();
        $config = $this->getConfig();

        $requiredAck = $config->getRequiredAck();
        $timeout     = $config->getTimeout();
        $compression = $config->getCompression();

        // get send message
        // data struct
        //  topic:
        //  partId:
        //  key:
        //  value:
        if (empty($recordSet)) {
            return [];
        }

        $sendData = $this->convertRecordSet($recordSet);
        $result   = [];
        foreach ($sendData as $brokerId => $topicList) {
            $connect = $broker->getDataConnect((string) $brokerId, true);

            if ($connect === null) {
                return [];
            }

            $params = [
                'required_ack' => $requiredAck,
                'timeout'      => $timeout,
                'data'         => $topicList,
                'compression'  => $compression,
            ];

            $this->debug('Send message start, params:' . json_encode($params));
            $requestData = \Kafka\Protocol::encode(\Kafka\Protocol::PRODUCE_REQUEST, $params);
            $connect->write($requestData);

            if ($requiredAck !== 0) { // If it is 0 the server will not send any response
                $dataLen       = Protocol::unpack(Protocol::BIT_B32, $connect->read(4));
                $recordSet     = $connect->read($dataLen);
                $correlationId = Protocol::unpack(Protocol::BIT_B32, substr($recordSet, 0, 4));
                $ret           = \Kafka\Protocol::decode(\Kafka\Protocol::PRODUCE_REQUEST, substr($recordSet, 4));

                $result[] = $ret;
            }
        }

        return $result;
    }

    public function syncMeta(): void
    {
        $this->debug('Start sync metadata request');

        $brokerList = ProducerConfig::getInstance()->getMetadataBrokerList();
        $brokerHost = [];

        foreach (explode(',', $brokerList) as $key => $val) {
            if (trim($val)) {
                $brokerHost[] = $val;
            }
        }

        if (count($brokerHost) === 0) {
            throw new Exception('No valid broker configured');
        }

        shuffle($brokerHost);
        $broker = $this->getBroker();

        foreach ($brokerHost as $host) {
            $socket = $broker->getMetaConnect($host, true);

            if ($socket === null) {
                continue;
            }

            $params = [];
            $this->debug('Start sync metadata request params:' . json_encode($params));
            $requestData = \Kafka\Protocol::encode(\Kafka\Protocol::METADATA_REQUEST, $params);
            $socket->write($requestData);
            $dataLen       = Protocol::unpack(Protocol::BIT_B32, $socket->read(4));
            $data          = $socket->read($dataLen);
            $correlationId = Protocol::unpack(Protocol::BIT_B32, substr($data, 0, 4));
            $result        = \Kafka\Protocol::decode(\Kafka\Protocol::METADATA_REQUEST, substr($data, 4));

            if (! isset($result['brokers'], $result['topics'])) {
                throw new Exception('Get metadata is fail, brokers or topics is null.');
            }

            $broker = $this->getBroker();
            $broker->setData($result['topics'], $result['brokers']);

            return;
        }

        throw Exception\ConnectionException::fromBrokerList($brokerList);
    }

    /**
     * @param string[][] $recordSet
     *
     * @return mixed[]
     */
    protected function convertRecordSet(array $recordSet): array
    {
        $sendData = [];
        $broker   = $this->getBroker();
        $topics   = $broker->getTopics();

        foreach ($recordSet as $record) {
            $this->recordValidator->validate($record, $topics);

            $topicMeta = $topics[$record['topic']];
            $partNums  = array_keys($topicMeta);
            shuffle($partNums);

            $partId = isset($record['partId'], $topicMeta[$record['partId']]) ? $record['partId'] : $partNums[0];

            $brokerId  = $topicMeta[$partId];
            $topicData = [];
            if (isset($sendData[$brokerId][$record['topic']])) {
                $topicData = $sendData[$brokerId][$record['topic']];
            }

            $partition = [];
            if (isset($topicData['partitions'][$partId])) {
                $partition = $topicData['partitions'][$partId];
            }

            $partition['partition_id'] = $partId;

            if (trim($record['key'] ?? '') !== '') {
                $partition['messages'][] = ['value' => $record['value'], 'key' => $record['key']];
            } else {
                $partition['messages'][] = $record['value'];
            }

            $topicData['partitions'][$partId]      = $partition;
            $topicData['topic_name']               = $record['topic'];
            $sendData[$brokerId][$record['topic']] = $topicData;
        }

        return $sendData;
    }

    private function getBroker(): Broker
    {
        return Broker::getInstance();
    }

    private function getConfig(): ProducerConfig
    {
        return ProducerConfig::getInstance();
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception;
use Kafka\Exception\NotSupported;
use Kafka\Exception\Protocol as ProtocolException;
use function array_merge;
use function strlen;
use function substr;

class Fetch extends Protocol
{
    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    public function encode(array $payloads = []): string
    {
        if (! isset($payloads['data'])) {
            throw new ProtocolException('given fetch kafka data invalid. `data` is undefined.');
        }

        if (! isset($payloads['replica_id'])) {
            $payloads['replica_id'] = -1;
        }

        if (! isset($payloads['max_wait_time'])) {
            $payloads['max_wait_time'] = 100; // default timeout 100ms
        }

        if (! isset($payloads['min_bytes'])) {
            $payloads['min_bytes'] = 64 * 1024; // 64k
        }

        $header = $this->requestHeader('kafka-php', self::FETCH_REQUEST, self::FETCH_REQUEST);
        $data   = self::pack(self::BIT_B32, (string) $payloads['replica_id']);
        $data  .= self::pack(self::BIT_B32, (string) $payloads['max_wait_time']);
        $data  .= self::pack(self::BIT_B32, (string) $payloads['min_bytes']);
        $data  .= self::encodeArray($payloads['data'], [$this, 'encodeFetchTopic']);
        $data   = self::encodeString($header . $data, self::PACK_INT32);

        return $data;
    }

    /**
     * @return mixed[]
     */
    public function decode(string $data): array
    {
        $offset       = 0;
        $version      = $this->getApiVersion(self::FETCH_REQUEST);
        $throttleTime = 0;

        if ($version !== self::API_VERSION0) {
            $throttleTime = self::unpack(self::BIT_B32, substr($data, $offset, 4));
            $offset      += 4;
        }

        $topics  = $this->decodeArray(substr($data, $offset), [$this, 'fetchTopic']);
        $offset += $topics['length'];

        return [
            'throttleTime' => $throttleTime,
            'topics'       => $topics['data'],
        ];
    }

    /**
     * @return mixed[]
     */
    protected function fetchTopic(string $data): array
    {
        $offset    = 0;
        $topicInfo = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset   += $topicInfo['length'];

        $partitions = $this->decodeArray(substr($data, $offset), [$this, 'fetchPartition']);
        $offset    += $partitions['length'];

        return [
            'length' => $offset,
            'data'   => [
                'topicName'  => $topicInfo['data'],
                'partitions' => $partitions['data'],
            ],
        ];
    }

    /**
     * @return mixed[]
     *
     * @throws ProtocolException
     */
    protected function fetchPartition(string $data): array
    {
        $offset              = 0;
        $partitionId         = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset             += 4;
        $errorCode           = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset             += 2;
        $highwaterMarkOffset = self::unpack(self::BIT_B64, substr($data, $offset, 8));
        $offset             += 8;

        $messageSetSize = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset        += 4;

        $messages = [];

        if ($messageSetSize > 0 && $offset < strlen($data)) {
            $messages = $this->decodeMessageSetArray(substr($data, $offset, $messageSetSize), $messageSetSize);

            $offset += $messages['length'];
        }

        return [
            'length' => $offset,
            'data'   => [
                'partition'           => $partitionId,
                'errorCode'           => $errorCode,
                'highwaterMarkOffset' => $highwaterMarkOffset,
                'messageSetSize'      => $messageSetSize,
                'messages'            => $messages['data'] ?? [],
            ],
        ];
    }

    /**
     * @return mixed[]
     *
     * @throws ProtocolException
     */
    protected function decodeMessageSetArray(string $data, int $messageSetSize): array
    {
        $offset = 0;
        $result = [];

        while ($offset < strlen($data)) {
            $value = substr($data, $offset);
            $ret   = $this->decodeMessageSet($value);

            if ($ret === null) {
                break;
            }

            if (! isset($ret['length'], $ret['data'])) {
                throw new ProtocolException('Decode array failed, given function return format is invalid');
            }

            if ((int) $ret['length'] === 0) {
                continue;
            }

            $offset += $ret['length'];

            if (($ret['data']['message']['attr'] & Produce::COMPRESSION_CODEC_MASK) === Produce::COMPRESSION_NONE) {
                $result[] = $ret['data'];
                continue;
            }

            $innerMessages = $this->decodeMessageSetArray($ret['data']['message']['value'], $ret['length']);
            $result        = array_merge($result, $innerMessages['data']);
        }

        if ($offset < $messageSetSize) {
            $offset = $messageSetSize;
        }

        return ['length' => $offset, 'data' => $result];
    }

    /**
     * decode message set
     * N.B., MessageSets are not preceded by an int32 like other array elements
     * in the protocol.
     *
     * @return mixed[]|null
     */
    protected function decodeMessageSet(string $data): ?array
    {
        if (strlen($data) <= 12) {
            return null;
        }

        $offset      = 0;
        $roffset     = self::unpack(self::BIT_B64, substr($data, $offset, 8));
        $offset     += 8;
        $messageSize = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset     += 4;
        $ret         = $this->decodeMessage(substr($data, $offset), $messageSize);

        if ($ret === null) {
            return null;
        }

        $offset += $ret['length'];

        return [
            'length' => $offset,
            'data'   => [
                'offset'  => $roffset,
                'size'    => $messageSize,
                'message' => $ret['data'],
            ],
        ];
    }

    /**
     * decode message
     * N.B., MessageSets are not preceded by an int32 like other array elements
     * in the protocol.
     *
     * @return mixed[]|null
     */
    protected function decodeMessage(string $data, int $messageSize): ?array
    {
        if ($messageSize === 0 || strlen($data) < $messageSize) {
            return null;
        }

        $offset  = 0;
        $crc     = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset += 4;

        $magic = self::unpack(self::BIT_B8, substr($data, $offset, 1));
        ++$offset;

        $attr = self::unpack(self::BIT_B8, substr($data, $offset, 1));
        ++$offset;

        $timestamp  = 0;
        $backOffset = $offset;

        try { // try unpack message format v1, falling back to v0 if it fails
            if ($magic >= self::MESSAGE_MAGIC_VERSION1) {
                $timestamp = self::unpack(self::BIT_B64, substr($data, $offset, 8));
                $offset   += 8;
            }

            $keyRet  = $this->decodeString(substr($data, $offset), self::BIT_B32);
            $offset += $keyRet['length'];

            $valueRet = $this->decodeString((string) substr($data, $offset), self::BIT_B32, $attr & Produce::COMPRESSION_CODEC_MASK);
            $offset  += $valueRet['length'];

            if ($offset !== $messageSize) {
                throw new Exception(
                    'pack message fail, message len:' . $messageSize . ' , data unpack offset :' . $offset
                );
            }
        } catch (Exception $e) { // try unpack message format v0
            $offset    = $backOffset;
            $timestamp = 0;
            $keyRet    = $this->decodeString(substr($data, $offset), self::BIT_B32);
            $offset   += $keyRet['length'];

            $valueRet = $this->decodeString(substr($data, $offset), self::BIT_B32);
            $offset  += $valueRet['length'];
        }

        return [
            'length' => $offset,
            'data'   => [
                'crc'       => $crc,
                'magic'     => $magic,
                'attr'      => $attr,
                'timestamp' => $timestamp,
                'key'       => $keyRet['data'],
                'value'     => $valueRet['data'],
            ],
        ];
    }

    /**
     * @param mixed[] $values
     *
     * @throws ProtocolException
     */
    protected function encodeFetchPartition(array $values): string
    {
        if (! isset($values['partition_id'])) {
            throw new ProtocolException('given fetch data invalid. `partition_id` is undefined.');
        }

        if (! isset($values['offset'])) {
            $values['offset'] = 0;
        }

        if (! isset($values['max_bytes'])) {
            $values['max_bytes'] = 2 * 1024 * 1024;
        }

        $data  = self::pack(self::BIT_B32, (string) $values['partition_id']);
        $data .= self::pack(self::BIT_B64, (string) $values['offset']);
        $data .= self::pack(self::BIT_B32, (string) $values['max_bytes']);

        return $data;
    }

    /**
     * @param mixed[] $values
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    protected function encodeFetchTopic(array $values): string
    {
        if (! isset($values['topic_name'])) {
            throw new ProtocolException('given fetch data invalid. `topic_name` is undefined.');
        }

        if (! isset($values['partitions']) || empty($values['partitions'])) {
            throw new ProtocolException('given fetch data invalid. `partitions` is undefined.');
        }

        $topic      = self::encodeString($values['topic_name'], self::PACK_INT16);
        $partitions = self::encodeArray($values['partitions'], [$this, 'encodeFetchPartition']);

        return $topic . $partitions;
    }
}


<?php
declare(strict_types=1);

namespace Kafka\Protocol;

use Kafka\Exception\NotSupported;
use Kafka\Exception\Protocol as ProtocolException;
use function substr;

class JoinGroup extends Protocol
{
    /**
     * @param mixed[] $payloads
     *
     * @throws NotSupported
     * @throws ProtocolException
     */
    public function encode(array $payloads = []): string
    {
        if (! isset($payloads['group_id'])) {
            throw new ProtocolException('given join group data invalid. `group_id` is undefined.');
        }

        if (! isset($payloads['session_timeout'])) {
            throw new ProtocolException('given join group data invalid. `session_timeout` is undefined.');
        }

        if (! isset($payloads['member_id'])) {
            throw new ProtocolException('given join group data invalid. `member_id` is undefined.');
        }

        if (! isset($payloads['data'])) {
            throw new ProtocolException('given join group data invalid. `data` is undefined.');
        }

        if (! isset($payloads['protocol_type'])) {
            $payloads['protocol_type'] = 'consumer';
        }

        if (! isset($payloads['rebalance_timeout'])) {
            $payloads['rebalance_timeout'] = $payloads['session_timeout'];
        }

        $header = $this->requestHeader('kafka-php', self::JOIN_GROUP_REQUEST, self::JOIN_GROUP_REQUEST);
        $data   = self::encodeString($payloads['group_id'], self::PACK_INT16);
        $data  .= self::pack(self::BIT_B32, (string) $payloads['session_timeout']);

        if ($this->getApiVersion(self::JOIN_GROUP_REQUEST) === self::API_VERSION1) {
            $data .= self::pack(self::BIT_B32, (string) $payloads['rebalance_timeout']);
        }

        $data .= self::encodeString($payloads['member_id'], self::PACK_INT16);
        $data .= self::encodeString($payloads['protocol_type'], self::PACK_INT16);
        $data .= self::encodeArray($payloads['data'], [$this, 'encodeGroupProtocol']);
        $data  = self::encodeString($header . $data, self::PACK_INT32);

        return $data;
    }

    /**
     * @return mixed[]
     */
    public function decode(string $data): array
    {
        $offset        = 0;
        $errorCode     = self::unpack(self::BIT_B16_SIGNED, substr($data, $offset, 2));
        $offset       += 2;
        $generationId  = self::unpack(self::BIT_B32, substr($data, $offset, 4));
        $offset       += 4;
        $groupProtocol = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset       += $groupProtocol['length'];
        $leaderId      = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset       += $leaderId['length'];
        $memberId      = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset       += $memberId['length'];

        $members = $this->decodeArray(substr($data, $offset), [$this, 'joinGroupMember']);
        $offset += $memberId['length'];

        return [
            'errorCode'     => $errorCode,
            'generationId'  => $generationId,
            'groupProtocol' => $groupProtocol['data'],
            'leaderId'      => $leaderId['data'],
            'memberId'      => $memberId['data'],
            'members'       => $members['data'],
        ];
    }

    /**
     * @param mixed[] $values
     *
     * @throws ProtocolException
     * @throws NotSupported
     */
    protected function encodeGroupProtocol(array $values): string
    {
        if (! isset($values['protocol_name'])) {
            throw new ProtocolException('given join group data invalid. `protocol_name` is undefined.');
        }

        $protocolName = self::encodeString($values['protocol_name'], self::PACK_INT16);

        if (! isset($values['version'])) {
            throw new ProtocolException('given data invalid. `version` is undefined.');
        }

        if (! isset($values['subscription']) || empty($values['subscription'])) {
            throw new ProtocolException('given data invalid. `subscription` is undefined.');
        }
        if (! isset($values['user_data'])) {
            $values['user_data'] = '';
        }

        $data  = self::pack(self::BIT_B16, '0');
        $data .= self::encodeArray($values['subscription'], [$this, 'encodeGroupProtocolMetaTopic']);
        $data .= self::encodeString($values['user_data'], self::PACK_INT32);

        return $protocolName . self::encodeString($data, self::PACK_INT32);
    }

    /**
     * @throws NotSupported
     */
    protected function encodeGroupProtocolMetaTopic(string $values): string
    {
        return self::encodeString($values, self::PACK_INT16);
    }

    /**
     * @return mixed[]
     */
    protected function joinGroupMember(string $data): array
    {
        $offset     = 0;
        $memberId   = $this->decodeString(substr($data, $offset), self::BIT_B16);
        $offset    += $memberId['length'];
        $memberMeta = $this->decodeString(substr($data, $offset), self::BIT_B32);
        $offset    += $memberMeta['length'];

        $metaData    = $memberMeta['data'];
        $metaOffset  = 0;
        $version     = self::unpack(self::BIT_B16, substr($metaData, $metaOffset, 2));
        $metaOffset += 2;
        $topics      = $this->decodeArray(substr($metaData, $metaOffset), [$this, 'decodeString'], self::BIT_B16);
        $metaOffset += $topics['length'];
        $userData    = $this->decodeString(substr($metaData, $metaOffset), self::BIT_B32);

        return [
            'length' => $offset,
            'data'   => [
                'memberId'   => $memberId['data'],
                'memberMeta' => [
                    'version'  => $version,
                    'topics'   => $topics['data'],
                    'userData' => $userData['data'],
                ],
            ],
        ];
    }
}


<?php
declare(strict_types=1);

namespace Kafka;

use Amp\Loop;
use Kafka\Consumer\Process;
use Kafka\Consumer\StopStrategy;
use Psr\Log\LoggerAwareTrait;

class Consumer
{
    use LoggerAwareTrait;
    use LoggerTrait;

    /**
     * @var StopStrategy|null
     */
    private $stopStrategy;

    /**
     * @var Process|null
     */
    private $process;

    public function __construct(?StopStrategy $stopStrategy = null)
    {
        $this->stopStrategy = $stopStrategy;
    }

    /**
     * start consumer
     *
     * @access public
     *
     *
     */
    public function start(?callable $consumer = null): void
    {
        if ($this->process !== null) {
            $this->error('Consumer is already being executed');
            return;
        }

        $this->setupStopStrategy();

        $this->process = $this->createProcess($consumer);
        $this->process->start();

        Loop::run();
    }

    /**
     * FIXME: remove it when we implement dependency injection
     *
     * This is a very bad practice, but if we don't create this method
     * this class will never be testable...
     *
     * @codeCoverageIgnore
     */
    protected function createProcess(?callable $consumer): Process
    {
        $process = new Process($consumer);

        if ($this->logger) {
            $process->setLogger($this->logger);
        }

        return $process;
    }

    private function setupStopStrategy(): void
    {
        if ($this->stopStrategy === null) {
            return;
        }

        $this->stopStrategy->setup($this);
    }

    public function stop(): void
    {
        if ($this->process === null) {
            $this->error('Consumer is not running');
            return;
        }

        $this->process->stop();
        $this->process = null;

        Loop::stop();
    }
}