/**
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.  The ASF licenses this file to you 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.
 */

package org.apache.storm.hdfs.bolt;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.storm.hdfs.bolt.format.FileNameFormat;
import org.apache.storm.hdfs.bolt.rotation.FileRotationPolicy;
import org.apache.storm.hdfs.bolt.rotation.TimedRotationPolicy;
import org.apache.storm.hdfs.bolt.sync.SyncPolicy;
import org.apache.storm.hdfs.common.NullPartitioner;
import org.apache.storm.hdfs.common.Partitioner;
import org.apache.storm.hdfs.common.rotation.RotationAction;
import org.apache.storm.hdfs.security.HdfsSecurityUtil;
import org.apache.storm.task.OutputCollector;
import org.apache.storm.task.TopologyContext;
import org.apache.storm.topology.OutputFieldsDeclarer;
import org.apache.storm.topology.base.BaseRichBolt;
import org.apache.storm.tuple.Tuple;
import org.apache.storm.utils.TupleUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public abstract class AbstractHdfsBolt extends BaseRichBolt {
    private static final Logger LOG = LoggerFactory.getLogger(AbstractHdfsBolt.class);
    private static final Integer DEFAULT_RETRY_COUNT = 3;
    /**
     * Half of the default Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS
     */
    private static final int DEFAULT_TICK_TUPLE_INTERVAL_SECS = 15;
    private static final Integer DEFAULT_MAX_OPEN_FILES = 50;

    protected Map<String, Writer> writers;
    protected Map<String, Integer> rotationCounterMap = new HashMap<>();
    protected List<RotationAction> rotationActions = new ArrayList<>();
    protected OutputCollector collector;
    protected transient FileSystem fs;
    protected SyncPolicy syncPolicy;
    protected FileRotationPolicy rotationPolicy;
    protected FileNameFormat fileNameFormat;
    protected String fsUrl;
    protected String configKey;
    protected transient Object writeLock;
    protected transient Timer rotationTimer; // only used for TimedRotationPolicy
    protected long offset = 0;
    protected Integer fileRetryCount = DEFAULT_RETRY_COUNT;
    protected Integer tickTupleInterval = DEFAULT_TICK_TUPLE_INTERVAL_SECS;
    protected Integer maxOpenFiles = DEFAULT_MAX_OPEN_FILES;
    protected Partitioner partitioner = new NullPartitioner();
    protected transient Configuration hdfsConfig;
    private List<Tuple> tupleBatch = new LinkedList<>();

    protected void rotateOutputFile(Writer writer) throws IOException {
        LOG.info("Rotating output file...");
        long start = System.currentTimeMillis();
        synchronized (this.writeLock) {
            writer.close();

            LOG.info("Performing {} file rotation actions.", this.rotationActions.size());
            for (RotationAction action : this.rotationActions) {
                action.execute(this.fs, writer.getFilePath());
            }
        }
        long time = System.currentTimeMillis() - start;
        LOG.info("File rotation took {} ms.", time);
    }

    /**
     * Marked as final to prevent override. Subclasses should implement the doPrepare() method.
     */
    @Override
    public final void prepare(Map<String, Object> conf, TopologyContext topologyContext, OutputCollector collector) {
        this.writeLock = new Object();
        if (this.syncPolicy == null) {
            throw new IllegalStateException("SyncPolicy must be specified.");
        }
        if (this.rotationPolicy == null) {
            throw new IllegalStateException("RotationPolicy must be specified.");
        }
        if (this.fsUrl == null) {
            throw new IllegalStateException("File system URL must be specified.");
        }

        writers = new WritersMap(this.maxOpenFiles, collector);

        this.collector = collector;
        this.fileNameFormat.prepare(conf, topologyContext);
        this.hdfsConfig = new Configuration();
        Map<String, Object> map = (Map<String, Object>) conf.get(this.configKey);
        if (map != null) {
            for (String key : map.keySet()) {
                this.hdfsConfig.set(key, String.valueOf(map.get(key)));
            }
        }

        try {
            HdfsSecurityUtil.login(conf, hdfsConfig);
            doPrepare(conf, topologyContext, collector);
        } catch (Exception e) {
            throw new RuntimeException("Error preparing HdfsBolt: " + e.getMessage(), e);
        }

        if (this.rotationPolicy instanceof TimedRotationPolicy) {
            startTimedRotationPolicy();
        }
    }

    @Override
    public final void execute(Tuple tuple) {

        synchronized (this.writeLock) {
            boolean forceSync = false;
            Writer writer = null;
            String writerKey = null;

            if (TupleUtils.isTick(tuple)) {
                LOG.debug("TICK! forcing a file system flush");
                this.collector.ack(tuple);
                forceSync = true;
            } else {

                writerKey = getHashKeyForTuple(tuple);

                try {
                    writer = getOrCreateWriter(writerKey, tuple);
                    this.offset = writer.write(tuple);
                    tupleBatch.add(tuple);
                } catch (IOException e) {
                    //If the write failed, try to sync anything already written
                    LOG.info("Tuple failed to write, forcing a flush of existing data.");
                    this.collector.reportError(e);
                    forceSync = true;
                    this.collector.fail(tuple);
                }
            }

            if (this.syncPolicy.mark(tuple, this.offset) || (forceSync && tupleBatch.size() > 0)) {
                int attempts = 0;
                boolean success = false;
                IOException lastException = null;
                // Make every attempt to sync the data we have.  If it can't be done then kill the bolt with
                // a runtime exception.  The filesystem is presumably in a very bad state.
                while (success == false && attempts < fileRetryCount) {
                    attempts += 1;
                    try {
                        syncAllWriters();
                        LOG.debug("Data synced to filesystem. Ack'ing [{}] tuples", tupleBatch.size());
                        for (Tuple t : tupleBatch) {
                            this.collector.ack(t);
                        }
                        tupleBatch.clear();
                        syncPolicy.reset();
                        success = true;
                    } catch (IOException e) {
                        LOG.warn("Data could not be synced to filesystem on attempt [{}]", attempts);
                        this.collector.reportError(e);
                        lastException = e;
                    }
                }

                // If unsuccesful fail the pending tuples
                if (success == false) {
                    LOG.warn("Data could not be synced to filesystem, failing this batch of tuples");
                    for (Tuple t : tupleBatch) {
                        this.collector.fail(t);
                    }
                    tupleBatch.clear();

                    throw new RuntimeException("Sync failed [" + attempts + "] times.", lastException);
                }
            }

            if (writer != null && writer.needsRotation()) {
                doRotationAndRemoveWriter(writerKey, writer);
            }
        }
    }

    private Writer getOrCreateWriter(String writerKey, Tuple tuple) throws IOException {
        Writer writer;

        writer = writers.get(writerKey);
        if (writer == null) {
            Path pathForNextFile = getBasePathForNextFile(tuple);
            writer = makeNewWriter(pathForNextFile, tuple);
            writers.put(writerKey, writer);
        }
        return writer;
    }

    /**
     * A tuple must be mapped to a writer based on two factors.
     *  - bolt specific logic that must separate tuples into different files in the same directory (see the avro bolt
     *    for an example of this)
     *  - the directory the tuple will be partioned into
     */
    private String getHashKeyForTuple(Tuple tuple) {
        final String boltKey = getWriterKey(tuple);
        final String partitionDir = this.partitioner.getPartitionPath(tuple);
        return boltKey + "****" + partitionDir;
    }

    void doRotationAndRemoveWriter(String writerKey, Writer writer) {
        try {
            rotateOutputFile(writer);
        } catch (IOException e) {
            this.collector.reportError(e);
            LOG.error("File could not be rotated");
            //At this point there is nothing to do.  In all likelihood any filesystem operations will fail.
            //The next tuple will almost certainly fail to write and/or sync, which force a rotation.  That
            //will give rotateAndReset() a chance to work which includes creating a fresh file handle.
        } finally {
            //rotateOutputFile(writer) has closed the writer. It's safe to remove the writer from the map here.
            writers.remove(writerKey);
        }
    }

    @Override
    public Map<String, Object> getComponentConfiguration() {
        return TupleUtils.putTickFrequencyIntoComponentConfig(super.getComponentConfiguration(), tickTupleInterval);
    }

    @Override
    public void declareOutputFields(OutputFieldsDeclarer outputFieldsDeclarer) {
    }

    @Override
    public void cleanup() {
        doRotationAndRemoveAllWriters();
        if (this.rotationTimer != null) {
            this.rotationTimer.cancel();
        }
    }

    private void doRotationAndRemoveAllWriters() {
        synchronized (writeLock) {
            for (final Writer writer : writers.values()) {
                try {
                    rotateOutputFile(writer);
                } catch (IOException e) {
                    this.collector.reportError(e);
                    LOG.warn("IOException during scheduled file rotation.", e);
                }
            }
            //above for-loop has closed all the writers. It's safe to clear the map here.
            writers.clear();
        }
    }

    private void syncAllWriters() throws IOException {
        for (Writer writer : writers.values()) {
            writer.sync();
        }
    }

    private void startTimedRotationPolicy() {
        long interval = ((TimedRotationPolicy) this.rotationPolicy).getInterval();
        this.rotationTimer = new Timer(true);
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                doRotationAndRemoveAllWriters();
            }
        };
        this.rotationTimer.scheduleAtFixedRate(task, interval, interval);
    }

    protected Path getBasePathForNextFile(Tuple tuple) {

        final String partitionPath = this.partitioner.getPartitionPath(tuple);
        final int rotation;
        if (rotationCounterMap.containsKey(partitionPath)) {
            rotation = rotationCounterMap.get(partitionPath) + 1;
        } else {
            rotation = 0;
        }
        rotationCounterMap.put(partitionPath, rotation);

        return new Path(this.fsUrl + this.fileNameFormat.getPath() + partitionPath,
                        this.fileNameFormat.getName(rotation, System.currentTimeMillis()));
    }

    protected abstract void doPrepare(Map<String, Object> conf, TopologyContext topologyContext, OutputCollector collector) throws
        IOException;

    protected abstract String getWriterKey(Tuple tuple);

    protected abstract Writer makeNewWriter(Path path, Tuple tuple) throws IOException;

    static class WritersMap extends LinkedHashMap<String, Writer> {
        final long maxWriters;
        final OutputCollector collector;

        WritersMap(long maxWriters, OutputCollector collector) {
            super((int) maxWriters, 0.75f, true);
            this.maxWriters = maxWriters;
            this.collector = collector;
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<String, Writer> eldest) {
            if (this.size() > this.maxWriters) {
                //The writer must be closed before removed from the map.
                //If it failed, we might lose some data.
                try {
                    eldest.getValue().close();
                } catch (IOException e) {
                    collector.reportError(e);
                    LOG.error("Failed to close the eldest Writer");
                }
                return true;
            } else {
                return false;
            }
        }
    }
}
