package org.fdroid.fdroid.net;

import android.net.Uri;
import android.text.format.DateUtils;

import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.Utils;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ConnectException;
import java.util.Timer;
import java.util.TimerTask;

import androidx.annotation.NonNull;

public abstract class Downloader {

    private static final String TAG = "Downloader";

    public static final String ACTION_STARTED = "org.fdroid.fdroid.net.Downloader.action.STARTED";
    public static final String ACTION_PROGRESS = "org.fdroid.fdroid.net.Downloader.action.PROGRESS";
    public static final String ACTION_INTERRUPTED = "org.fdroid.fdroid.net.Downloader.action.INTERRUPTED";
    public static final String ACTION_CONNECTION_FAILED = "org.fdroid.fdroid.net.Downloader.action.CONNECTION_FAILED";
    public static final String ACTION_COMPLETE = "org.fdroid.fdroid.net.Downloader.action.COMPLETE";

    public static final String EXTRA_DOWNLOAD_PATH = "org.fdroid.fdroid.net.Downloader.extra.DOWNLOAD_PATH";
    public static final String EXTRA_BYTES_READ = "org.fdroid.fdroid.net.Downloader.extra.BYTES_READ";
    public static final String EXTRA_TOTAL_BYTES = "org.fdroid.fdroid.net.Downloader.extra.TOTAL_BYTES";
    public static final String EXTRA_ERROR_MESSAGE = "org.fdroid.fdroid.net.Downloader.extra.ERROR_MESSAGE";
    public static final String EXTRA_REPO_ID = "org.fdroid.fdroid.net.Downloader.extra.REPO_ID";
    public static final String EXTRA_MIRROR_URL = "org.fdroid.fdroid.net.Downloader.extra.MIRROR_URL";
    /**
     * Unique ID used to represent this specific package's install process,
     * including {@link android.app.Notification}s, also known as {@code canonicalUrl}.
     * Careful about types, this should always be a {@link String}, so it can
     * be handled on the receiving side by {@link android.content.Intent#getStringArrayExtra(String)}.
     *
     * @see org.fdroid.fdroid.installer.InstallManagerService
     * @see android.content.Intent#EXTRA_ORIGINATING_URI
     */
    public static final String EXTRA_CANONICAL_URL = "org.fdroid.fdroid.net.Downloader.extra.CANONICAL_URL";

    public static final int DEFAULT_TIMEOUT = 10000;
    public static final int SECOND_TIMEOUT = (int) DateUtils.MINUTE_IN_MILLIS;
    public static final int LONGEST_TIMEOUT = 600000; // 10 minutes

    private volatile boolean cancelled = false;
    private volatile long bytesRead;
    private volatile long totalBytes;

    public final File outputFile;

    final String urlString;
    String cacheTag;
    boolean notFound;

    private volatile int timeout = DEFAULT_TIMEOUT;

    /**
     * For sending download progress, should only be called in {@link #progressTask}
     */
    private volatile ProgressListener downloaderProgressListener;

    protected abstract InputStream getDownloadersInputStream() throws IOException;

    protected abstract void close();

    Downloader(Uri uri, File destFile) {
        this.urlString = uri.toString();
        outputFile = destFile;
    }

    public final InputStream getInputStream() throws IOException {
        return new WrappedInputStream(getDownloadersInputStream());
    }

    public void setListener(ProgressListener listener) {
        this.downloaderProgressListener = listener;
    }

    public void setTimeout(int ms) {
        timeout = ms;
    }

    public int getTimeout() {
        return timeout;
    }

    /**
     * If you ask for the cacheTag before calling download(), you will get the
     * same one you passed in (if any). If you call it after download(), you
     * will get the new cacheTag from the server, or null if there was none.
     */
    public String getCacheTag() {
        return cacheTag;
    }

    /**
     * If this cacheTag matches that returned by the server, then no download will
     * take place, and a status code of 304 will be returned by download().
     */
    public void setCacheTag(String cacheTag) {
        this.cacheTag = cacheTag;
    }

    public abstract boolean hasChanged();

    protected abstract long totalDownloadSize();

    public abstract void download() throws ConnectException, IOException, InterruptedException;

    /**
     * @return whether the requested file was not found in the repo (e.g. HTTP 404 Not Found)
     */
    public boolean isNotFound() {
        return notFound;
    }

    void downloadFromStream(boolean resumable) throws IOException, InterruptedException {
        Utils.debugLog(TAG, "Downloading from stream");
        InputStream input = null;
        OutputStream outputStream = new FileOutputStream(outputFile, resumable);
        try {
            input = getInputStream();

            // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if
            // we were interrupted before proceeding to the download.
            throwExceptionIfInterrupted();

            copyInputToOutputStream(input, 8192, outputStream);
        } finally {
            Utils.closeQuietly(outputStream);
            Utils.closeQuietly(input);
        }

        // Even if we have completely downloaded the file, we should probably respect
        // the wishes of the user who wanted to cancel us.
        throwExceptionIfInterrupted();
    }

    /**
     * After every network operation that could take a while, we will check if an
     * interrupt occurred during that blocking operation. The goal is to ensure we
     * don't move onto another slow, network operation if we have cancelled the
     * download.
     *
     * @throws InterruptedException
     */
    private void throwExceptionIfInterrupted() throws InterruptedException {
        if (cancelled) {
            Utils.debugLog(TAG, "Received interrupt, cancelling download");
            throw new InterruptedException();
        }
    }

    /**
     * Cancel a running download, triggering an {@link InterruptedException}
     */
    public void cancelDownload() {
        cancelled = true;
    }

    /**
     * This copies the downloaded data from the InputStream to the OutputStream,
     * keeping track of the number of bytes that have flowed through for the
     * progress counter.
     */
    private void copyInputToOutputStream(InputStream input, int bufferSize, OutputStream output)
            throws IOException, InterruptedException {
        Timer timer = new Timer();
        try {
            bytesRead = outputFile.length();
            totalBytes = totalDownloadSize();
            byte[] buffer = new byte[bufferSize];

            timer.scheduleAtFixedRate(progressTask, 0, 100);

            // Getting the total download size could potentially take time, depending on how
            // it is implemented, so we may as well check this before we proceed.
            throwExceptionIfInterrupted();

            while (true) {

                int count;
                if (input.available() > 0) {
                    int readLength = Math.min(input.available(), buffer.length);
                    count = input.read(buffer, 0, readLength);
                } else {
                    count = input.read(buffer);
                }

                throwExceptionIfInterrupted();

                if (count == -1) {
                    Utils.debugLog(TAG, "Finished downloading from stream");
                    break;
                }
                bytesRead += count;
                output.write(buffer, 0, count);
            }
        } finally {
            downloaderProgressListener = null;
            timer.cancel();
            timer.purge();
            output.flush();
            output.close();
        }
    }

    /**
     * Send progress updates on a timer to avoid flooding receivers with pointless events.
     */
    private final TimerTask progressTask = new TimerTask() {
        private long lastBytesRead = Long.MIN_VALUE;
        private long lastTotalBytes = Long.MIN_VALUE;

        @Override
        public void run() {
            if (downloaderProgressListener != null
                    && (bytesRead != lastBytesRead || totalBytes != lastTotalBytes)) {
                downloaderProgressListener.onProgress(bytesRead, totalBytes);
                lastBytesRead = bytesRead;
                lastTotalBytes = totalBytes;
            }
        }
    };

    /**
     * Overrides every method in {@link InputStream} and delegates to the wrapped stream.
     * The only difference is that when we call the {@link WrappedInputStream#close()} method,
     * after delegating to the wrapped stream we invoke the {@link Downloader#close()} method
     * on the {@link Downloader} which created this.
     */
    private class WrappedInputStream extends InputStream {

        private final InputStream toWrap;

        WrappedInputStream(InputStream toWrap) {
            super();
            this.toWrap = toWrap;
        }

        @Override
        public void close() throws IOException {
            toWrap.close();
            Downloader.this.close();
        }

        @Override
        public int available() throws IOException {
            return toWrap.available();
        }

        @Override
        public void mark(int readlimit) {
            toWrap.mark(readlimit);
        }

        @Override
        public boolean markSupported() {
            return toWrap.markSupported();
        }

        @Override
        public int read(@NonNull byte[] buffer) throws IOException {
            return toWrap.read(buffer);
        }

        @Override
        public int read(@NonNull byte[] buffer, int byteOffset, int byteCount) throws IOException {
            return toWrap.read(buffer, byteOffset, byteCount);
        }

        @Override
        public synchronized void reset() throws IOException {
            toWrap.reset();
        }

        @Override
        public long skip(long byteCount) throws IOException {
            return toWrap.skip(byteCount);
        }

        @Override
        public int read() throws IOException {
            return toWrap.read();
        }
    }
}
