/**
 * i-net software provides programming examples for illustration only, without warranty
 * either expressed or implied, including, but not limited to, the implied warranties
 * of merchantability and/or fitness for a particular purpose. This programming example
 * assumes that you are familiar with the programming language being demonstrated and
 * the tools used to create and debug procedures. i-net software support professionals
 * can help explain the functionality of a particular procedure, but they will not modify
 * these examples to provide added functionality or construct procedures to meet your
 * specific needs.
 *
 * Copyright © 1999-2026 i-net software GmbH, Berlin, Germany.
**/
package com.inet.dashboard.openweathermap;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import com.inet.config.structure.model.LocalizedKey;
import com.inet.dashboard.api.ClientWidgetKey;
import com.inet.dashboard.api.DashboardWidget;
import com.inet.dashboard.api.model.DashboardWidgetDefinition;
import com.inet.dashboard.openweathermap.ForecastData.Forecast;
import com.inet.http.ClientMessageException;
import com.inet.http.servlet.ClientLocale;
import com.inet.lib.json.Json;
import com.inet.lib.ui.UIBuilder;
import com.inet.lib.ui.UIGroup;
import com.inet.lib.ui.UIProperty;
import com.inet.lib.util.EncodingFunctions;
import com.inet.lib.util.StringFunctions;
import com.inet.thread.timer.DefaultTimer;
import com.inet.thread.timer.DefaultTimerTask;

/**
 * A dashboard widget implementation that provides the information about the required settings and serves the weather data to the registered clients
 */
public class OpenWeatherMapWidget implements DashboardWidget {

    // The URL to the openweathermap.org API
    private static final String                    API_URL      = "https://api.openweathermap.org/data/2.5/";

    private static final String                    API_KEY      = "apikey";
    private static final String                    LOCATION     = "location";
    private static final String                    UNITS        = "units";

    private final Map<WidgetSettings, RefreshTask> refreshTasks = new ConcurrentHashMap<>();

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nonnull String getExtensionName() {
        return "openweathermap";
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nonnull String getCategory() {
        return DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.category" );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nonnull String getDescription() {
        return DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.description" );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nonnull String getDisplayName() {
        return DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.name" );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public URL getIcon() {
        return getClass().getResource( "dashboard_openweather.svg" );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nonnull String getRenderer( @Nonnull Map<String, Object> settings ) {
        return "dashboard-widget-openweathermap"; // a string, containing hyphens, to identify the javascript file and the tag name of the widget
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public @Nullable UIBuilder getSettingsUI() {
        UIBuilder ui = new UIBuilder( DashboardOpenWeatherMapServerPlugin.MSG, "dashboard.openweathermap." );
        appendBaseSettings( ui ); // append the default widget settings like the title

        UIGroup group = ui.group( "weathersettings" );
        group.input().text( API_KEY );
        group.input().text( LOCATION );
        UIProperty unitSelect = group.select().optionsFromKeys( UNITS, Stream.of( Units.values() ).map( Enum::name ).collect( Collectors.toList() ) );
        ui.defaultValue( unitSelect, Units.METRIC.name() );

        ui.validator( ( settings ) -> {
            if( ((String)settings.getOrDefault( API_KEY, "" )).isEmpty() ) {
                throw new ClientMessageException( DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.error.noapikey" ) );
            }
        } );
        ui.validator( ( settings ) -> {
            if( ((String)settings.getOrDefault( LOCATION, "" )).isEmpty() ) {
                throw new ClientMessageException( DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.error.nolocation" ) );
            }
        } );

        return ui;
    }

    /**
     * {@inheritDoc}
     */
    @SuppressWarnings( "null" )
    @Override
    public void register( @Nonnull String clientID, @Nonnull DashboardWidgetDefinition definition ) {
        Map<String, Object> settings = definition.getSettings();
        String apiKey = (String)settings.getOrDefault( API_KEY, "" );
        String location = (String)settings.getOrDefault( LOCATION, "" );
        Units units = Units.valueOf( (String)settings.getOrDefault( UNITS, Units.METRIC.name() ) );

        ClientWidgetKey key = new ClientWidgetKey( clientID, definition.getID() );
        try {
            WidgetSettings widgetSettings = new WidgetSettings( apiKey, location, units, ClientLocale.getThreadLocale() );
            RefreshTask refreshTask = refreshTasks.computeIfAbsent( widgetSettings, s -> new RefreshTask( s ) );
            refreshTask.registerClient( key );
        } catch( Throwable t ) {
            sendError( key, String.valueOf( t.getMessage() ) );
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void unregister( @Nonnull String clientID, @Nonnull DashboardWidgetDefinition definition ) {
        Map<String, Object> settings = definition.getSettings();
        String apiKey = (String)settings.getOrDefault( API_KEY, "" );
        String location = (String)settings.getOrDefault( LOCATION, "" );
        Units units = Units.valueOf( (String)settings.getOrDefault( UNITS, Units.METRIC.name() ) );

        ClientWidgetKey key = new ClientWidgetKey( clientID, definition.getID() );
        try {
            WidgetSettings widgetSettings = new WidgetSettings( apiKey, location, units, ClientLocale.getThreadLocale() );
            refreshTasks.computeIfPresent( widgetSettings, ( s, refreshTask ) -> {
                if( refreshTask.unregisterClient( key ) ) {
                    refreshTask.cancel();
                    return null;
                }
                return refreshTask;
            } );
        } catch( Throwable t ) {
            DashboardOpenWeatherMapServerPlugin.LOGGER.error( t );
        }

    }

    /**
     * DefaultTimerTask implementation to refresh the weather data at a fixed interval.
     */
    private final class RefreshTask extends DefaultTimerTask {

        private WidgetSettings                widgetSettings;
        private Map<String, Object>           weatherData;
        private Set<@Nonnull ClientWidgetKey> clients = ConcurrentHashMap.newKeySet();

        /**
         * Creates the task for the given settings and starts the timer immediately.
         * @param widgetSettings the settings of the widget
         */
        public RefreshTask( WidgetSettings widgetSettings ) {
            this.widgetSettings = widgetSettings;
            DefaultTimer.getInstance().schedule( this, 0, 3600000 ); // Refresh instantly and then each hour
        }

        /**
         * Registers a new client.
         * @param key the new client
         */
        public void registerClient( @Nonnull ClientWidgetKey key ) {
            clients.add( key );

            Map<String, Object> weatherData = this.weatherData;
            if( weatherData != null ) {
                sendData( key, weatherData );
            }
        }

        /**
         * Unregisters a client. Returns whether all clients are removed now.
         * @param key the client to unregister
         * @return whether all clients are disconnected
         */
        public boolean unregisterClient( ClientWidgetKey key ) {
            if( clients.remove( key ) ) {
                if( clients.isEmpty() ) {
                    return true;
                }
            }
            return false;
        }

        /**
         * Loads the weather data for the given user input and returns a formatted map with values for the client
         * @return the map with values for the client
         * @throws IOException if the data could not be requested
         */
        private Map<String, Object> loadWeatherData() throws IOException, URISyntaxException {
            Locale locale = widgetSettings.getLocale();
            if (locale == null) {
                locale = Locale.getDefault();
            }

            if( widgetSettings.getApiKey() == null || widgetSettings.getApiKey().isEmpty() ) {
                throw new ClientMessageException( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.error.noapikey" ) );
            }
            if( widgetSettings.getLocation() == null || widgetSettings.getLocation().isEmpty() ) {
                throw new ClientMessageException( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.error.nolocation" ) );
            }

            Units units = widgetSettings.getUnits();

            String cityAndCountry = "q=" + EncodingFunctions.encodeUrlParameter( widgetSettings.getLocation() ); // Location as City,CountryCode as search query
            String appID = "appid=" + EncodingFunctions.encodeUrlParameter( widgetSettings.getApiKey() ); // App ID, entered by the user
            String language = "lang=" + EncodingFunctions.encodeUrlParameter( locale.getLanguage() ); // Current language settings retrieved from the client locale
            String unitSettings = "units=" + EncodingFunctions.encodeUrlParameter( units.name().toLowerCase() ); // The units, selected by the user

            NumberFormat exactTemperatureFormat = DecimalFormat.getInstance();
            exactTemperatureFormat.setMinimumFractionDigits( 0 );
            exactTemperatureFormat.setMaximumFractionDigits( 2 );
            NumberFormat roundedTemperatureFormat = DecimalFormat.getInstance();
            roundedTemperatureFormat.setMaximumFractionDigits( 0 );
            DateFormat timeFormat = SimpleDateFormat.getTimeInstance( SimpleDateFormat.SHORT );
            DateFormat dateFormat = new SimpleDateFormat( "E" );

            Map<String, Object> result = new HashMap<String, Object>();

            // Create the target URL for the current weather data
            URL targetCurrentWeather = new URI( API_URL + "weather?" + cityAndCountry + "&" + appID + "&" + language + "&" + unitSettings ).toURL();

            // Open the connection and read the data from the input stream
            URLConnection connectionCurrentWeather = targetCurrentWeather.openConnection();
            try (InputStream inputStream = connectionCurrentWeather.getInputStream()) {

                // Read the data with the JSON-Parser into a WeatherData instance.
                // the HashMap for extraFields is set, to allow additional fields int the response data that can be ignored. Values that do not match a field, will be added to this map.
                WeatherData weatherData = new Json().fromJson( inputStream, WeatherData.class, new HashMap<Object, Map<String, String>>(), null );
                if( weatherData.getCod() != 200 ) {
                    // If the code os not 200 OK, throw exception
                    return null;
                }

                // Get all details and format them
                result.put( "description", weatherData.getWeather().get( 0 ).getDescription() );
                result.put( "icon", weatherData.getWeather().get( 0 ).getIcon() );
                result.put( "temperature", String.format( "%s", exactTemperatureFormat.format( weatherData.getMain().getTemp() ) ) + " " + units.getTemperatureUnit() );
                result.put( "pressure", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.pressure" ), weatherData.getMain().getPressure() + " hPa" ) );
                result.put( "humidity", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.humidity" ), weatherData.getMain().getHumidity() + "%" ) );
                result.put( "windspeed", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.windspeed" ), weatherData.getWind().getSpeed() + " " + units.getSpeedUnit() ) );
                result.put( "sunrise", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.sunrise" ), timeFormat.format( new Date( weatherData.getSys().getSunrise() * 1000 ) ) ) );
                result.put( "sunset", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.sunset" ), timeFormat.format( new Date( weatherData.getSys().getSunset() * 1000 ) ) ) );
                result.put( "visibility", new LocalizedKey( DashboardOpenWeatherMapServerPlugin.MSG.getMsg(locale, "dashboard.openweathermap.visibility" ), weatherData.getVisibility() + " m" ) );

            } catch( Throwable th ) {
                for( ClientWidgetKey widgetKey : clients ) {
                    sendError( widgetKey, StringFunctions.getUserFriendlyErrorMessage( connectionCurrentWeather ) );
                }
                return null;
            }

            // Create the target URL for the daily forecast
            URL targetForecast = new URI( API_URL + "forecast/daily?" + cityAndCountry + "&" + appID + "&" + language + "&" + unitSettings + "&cnt=7" ).toURL();

            List<Map<String, String>> forecasts = new ArrayList<>();
            // Open the connection and read the data from the input stream
            URLConnection connectionForecast = targetForecast.openConnection();
            try (InputStream inputStream = connectionForecast.getInputStream()) {

                // Read the data with the JSON-Parser into a WeatherData instance.
                // the HashMap for extraFields is set, to allow additional fields int the response data that can be ignored. Values that do not match a field, will be added to this map.
                ForecastData forecastData = new Json().fromJson( inputStream, ForecastData.class, new HashMap<Object, Map<String, String>>(), null );
                if( forecastData.getCod() == 200 ) {
                    // If the code is 200 OK, go on
                    for( Forecast forecast : forecastData.getForecasts() ) {
                        Map<String, String> forecastResult = new HashMap<String, String>();
                        forecastResult.put( "date", dateFormat.format( new Date( forecast.getDt() * 1000 ) ) );
                        forecastResult.put( "description", forecast.getWeather().getDescription() );
                        forecastResult.put( "icon", forecast.getWeather().getIcon() );
                        forecastResult.put( "temperaturemin", String.format( "%s", roundedTemperatureFormat.format( forecast.getTemp().getMin() ) + " " + units.getTemperatureUnit() ) );
                        forecastResult.put( "temperaturemax", String.format( "%s", roundedTemperatureFormat.format( forecast.getTemp().getMax() ) + " " + units.getTemperatureUnit() ) );
                        forecasts.add( forecastResult );
                    }
                }
            } catch( Throwable th ) {
                // send no forecasts in case of errors. There are api keys that can not request forecast data.
            }

            result.put( "forecasts", forecasts );
            return result;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void runImpl() throws Throwable {
            ForkJoinPool.commonPool().execute( () -> {
                try {
                    Map<String, Object> weatherData = this.weatherData = loadWeatherData();
                    if( weatherData == null ) {
                        return;
                    }
                    for( ClientWidgetKey widgetKey : clients ) {
                        sendData( widgetKey, weatherData );
                    }
                } catch( Throwable t ) {
                    if( t instanceof ClientMessageException ) {
                        ClientMessageException cme = (ClientMessageException)t;
                        for( ClientWidgetKey widgetKey : clients ) {
                            sendError( widgetKey, cme.getMessage() );
                        }
                        return;
                    }
                    DashboardOpenWeatherMapServerPlugin.LOGGER.error( t );
                    for( ClientWidgetKey widgetKey : clients ) {
                        sendError( widgetKey, DashboardOpenWeatherMapServerPlugin.MSG.getMsg( "dashboard.openweathermap.error.loading" ) );
                    }
                }
            } );
        }
    }
}
