/**
 *
 */
package com.thinkbiganalytics.alerts.spi.mem;

/*-
 * #%L
 * thinkbig-alerts-core
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */

import com.thinkbiganalytics.alerts.api.Alert;
import com.thinkbiganalytics.alerts.api.Alert.ID;
import com.thinkbiganalytics.alerts.api.Alert.State;
import com.thinkbiganalytics.alerts.api.AlertChangeEvent;
import com.thinkbiganalytics.alerts.api.AlertCriteria;
import com.thinkbiganalytics.alerts.api.AlertResponse;
import com.thinkbiganalytics.alerts.api.core.BaseAlertCriteria;
import com.thinkbiganalytics.alerts.spi.AlertDescriptor;
import com.thinkbiganalytics.alerts.spi.AlertManager;
import com.thinkbiganalytics.alerts.spi.AlertNotifyReceiver;
import com.thinkbiganalytics.alerts.spi.AlertSource;

import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Serializable;
import java.net.URI;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 *
 */
public class InMemoryAlertManager implements AlertManager {

    public static final int MAX_ALERTS = 2000;
    private static final Logger LOG = LoggerFactory.getLogger(InMemoryAlertManager.class);
    private static AtomicReference<GenericAlert> NULL_REF = new AtomicReference<GenericAlert>(null);

    private final Set<AlertDescriptor> descriptors;
    private final Set<AlertNotifyReceiver> alertReceivers;
    private final Map<Alert.ID, AtomicReference<GenericAlert>> alertsById;
    private final NavigableMap<DateTime, AtomicReference<GenericAlert>> alertsByTime;
    private final ReadWriteLock alertsLock = new ReentrantReadWriteLock();

    private volatile Executor receiversExecutor;
    private AtomicInteger changeCount = new AtomicInteger(0);


    /**
     *
     */
    public InMemoryAlertManager() {
        this.descriptors = Collections.synchronizedSet(new HashSet<AlertDescriptor>());
        this.alertReceivers = Collections.synchronizedSet(new HashSet<AlertNotifyReceiver>());
        this.alertsById = new ConcurrentHashMap<>();
        this.alertsByTime = new ConcurrentSkipListMap<>();
    }

    public void setReceiversExecutor(Executor receiversExecutor) {
        synchronized (this) {
            this.receiversExecutor = receiversExecutor;
        }
    }

    protected Executor getRespondersExecutor() {
        if (this.receiversExecutor == null) {
            synchronized (this) {
                if (this.receiversExecutor == null) {
                    this.receiversExecutor = Executors.newFixedThreadPool(1);
                }
            }
        }

        return receiversExecutor;
    }

    @Override
    public Set<AlertDescriptor> getAlertDescriptors() {
        synchronized (this.descriptors) {
            return new HashSet<>(this.descriptors);
        }
    }

    public void setAlertDescriptors(Collection<AlertDescriptor> types) {
        synchronized (this.descriptors) {
            this.descriptors.addAll(types);
        }
    }

    @Override
    public boolean addDescriptor(AlertDescriptor descriptor) {
        return this.descriptors.add(descriptor);
    }

    @Override
    public void addReceiver(AlertNotifyReceiver receiver) {
        this.alertReceivers.add(receiver);
    }

    @Override
    public void removeReceiver(AlertNotifyReceiver receiver) {
        this.alertReceivers.remove(receiver);
    }

    @Override
    public Optional<Alert> getAlert(ID id) {
        return Optional.ofNullable(this.alertsById.getOrDefault(id, NULL_REF).get());
    }

    @Override
    public AlertCriteria criteria() {
        return new BaseAlertCriteria();
    }

    @Override
    public ID resolve(Serializable ser) {
        if (ser instanceof String) {
            return new AlertID((String) ser);
        } else if (ser instanceof UUID) {
            return new AlertID((UUID) ser);
        } else if (ser instanceof AlertID) {
            return (AlertID) ser;
        } else {
            throw new IllegalArgumentException("Invalid ID source format: " + ser.getClass());
        }
    }

    @Override
    public Iterator<Alert> getAlerts(AlertCriteria criteria) {
        BaseAlertCriteria predicate = (BaseAlertCriteria) (criteria == null ? criteria() : criteria);
        // TODO Grab a partition of the map first based on before/after times of criteria
        return this.alertsByTime.values().stream()
            .map(ref -> (Alert) ref.get())
            .filter(predicate)
            .iterator();
    }
//
//    @Override
//    public Iterator<Alert> getAlerts(DateTime since) {
//        this.alertsLock.readLock().lock();
//        try {
//            DateTime higher = this.alertsByTime.higherKey(since);
//            
//            if (higher != null) {
//                SortedMap<DateTime, AtomicReference<GenericAlert>> submap = this.alertsByTime.subMap(higher, DateTime.now());
//                return submap.values().stream().map(ref -> (Alert) ref.get()).iterator();
//            } else {
//                return Collections.<Alert>emptySet().iterator();
//            }
//        } finally {
//            this.alertsLock.readLock().unlock();
//        }
//    }
//
//    @Override
//    public Iterator<Alert> getAlerts(ID since) {
//        AtomicReference<GenericAlert> ref = this.alertsById.get(since);
//        
//        if (ref == null) {
//            return Collections.<Alert>emptySet().iterator();
//        } else {
//            GenericAlert alert = ref.get();
//            int index = alert.getEvents().size() - 1;
//            DateTime createdTime = alert.getEvents().get(index).getChangeTime();
//            
//            return getAlerts(createdTime);
//        }
//    }

    @Override
    public <C extends Serializable> Alert create(URI type, Alert.Level level, String description, C content) {
        GenericAlert alert = new GenericAlert(type, level, description, content);
        DateTime createdTime = alert.getEvents().get(0).getChangeTime();

        addAlert(alert, createdTime);
        return alert;
    }

    private void updated(GenericAlert alert) {
        this.alertsById.computeIfPresent(alert.getId(),
                                         (id, ref) -> {
                                             ref.set(alert);
                                             return ref;
                                         });
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.alerts.spi.AlertManager#getUpdator(com.thinkbiganalytics.alerts.api.Alert)
     */
    @Override
    public AlertResponse getResponse(Alert alert) {
        return new InternalAlertResponse((GenericAlert) alert);
    }

    @Override
    public Alert remove(ID id) {
        this.alertsLock.writeLock().lock();
        try {
            AtomicReference<GenericAlert> ref = this.alertsById.remove(id);

            if (ref != null) {
                this.alertsByTime.values().remove(ref);
                return ref.get();
            } else {
                return null;
            }
        } finally {
            this.alertsLock.writeLock().unlock();
        }
    }


    protected void addAlert(GenericAlert alert, DateTime createdTime) {
        AtomicReference<GenericAlert> ref = new AtomicReference<>(alert);
        int count = 0;

        this.alertsLock.writeLock().lock();
        try {
            this.alertsByTime.put(createdTime, ref);
            this.alertsById.put(alert.getId(), ref);
            count = this.changeCount.incrementAndGet();
        } finally {
            this.alertsLock.writeLock().unlock();
        }

        LOG.info("Alert added - pending notifications: {}", count);

        if (count > 0) {
            signalReceivers();
        }
    }


    private void signalReceivers() {
        Executor exec = getRespondersExecutor();
        final Set<AlertNotifyReceiver> receivers;

        synchronized (this.alertReceivers) {
            receivers = new HashSet<>(this.alertReceivers);
        }

        exec.execute(() -> {
            int count = InMemoryAlertManager.this.changeCount.get();

            LOG.info("Notifying receivers: {} about events: {}", receivers.size(), count);

            for (AlertNotifyReceiver receiver : receivers) {
                receiver.alertsAvailable(count);
            }

            InMemoryAlertManager.this.changeCount.getAndAdd(-count);
        });
    }

    private static class AlertID implements Alert.ID {

        private final UUID uuid;

        public AlertID() {
            this(UUID.randomUUID());
        }

        public AlertID(String str) {
            this(UUID.fromString(str));
        }

        public AlertID(UUID id) {
            this.uuid = id;
        }

        @Override
        public String toString() {
            return this.uuid.toString();
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null) {
                return false;
            }
            if (!this.getClass().equals(obj.getClass())) {
                return false;
            }

            return Objects.equals(this.uuid, ((AlertID) obj).uuid);
        }

        @Override
        public int hashCode() {
            return Objects.hash(getClass(), this.uuid);
        }
    }

    private static class GenericChangeEvent implements AlertChangeEvent {

        private final AlertID alertId;
        private final DateTime changeTime;
        private final State state;
        private final Principal user;
        private final String description;
        private final Object content;


        public GenericChangeEvent(AlertID id, State state) {
            this(id, state, null, null, null);
        }

        public GenericChangeEvent(AlertID alertId, State state, Principal user, String descr, Object content) {
            super();
            this.alertId = alertId;
            this.state = state;
            this.user = user;
            this.description = descr;
            this.content = content;
            this.changeTime = DateTime.now();
        }

        @Override
        public DateTime getChangeTime() {
            return changeTime;
        }

        @Override
        public State getState() {
            return state;
        }

        public AlertID getAlertId() {
            return alertId;
        }

        public Principal getUser() {
            return user;
        }

        @Override
        public String getDescription() {
            return this.description;
        }

        @Override
        @SuppressWarnings("unchecked")
        public <C extends Serializable> C getContent() {
            return (C) this.content;
        }
    }

    private class InternalAlertResponse implements AlertResponse {

        private GenericAlert current;

        public InternalAlertResponse(GenericAlert alert) {
            this.current = alert;
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#inProgress()
         */
        @Override
        public Alert inProgress(String description) {
            return inProgress(description, null);
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#inProgress(java.io.Serializable)
         */
        @Override
        public <C extends Serializable> Alert inProgress(String description, C content) {
            checkCleared();
            this.current = new GenericAlert(this.current, State.IN_PROGRESS, content);
            updated(current);
            return this.current;
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#handle()
         */
        @Override
        public Alert handle(String description) {
            return handle(null);
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#handle(java.io.Serializable)
         */
        @Override
        public <C extends Serializable> Alert handle(String description, C content) {
            checkCleared();
            this.current = new GenericAlert(this.current, State.HANDLED, content);
            updated(current);
            return this.current;
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#unHandle()
         */
        @Override
        public Alert unhandle(String description) {
            return unhandle(description, null);
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.#unHandle(java.io.Serializable)
         */
        @Override
        public <C extends Serializable> Alert unhandle(String description, C content) {
            checkCleared();
            this.current = new GenericAlert(this.current, State.UNHANDLED, content);
            updated(current);
            return this.current;
        }

        /* (non-Javadoc)
         * @see com.thinkbiganalytics.alerts.api.AlertResponse#clear()
         */
        @Override
        public void clear() {
            checkCleared();
            this.current = new GenericAlert(this.current, true);
            this.current = null;
        }

        private void checkCleared() {
            if (this.current == null) {
                throw new IllegalStateException("The alert cannot be updated as it has been already cleared.");
            }
        }
    }

    private class AlertByIdMap extends LinkedHashMap<Alert.ID, AtomicReference<Alert>> {

        @Override
        protected boolean removeEldestEntry(java.util.Map.Entry<ID, AtomicReference<Alert>> eldest) {
            if (this.size() > MAX_ALERTS) {
                InMemoryAlertManager.this.alertsByTime.values().remove(eldest.getValue());
                return true;
            } else {
                return false;
            }
        }
    }

    /**
     * Immutable implementation of an Alert.
     */
    protected class GenericAlert implements Alert {

        private final AlertID id;
        private final URI type;
        private final Level level;
        private final String description;
        private final boolean cleared;
        private final AlertSource source;
        private final Object content;
        private final List<AlertChangeEvent> events;

        public GenericAlert(URI type, Level level, String description, Object content) {
            this.id = new AlertID();
            this.type = type;
            this.level = level;
            this.cleared = false;
            this.description = description;
            this.content = content;
            this.source = InMemoryAlertManager.this;

            if (content != null) {
                this.events = Collections.unmodifiableList(Collections.singletonList(new GenericChangeEvent(this.id, State.UNHANDLED, null, null, content)));
            } else {
                this.events = Collections.emptyList();
            }
        }

        public GenericAlert(URI type, Level level, Object content) {
            this(type, level, "", content);
        }

        public GenericAlert(GenericAlert alert, State newState, Object eventContent) {
            this.id = alert.id;
            this.type = alert.type;
            this.level = alert.level;
            this.description = alert.description;
            this.source = alert.source;
            this.content = alert.content;
            this.cleared = false;

            ArrayList<AlertChangeEvent> evList = new ArrayList<>(alert.events);
            evList.add(0, new GenericChangeEvent(this.id, newState, null, null, eventContent));
            this.events = Collections.unmodifiableList(evList);
        }

        public GenericAlert(GenericAlert alert, boolean cleared) {
            this.id = alert.id;
            this.type = alert.type;
            this.level = alert.level;
            this.description = alert.description;
            this.source = alert.source;
            this.content = alert.content;
            this.events = Collections.unmodifiableList(alert.events);
            this.cleared = cleared;
        }

        @Override
        public ID getId() {
            return this.id;
        }

        @Override
        public URI getType() {
            return this.type;
        }

        @Override
        public String getDescription() {
            return this.description;
        }

        @Override
        public Level getLevel() {
            return this.level;
        }

        @Override
        public State getState() {
            return this.events.get(0).getState();
        }

        @Override
        public DateTime getCreatedTime() {
            return this.events.get(this.events.size() - 1).getChangeTime();
        }

        @Override
        public AlertSource getSource() {
            return this.source;
        }

        @Override
        public boolean isActionable() {
            return true;
        }

        @Override
        public boolean isCleared() {
            return this.cleared;
        }

        @Override
        public List<AlertChangeEvent> getEvents() {
            return this.events;
        }

        @Override
        @SuppressWarnings("unchecked")
        public <C extends Serializable> C getContent() {
            return (C) this.content;
        }
    }

}
