/*
 * 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.eclipse.aether.connector.basic;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.aether.internal.test.util.TestChecksumProcessor;
import org.eclipse.aether.internal.test.util.TestFileUtils;
import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
import org.eclipse.aether.spi.connector.checksum.ChecksumPolicy;
import org.eclipse.aether.spi.connector.checksum.ChecksumPolicy.ChecksumKind;
import org.eclipse.aether.spi.connector.layout.RepositoryLayout;
import org.eclipse.aether.spi.io.PathProcessorSupport;
import org.eclipse.aether.transfer.ChecksumFailureException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.eclipse.aether.connector.basic.TestChecksumAlgorithmSelector.MD5;
import static org.eclipse.aether.connector.basic.TestChecksumAlgorithmSelector.SHA1;
import static org.junit.jupiter.api.Assertions.*;

public class ChecksumValidatorTest {

    private static class StubChecksumPolicy implements ChecksumPolicy {

        boolean inspectAll;

        boolean tolerateFailure;

        private final ArrayList<String> callbacks = new ArrayList<>();

        private Object conclusion;

        @Override
        public boolean onChecksumMatch(String algorithm, ChecksumKind kind) {
            callbacks.add(String.format("match(%s, %s)", algorithm, kind));
            if (inspectAll) {
                if (conclusion == null) {
                    conclusion = true;
                }
                return false;
            }
            return true;
        }

        @Override
        public void onChecksumMismatch(String algorithm, ChecksumKind kind, ChecksumFailureException exception)
                throws ChecksumFailureException {
            callbacks.add(String.format("mismatch(%s, %s)", algorithm, kind));
            if (inspectAll) {
                conclusion = exception;
                return;
            }
            throw exception;
        }

        @Override
        public void onChecksumError(String algorithm, ChecksumKind kind, ChecksumFailureException exception) {
            callbacks.add(String.format(
                    "error(%s, %s, %s)", algorithm, kind, exception.getCause().getMessage()));
        }

        @Override
        public void onNoMoreChecksums() throws ChecksumFailureException {
            callbacks.add("noMore()");
            if (conclusion instanceof ChecksumFailureException) {
                throw (ChecksumFailureException) conclusion;
            } else if (!Boolean.TRUE.equals(conclusion)) {
                throw new ChecksumFailureException("no checksums");
            }
        }

        @Override
        public void onTransferRetry() {
            callbacks.add("retry()");
        }

        @Override
        public boolean onTransferChecksumFailure(ChecksumFailureException exception) {
            callbacks.add(String.format("fail(%s)", exception.getMessage()));
            return tolerateFailure;
        }

        void assertCallbacks(String... callbacks) {
            assertEquals(Arrays.asList(callbacks), this.callbacks);
        }
    }

    private static class StubChecksumFetcher implements ChecksumValidator.ChecksumFetcher {

        HashMap<URI, Object> checksums = new HashMap<>();

        ArrayList<Path> checksumFiles = new ArrayList<>();

        private final ArrayList<URI> fetchedFiles = new ArrayList<>();

        @Override
        public boolean fetchChecksum(URI remote, Path local) throws Exception {
            fetchedFiles.add(remote);
            Object checksum = checksums.get(remote);
            if (checksum == null) {
                return false;
            }
            if (checksum instanceof Exception) {
                throw (Exception) checksum;
            }
            TestFileUtils.writeString(local.toFile(), checksum.toString());
            checksumFiles.add(local);
            return true;
        }

        void mock(String algo, Object value) {
            checksums.put(toUri(algo), value);
        }

        void assertFetchedFiles(String... algos) {
            List<URI> expected = new ArrayList<>();
            for (String algo : algos) {
                expected.add(toUri(algo));
            }
            assertEquals(expected, fetchedFiles);
        }

        private static URI toUri(String algo) {
            return newChecksum(algo).getLocation();
        }
    }

    private StubChecksumPolicy policy;

    private StubChecksumFetcher fetcher;

    private File dataFile;

    private static final TestChecksumAlgorithmSelector selector = new TestChecksumAlgorithmSelector();

    private List<ChecksumAlgorithmFactory> newChecksumAlgorithmFactories(String... factories) {
        List<ChecksumAlgorithmFactory> checksums = new ArrayList<>();
        for (String factory : factories) {
            checksums.add(selector.select(factory));
        }
        return checksums;
    }

    private static RepositoryLayout.ChecksumLocation newChecksum(String factory) {
        return RepositoryLayout.ChecksumLocation.forLocation(URI.create("file"), selector.select(factory));
    }

    private List<RepositoryLayout.ChecksumLocation> newChecksums(
            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
        List<RepositoryLayout.ChecksumLocation> checksums = new ArrayList<>();
        for (ChecksumAlgorithmFactory factory : checksumAlgorithmFactories) {
            checksums.add(RepositoryLayout.ChecksumLocation.forLocation(URI.create("file"), factory));
        }
        return checksums;
    }

    private ChecksumValidator newValidator(String... factories) {
        return newValidator(null, factories);
    }

    private ChecksumValidator newValidator(Map<String, String> providedChecksums, String... factories) {
        List<ChecksumAlgorithmFactory> checksumAlgorithmFactories = newChecksumAlgorithmFactories(factories);
        return new ChecksumValidator(
                dataFile.toPath(),
                checksumAlgorithmFactories,
                new PathProcessorSupport(),
                new TestChecksumProcessor(),
                fetcher,
                policy,
                providedChecksums,
                newChecksums(checksumAlgorithmFactories));
    }

    private Map<String, ?> checksums(String... algoDigestPairs) {
        Map<String, Object> checksums = new LinkedHashMap<>();
        for (int i = 0; i < algoDigestPairs.length; i += 2) {
            String algo = algoDigestPairs[i];
            String digest = algoDigestPairs[i + 1];
            if (digest == null) {
                checksums.put(algo, new IOException("error"));
            } else {
                checksums.put(algo, digest);
            }
        }
        return checksums;
    }

    @BeforeEach
    void init() throws Exception {
        dataFile = TestFileUtils.createTempFile("");
        dataFile.delete();
        policy = new StubChecksumPolicy();
        fetcher = new StubChecksumFetcher();
    }

    @Test
    void testValidate_NullPolicy() throws Exception {
        policy = null;
        ChecksumValidator validator = newValidator(SHA1);
        validator.validate(checksums(SHA1, "ignored"), null);
        fetcher.assertFetchedFiles();
    }

    @Test
    void testValidate_AcceptOnFirstMatch() throws Exception {
        ChecksumValidator validator = newValidator(SHA1);
        fetcher.mock(SHA1, "foo");
        validator.validate(checksums(SHA1, "foo"), null);
        fetcher.assertFetchedFiles(SHA1);
        policy.assertCallbacks("match(SHA-1, REMOTE_EXTERNAL)");
    }

    @Test
    void testValidate_FailOnFirstMismatch() {
        ChecksumValidator validator = newValidator(SHA1);
        fetcher.mock(SHA1, "foo");
        try {
            validator.validate(checksums(SHA1, "not-foo"), null);
            fail("expected exception");
        } catch (ChecksumFailureException e) {
            assertEquals("foo", e.getExpected());
            assertEquals(ChecksumKind.REMOTE_EXTERNAL.name(), e.getExpectedKind());
            assertEquals("not-foo", e.getActual());
            assertTrue(e.isRetryWorthy());
        }
        fetcher.assertFetchedFiles(SHA1);
        policy.assertCallbacks("mismatch(SHA-1, REMOTE_EXTERNAL)");
    }

    @Test
    void testValidate_AcceptOnEnd() throws Exception {
        policy.inspectAll = true;
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(SHA1, "foo");
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(SHA1, "foo", MD5, "bar"), null);
        fetcher.assertFetchedFiles(SHA1, MD5);
        policy.assertCallbacks("match(SHA-1, REMOTE_EXTERNAL)", "match(MD5, REMOTE_EXTERNAL)", "noMore()");
    }

    @Test
    void testValidate_FailOnEnd() {
        policy.inspectAll = true;
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(SHA1, "foo");
        fetcher.mock(MD5, "bar");
        try {
            validator.validate(checksums(SHA1, "not-foo", MD5, "bar"), null);
            fail("expected exception");
        } catch (ChecksumFailureException e) {
            assertEquals("foo", e.getExpected());
            assertEquals(ChecksumKind.REMOTE_EXTERNAL.name(), e.getExpectedKind());
            assertEquals("not-foo", e.getActual());
            assertTrue(e.isRetryWorthy());
        }
        fetcher.assertFetchedFiles(SHA1, MD5);
        policy.assertCallbacks("mismatch(SHA-1, REMOTE_EXTERNAL)", "match(MD5, REMOTE_EXTERNAL)", "noMore()");
    }

    @Test
    void testValidate_IncludedBeforeExternal() throws Exception {
        policy.inspectAll = true;
        HashMap<String, String> provided = new HashMap<>();
        provided.put(SHA1, "foo");
        ChecksumValidator validator = newValidator(provided, SHA1, MD5);
        fetcher.mock(SHA1, "foo");
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(SHA1, "foo", MD5, "bar"), checksums(SHA1, "foo", MD5, "bar"));
        fetcher.assertFetchedFiles(SHA1, MD5);
        policy.assertCallbacks(
                "match(SHA-1, PROVIDED)",
                "match(SHA-1, REMOTE_INCLUDED)",
                "match(MD5, REMOTE_INCLUDED)",
                "match(SHA-1, REMOTE_EXTERNAL)",
                "match(MD5, REMOTE_EXTERNAL)",
                "noMore()");
    }

    @Test
    void testValidate_CaseInsensitive() throws Exception {
        policy.inspectAll = true;
        ChecksumValidator validator = newValidator(SHA1);
        fetcher.mock(SHA1, "FOO");
        validator.validate(checksums(SHA1, "foo"), checksums(SHA1, "foo"));
        policy.assertCallbacks("match(SHA-1, REMOTE_INCLUDED)", "match(SHA-1, REMOTE_EXTERNAL)", "noMore()");
    }

    @Test
    void testValidate_MissingRemoteChecksum() throws Exception {
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(MD5, "bar"), null);
        fetcher.assertFetchedFiles(SHA1, MD5);
        policy.assertCallbacks("match(MD5, REMOTE_EXTERNAL)");
    }

    @Test
    void testValidate_InaccessibleRemoteChecksum() throws Exception {
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(SHA1, new IOException("inaccessible"));
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(MD5, "bar"), null);
        fetcher.assertFetchedFiles(SHA1, MD5);
        policy.assertCallbacks("error(SHA-1, REMOTE_EXTERNAL, inaccessible)", "match(MD5, REMOTE_EXTERNAL)");
    }

    @Test
    void testValidate_InaccessibleLocalChecksum() throws Exception {
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(SHA1, "foo");
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(SHA1, null, MD5, "bar"), null);
        fetcher.assertFetchedFiles(MD5);
        policy.assertCallbacks("error(SHA-1, REMOTE_EXTERNAL, error)", "match(MD5, REMOTE_EXTERNAL)");
    }

    @Test
    void testHandle_Accept() {
        policy.tolerateFailure = true;
        ChecksumValidator validator = newValidator(SHA1);
        assertTrue(validator.handle(new ChecksumFailureException("accept")));
        policy.assertCallbacks("fail(accept)");
    }

    @Test
    void testHandle_Reject() {
        policy.tolerateFailure = false;
        ChecksumValidator validator = newValidator(SHA1);
        assertFalse(validator.handle(new ChecksumFailureException("reject")));
        policy.assertCallbacks("fail(reject)");
    }

    @Test
    void testRetry_ResetPolicy() {
        ChecksumValidator validator = newValidator(SHA1);
        validator.retry();
        policy.assertCallbacks("retry()");
    }

    @Test
    void testRetry_RemoveTempFiles() throws Exception {
        ChecksumValidator validator = newValidator(SHA1);
        fetcher.mock(SHA1, "foo");
        validator.validate(checksums(SHA1, "foo"), null);
        fetcher.assertFetchedFiles(SHA1);
        assertEquals(1, fetcher.checksumFiles.size());
        validator.retry();
        for (Path file : fetcher.checksumFiles) {
            assertFalse(Files.exists(file), file.toAbsolutePath().toString());
        }
    }

    @Test
    void testCommit_SaveChecksumFiles() throws Exception {
        policy.inspectAll = true;
        ChecksumValidator validator = newValidator(SHA1, MD5);
        fetcher.mock(MD5, "bar");
        validator.validate(checksums(SHA1, "foo", MD5, "bar"), checksums(SHA1, "foo"));
        assertEquals(1, fetcher.checksumFiles.size());
        validator.commit();
        File checksumFile = new File(dataFile.getPath() + ".sha1");
        assertTrue(checksumFile.isFile(), checksumFile.getAbsolutePath());
        assertEquals("foo", TestFileUtils.readString(checksumFile));
        checksumFile = new File(dataFile.getPath() + ".md5");
        assertTrue(checksumFile.isFile(), checksumFile.getAbsolutePath());
        assertEquals("bar", TestFileUtils.readString(checksumFile));
        for (Path file : fetcher.checksumFiles) {
            assertFalse(Files.exists(file), file.toAbsolutePath().toString());
        }
    }

    @Test
    void testNoCommit_NoTempFiles() throws Exception {
        ChecksumValidator validator = newValidator(SHA1);
        fetcher.mock(SHA1, "foo");
        validator.validate(checksums(SHA1, "foo"), null);
        fetcher.assertFetchedFiles(SHA1);
        assertEquals(1, fetcher.checksumFiles.size());
        for (Path file : fetcher.checksumFiles) {
            assertFalse(Files.exists(file), file.toAbsolutePath().toString());
        }
    }
}
