<?php

namespace WeatherStation\SDK\OpenWeatherMap;

use WeatherStation\SDK\OpenWeatherMap\AbstractCache;
use WeatherStation\SDK\OpenWeatherMap\CurrentWeather;
use WeatherStation\SDK\OpenWeatherMap\Exception as OWMException;
use WeatherStation\SDK\OpenWeatherMap\Fetcher\CurlFetcher;
use WeatherStation\SDK\OpenWeatherMap\Fetcher\FetcherInterface;
use WeatherStation\SDK\OpenWeatherMap\Fetcher\FileGetContentsFetcher;
use WeatherStation\SDK\OpenWeatherMap\WeatherForecast;
use WeatherStation\SDK\OpenWeatherMap\WeatherHistory;

/**
 * OpenWeatherMap client implementation.
 *
 * @package Includes\Libraries
 * @author Originally written by Christian Flach <https://github.com/cmfcmf>.
 * @author Modified by Pierre Lannoy <https://pierre.lannoy.fr/>.
 * @since 2.0.0
 * @license MIT
 */
class OWMApiClient
{
    /**
     * @var string $weatherUrl The basic api url to fetch weather data from.
     */
    private $weatherUrl = "http://api.openweathermap.org/data/2.5/weather?";

    /**
     * @var string $weatherUrl The  api url to fetch station data from.
     */
    private $stationUrl = "http://api.openweathermap.org/data/2.5/station?";

    /**
     * @var string $url The basic api url to fetch weekly forecast data from.
     */
    private $weatherHourlyForecastUrl = "http://api.openweathermap.org/data/2.5/forecast?";

    /**
     * @var string $url The basic api url to fetch daily forecast data from.
     */
    private $weatherDailyForecastUrl = "http://api.openweathermap.org/data/2.5/forecast/daily?";

    /**
     * @var string $url The basic api url to fetch history weather data from.
     */
    private $weatherHistoryUrl = "http://api.openweathermap.org/data/2.5/history/city?";

    /**
     * The copyright notice. This is no official text, this hint was created regarding to http://openweathermap.org/copyright.
     *
     * @var string $copyright
     */
    const COPYRIGHT = "Weather data from <a href=\"http://www.openweathermap.org\">OpenWeatherMap.org</a>";

    /**
     * @var \WeatherStation\SDK\OpenWeatherMap\AbstractCache|bool $cacheClass The cache class.
     */
    private $cacheClass = false;

    /**
     * @var int
     */
    private $seconds;

    /**
     * @var FetcherInterface The url fetcher.
     */
    private $fetcher;

    /**
     * Constructs the OpenWeatherMap object.
     *
     * @param null|FetcherInterface $fetcher    The interface to fetch the data from OpenWeatherMap. Defaults to
     *                                          CurlFetcher() if cURL is available. Otherwise defaults to
     *                                          FileGetContentsFetcher() using 'file_get_contents()'.
     * @param bool|string           $cacheClass If set to false, caching is disabled. Otherwise this must be a class
     *                                          extending AbstractCache. Defaults to false.
     * @param int                   $seconds    How long weather data shall be cached. Default 10 minutes.
     *
     * @throws \Exception If $cache is neither false nor a valid callable extending owm\OpenWeatherMap\Util\Cache.
     * @api
     */
    public function __construct($fetcher = null, $cacheClass = false, $seconds = 600)
    {
        if ($cacheClass !== false && !($cacheClass instanceof AbstractCache)) {
            throw new \Exception("The cache class must implement the FetcherInterface!");
        }
        if (!is_numeric($seconds)) {
            throw new \Exception("\$seconds must be numeric.");
        }
        if (!isset($fetcher)) {
            $fetcher = (function_exists('curl_version')) ? new CurlFetcher() : new FileGetContentsFetcher();
        }
        if ($seconds == 0) {
            $cacheClass = false;
        }

        $this->cacheClass = $cacheClass;
        $this->seconds = $seconds;
        $this->fetcher = $fetcher;
    }

    /**
     * Returns the current weather at the place you specified as an object.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     *
     * @throws OWMException If OpenWeatherMap returns an error.
     * @throws \InvalidArgumentException If an argument error occurs.
     *
     * @return CurrentWeather The weather object.
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getWeather($query, $units = 'imperial', $lang = 'en', $appid = '')
    {
        // Disable default error handling of SimpleXML (Do not throw E_WARNINGs).
        libxml_use_internal_errors(true);
        libxml_clear_errors();

        $answer = $this->getRawWeatherData($query, $units, $lang, $appid, 'xml');

        try {
            $xml = new \SimpleXMLElement($answer);
        } catch (\Exception $e) {
            // Invalid xml format. This happens in case OpenWeatherMap returns an error.
            // OpenWeatherMap always uses json for errors, even if one specifies xml as format.
            $error = json_decode($answer, true);
            if (isset($error['message'])) {
                throw new OWMException($error['message'], $error['cod']);
            } else {
                throw new OWMException('Unknown fatal error: OpenWeatherMap returned the following json object: ' . $answer);
            }
        }

        return new CurrentWeather($xml, $units);
    }

    /**
     * Returns the current weather at the place you specified as an object.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     * @param int              $days  For how much days you want to get a forecast. Default 1, maximum: 16.
     *
     * @throws OWMException If OpenWeatherMap returns an error.
     * @throws \InvalidArgumentException If an argument error occurs.
     *
     * @return WeatherForecast The WeatherForecast object.
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getWeatherForecast($query, $units = 'imperial', $lang = 'en', $appid = '', $days = 1)
    {
        // Disable default error handling of SimpleXML (Do not throw E_WARNINGs).
        libxml_use_internal_errors(true);
        libxml_clear_errors();

        if ($days <= 5) {
            $answer = $this->getRawHourlyForecastData($query, $units, $lang, $appid, 'xml');
        } elseif ($days <= 16) {
            $answer = $this->getRawDailyForecastData($query, $units, $lang, $appid, 'xml', $days);
        } else {
            throw new \InvalidArgumentException('Error: forecasts are only available for the next 16 days. $days must be lower than 17.');
        }

        try {
            $xml = new \SimpleXMLElement($answer);
        } catch (\Exception $e) {
            // Invalid xml format. This happens in case OpenWeatherMap returns an error.
            // OpenWeatherMap always uses json for errors, even if one specifies xml as format.
            $error = json_decode($answer, true);
            if (isset($error['message'])) {
                throw new OWMException($error['message'], $error['cod']);
            } else {
                throw new OWMException('Unknown fatal error: OpenWeatherMap returned the following json object: ' . $answer);
            }
        }

        return new WeatherForecast($xml, $units, $days);
    }

    /**
     * Returns the weather history for the place you specified as an object.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param \DateTime        $start
     * @param int              $endOrCount
     * @param string           $type
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     *
     * @throws OWMException If OpenWeatherMap returns an error.
     * @throws \InvalidArgumentException If an argument error occurs.
     *
     * @return WeatherHistory The WeatherHistory object.
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getWeatherHistory($query, \DateTime $start, $endOrCount = 1, $type = 'hour', $units = 'imperial', $lang = 'en', $appid = '')
    {
        if (!in_array($type, array('tick', 'hour', 'day'))) {
            throw new \InvalidArgumentException('$type must be either "tick", "hour" or "day"');
        }

        $xml = json_decode($this->getRawWeatherHistory($query, $start, $endOrCount, $type, $units, $lang, $appid), true);

        if ($xml['cod'] != 200) {
            throw new OWMException($xml['message'], $xml['cod']);
        }

        return new WeatherHistory($xml, $query);
    }

    /**
     * @deprecated Use {@link self::getRawWeatherData()} instead.
     */
    public function getRawData($query, $units = 'imperial', $lang = 'en', $appid = '', $mode = 'xml')
    {
        return $this->getRawWeatherData($query, $units, $lang, $appid, $mode);
    }

    /**
     * Directly returns the xml/json/html string returned by OpenWeatherMap for the current weather.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     * @param string           $mode  The format of the data fetched. Possible values are 'json', 'html' and 'xml' (default).
     *
     * @return string Returns false on failure and the fetched data in the format you specified on success.
     *
     * Warning If an error occurred, OpenWeatherMap returns data in json format ALWAYS
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getRawWeatherData($query, $units = 'imperial', $lang = 'en', $appid = '', $mode = 'xml')
    {
        $url = $this->buildUrl($query, $units, $lang, $appid, $mode, $this->weatherUrl);

        return $this->cacheOrFetchResult($url);
    }

    /**
     * Directly returns the xml/json/html string returned by OpenWeatherMap for a specific station.
     *
     * @param integer          $query The station to get weather information for.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     * @param string           $mode  The format of the data fetched. Possible values are 'json', 'html' and 'xml' (default).
     *
     * @return string Returns false on failure and the fetched data in the format you specified on success.
     *
     * Warning If an error occurred, OpenWeatherMap returns data in json format ALWAYS
     *
     * @api
     */
    public function getRawStationData($query, $units = 'imperial', $lang = 'en', $appid = '', $mode = 'xml')
    {
        //$url = $this->buildUrl($query, $units, $lang, $appid, $mode, $this->stationUrl);

        $url = $this->stationUrl . 'ID=' . $query;
        if (!empty($appid)) {
            $url .= "&APPID=$appid";
        }

        return $this->cacheOrFetchResult($url);
    }

    /**
     * Directly returns the xml/json/html string returned by OpenWeatherMap for the hourly forecast.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     * @param string           $mode  The format of the data fetched. Possible values are 'json', 'html' and 'xml' (default).
     *
     * @return string Returns false on failure and the fetched data in the format you specified on success.
     *
     * Warning If an error occurred, OpenWeatherMap returns data in json format ALWAYS
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getRawHourlyForecastData($query, $units = 'imperial', $lang = 'en', $appid = '', $mode = 'xml')
    {
        $url = $this->buildUrl($query, $units, $lang, $appid, $mode, $this->weatherHourlyForecastUrl);

        return $this->cacheOrFetchResult($url);
    }

    /**
     * Directly returns the xml/json/html string returned by OpenWeatherMap for the daily forecast.
     *
     * @param array|int|string $query The place to get weather information for. For possible values see below.
     * @param string           $units Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang  The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid Your app id, default ''. See http://openweathermap.org/appid for more details.
     * @param string           $mode  The format of the data fetched. Possible values are 'json', 'html' and 'xml' (default)
     * @param int              $cnt   How many days of forecast shall be returned? Maximum (and default): 16
     *
     * @throws \InvalidArgumentException If $cnt is higher than 16.
     * @return string Returns false on failure and the fetched data in the format you specified on success.
     *
     * Warning If an error occurred, OpenWeatherMap returns data in json format ALWAYS
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getRawDailyForecastData($query, $units = 'imperial', $lang = 'en', $appid = '', $mode = 'xml', $cnt = 16)
    {
        if ($cnt > 16) {
            throw new \InvalidArgumentException('$cnt must be 16 or below!');
        }
        $url = $this->buildUrl($query, $units, $lang, $appid, $mode, $this->weatherDailyForecastUrl) . "&cnt=$cnt";

        return $this->cacheOrFetchResult($url);
    }

    /**
     * Directly returns the xml/json/html string returned by OpenWeatherMap for the daily forecast.
     *
     * @param array|int|string $query           The place to get weather information for. For possible values see below.
     * @param \DateTime        $start           The \DateTime object of the date to get the first weather information from.
     * @param \DateTime|int    $endOrCount      Can be either a \DateTime object representing the end of the period to
     *                                          receive weather history data for or an integer counting the number of
     *                                          reports requested.
     * @param string           $type            The period of the weather history requested. Can be either be either "tick",
     *                                          "hour" or "day".
     * @param string           $units           Can be either 'metric' or 'imperial' (default). This affects almost all units returned.
     * @param string           $lang            The language to use for descriptions, default is 'en'. For possible values see below.
     * @param string           $appid           Your app id, default ''. See http://openweathermap.org/appid for more details.
     *
     * @throws \InvalidArgumentException
     *
     * @return string Returns false on failure and the fetched data in the format you specified on success.
     *
     * Warning If an error occurred, OpenWeatherMap returns data in json format ALWAYS
     *
     * There are three ways to specify the place to get weather information for:
     * - Use the city name: $query must be a string containing the city name.
     * - Use the city id: $query must be an integer containing the city id.
     * - Use the coordinates: $query must be an associative array containing the 'lat' and 'lon' values.
     *
     * Available languages are (as of 17. July 2013):
     * - English - en
     * - Russian - ru
     * - Italian - it
     * - Spanish - sp
     * - Ukrainian - ua
     * - German - de
     * - Portuguese - pt
     * - Romanian - ro
     * - Polish - pl
     * - Finnish - fi
     * - Dutch - nl
     * - French - fr
     * - Bulgarian - bg
     * - Swedish - se
     * - Chinese Traditional - zh_tw
     * - Chinese Simplified - zh_cn
     * - Turkish - tr
     *
     * @api
     */
    public function getRawWeatherHistory($query, \DateTime $start, $endOrCount = 1, $type = 'hour', $units = 'imperial', $lang = 'en', $appid = '')
    {
        if (!in_array($type, array('tick', 'hour', 'day'))) {
            throw new \InvalidArgumentException('$type must be either "tick", "hour" or "day"');
        }

        $queryUrl = $this->weatherHistoryUrl . $this->buildQueryUrlParameter($query) . "&start={$start->format('U')}";

        if ($endOrCount instanceof \DateTime) {
            $queryUrl .= "&end={$endOrCount->format('U')}";
        } elseif (is_numeric($endOrCount) && $endOrCount > 0) {
            $queryUrl .= "&cnt=$endOrCount";
        } else {
            throw new \InvalidArgumentException('$endOrCount must be either a \DateTime or a positive integer.');
        }
        $queryUrl .= "&type=$type&units=$units&lang=$lang";

        if (!empty($appid)) {
            $queryUrl .= "&APPID=$appid";
        }

        return $this->cacheOrFetchResult($queryUrl);
    }

    /**
     * Fetches the result or delivers a cached version of the result.
     *
     * @param $url
     *
     * @return string
     *
     * @internal
     */
    private function cacheOrFetchResult($url)
    {
        if ($this->cacheClass !== false) {
            /** @var \WeatherStation\SDK\OpenWeatherMap\AbstractCache $cache */
            $cache = $this->cacheClass;
            $cache->setSeconds($this->seconds);
            if ($cache->isCached($url)) {
                return $cache->getCached($url);
            }
            $result = $this->fetcher->fetch($url);
            $cache->setCached($url, $result);
        } else {
            $result = $this->fetcher->fetch($url);
        }

        return $result;
    }

    /**
     * Build the url to fetch weather data from.
     *
     * @param        $query
     * @param        $units
     * @param        $lang
     * @param        $appid
     * @param        $mode
     * @param string $url The url to prepend.
     *
     * @return bool|string The fetched url, false on failure.
     *
     * @internal
     */
    private function buildUrl($query, $units, $lang, $appid, $mode, $url)
    {
        $queryUrl = $this->buildQueryUrlParameter($query);

        $url = $url . "$queryUrl&units=$units&lang=$lang&mode=$mode";
        if (!empty($appid)) {
            $url .= "&APPID=$appid";
        }

        return $url;
    }

    /**
     * Builds the query string for the url.
     *
     * @param $query
     *
     * @return string The built query string for the url.
     * @throws \InvalidArgumentException If the query parameter is invalid.
     *
     * @internal
     */
    private function buildQueryUrlParameter($query)
    {
        switch ($query) {
            case (is_array($query) && isset($query['lat']) && isset($query['lon']) && is_numeric($query['lat']) && is_numeric($query['lon'])):
                return "lat={$query['lat']}&lon={$query['lon']}";
            case (is_numeric($query)):
                return "id=$query";
            case (is_string($query)):
                return "q=" . urlencode($query);
            default:
                throw new \InvalidArgumentException('Error: $query has the wrong format. See the documentation of OpenWeatherMap::getRawData() to read about valid formats.');
        }
    }
}
