/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.
 */
package androidx.media3.datasource.cache;

import static androidx.media3.test.utils.TestUtil.createTestFile;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;

import android.net.Uri;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import androidx.media3.common.util.Util;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.Set;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Tests {@link CachedContentIndex}. */
@RunWith(AndroidJUnit4.class)
public class CachedContentIndexTest {

  private final byte[] testIndexV1File = {
    0,
    0,
    0,
    1, // version
    0,
    0,
    0,
    0, // flags
    0,
    0,
    0,
    2, // number_of_CachedContent
    0,
    0,
    0,
    5, // cache_id 5
    0,
    5,
    65,
    66,
    67,
    68,
    69, // cache_key "ABCDE"
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    10, // original_content_length
    0,
    0,
    0,
    2, // cache_id 2
    0,
    5,
    75,
    76,
    77,
    78,
    79, // cache_key "KLMNO"
    0,
    0,
    0,
    0,
    0,
    0,
    10,
    0, // original_content_length
    (byte) 0xF6,
    (byte) 0xFB,
    0x50,
    0x41 // hashcode_of_CachedContent_array
  };

  private final byte[] testIndexV2File = {
    0,
    0,
    0,
    2, // version
    0,
    0,
    0,
    0, // flags
    0,
    0,
    0,
    2, // number_of_CachedContent
    0,
    0,
    0,
    5, // cache_id 5
    0,
    5,
    65,
    66,
    67,
    68,
    69, // cache_key "ABCDE"
    0,
    0,
    0,
    2, // metadata count
    0,
    9,
    101,
    120,
    111,
    95,
    114,
    101,
    100,
    105,
    114, // "exo_redir"
    0,
    0,
    0,
    5, // value length
    97,
    98,
    99,
    100,
    101, // Redirected Uri "abcde"
    0,
    7,
    101,
    120,
    111,
    95,
    108,
    101,
    110, // "exo_len"
    0,
    0,
    0,
    8, // value length
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    10, // original_content_length
    0,
    0,
    0,
    2, // cache_id 2
    0,
    5,
    75,
    76,
    77,
    78,
    79, // cache_key "KLMNO"
    0,
    0,
    0,
    1, // metadata count
    0,
    7,
    101,
    120,
    111,
    95,
    108,
    101,
    110, // "exo_len"
    0,
    0,
    0,
    8, // value length
    0,
    0,
    0,
    0,
    0,
    0,
    10,
    0, // original_content_length
    0x12,
    0x15,
    0x66,
    (byte) 0x8A // hashcode_of_CachedContent_array
  };
  private File cacheDir;

  @Before
  public void setUp() throws Exception {
    cacheDir =
        Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest");
  }

  @After
  public void tearDown() {
    Util.recursiveDelete(cacheDir);
  }

  @Test
  public void addGetRemove() throws Exception {
    final String key1 = "key1";
    final String key2 = "key2";
    final String key3 = "key3";

    CachedContentIndex index = newInstance();

    // Add two CachedContents with add methods
    CachedContent cachedContent1 = index.getOrAdd(key1);
    CachedContent cachedContent2 = index.getOrAdd(key2);
    assertThat(cachedContent1.id != cachedContent2.id).isTrue();

    // add a span
    int cacheFileLength = 20;
    File cacheSpanFile =
        SimpleCacheSpan.getCacheFile(
            cacheDir, cachedContent1.id, /* position= */ 10, /* timestamp= */ 30);
    createTestFile(cacheSpanFile, cacheFileLength);
    SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(cacheSpanFile, cacheFileLength, index);
    assertThat(span).isNotNull();
    cachedContent1.addSpan(span);

    // Check if they are added and get method returns null if the key isn't found
    assertThat(index.get(key1)).isEqualTo(cachedContent1);
    assertThat(index.get(key2)).isEqualTo(cachedContent2);
    assertThat(index.get(key3)).isNull();

    // test getAll()
    Collection<CachedContent> cachedContents = index.getAll();
    assertThat(cachedContents).containsExactly(cachedContent1, cachedContent2);

    // test getKeys()
    Set<String> keys = index.getKeys();
    assertThat(keys).containsExactly(key1, key2);

    // test getKeyForId()
    assertThat(index.getKeyForId(cachedContent1.id)).isEqualTo(key1);
    assertThat(index.getKeyForId(cachedContent2.id)).isEqualTo(key2);

    // test remove()
    index.maybeRemove(key2);
    index.maybeRemove(key3);
    assertThat(index.get(key1)).isEqualTo(cachedContent1);
    assertThat(index.get(key2)).isNull();
    assertThat(cacheSpanFile.exists()).isTrue();

    // test removeEmpty()
    index.getOrAdd(key2);
    index.removeEmpty();
    assertThat(index.get(key1)).isEqualTo(cachedContent1);
    assertThat(index.get(key2)).isNull();
    assertThat(cacheSpanFile.exists()).isTrue();
  }

  @Test
  public void legacyStoreAndLoad() throws Exception {
    assertStoredAndLoadedEqual(newLegacyInstance(), newLegacyInstance());
  }

  @Test
  public void legacyLoadV1() throws Exception {
    CachedContentIndex index = newLegacyInstance();

    FileOutputStream fos =
        new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC));
    fos.write(testIndexV1File);
    fos.close();

    index.initialize(/* uid= */ 0);
    assertThat(index.getAll()).hasSize(2);

    assertThat(index.assignIdForKey("ABCDE")).isEqualTo(5);
    ContentMetadata metadata = index.get("ABCDE").getMetadata();
    assertThat(ContentMetadata.getContentLength(metadata)).isEqualTo(10);

    assertThat(index.assignIdForKey("KLMNO")).isEqualTo(2);
    ContentMetadata metadata2 = index.get("KLMNO").getMetadata();
    assertThat(ContentMetadata.getContentLength(metadata2)).isEqualTo(2560);
  }

  @Test
  public void legacyLoadV2() throws Exception {
    CachedContentIndex index = newLegacyInstance();

    FileOutputStream fos =
        new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC));
    fos.write(testIndexV2File);
    fos.close();

    index.initialize(/* uid= */ 0);
    assertThat(index.getAll()).hasSize(2);

    assertThat(index.assignIdForKey("ABCDE")).isEqualTo(5);
    ContentMetadata metadata = index.get("ABCDE").getMetadata();
    assertThat(ContentMetadata.getContentLength(metadata)).isEqualTo(10);
    assertThat(ContentMetadata.getRedirectedUri(metadata)).isEqualTo(Uri.parse("abcde"));

    assertThat(index.assignIdForKey("KLMNO")).isEqualTo(2);
    ContentMetadata metadata2 = index.get("KLMNO").getMetadata();
    assertThat(ContentMetadata.getContentLength(metadata2)).isEqualTo(2560);
  }

  @Test
  public void assignIdForKeyAndGetKeyForId() {
    CachedContentIndex index = newInstance();
    final String key1 = "key1";
    final String key2 = "key2";
    int id1 = index.assignIdForKey(key1);
    int id2 = index.assignIdForKey(key2);
    assertThat(index.getKeyForId(id1)).isEqualTo(key1);
    assertThat(index.getKeyForId(id2)).isEqualTo(key2);
    assertThat(id1 != id2).isTrue();
    assertThat(index.assignIdForKey(key1)).isEqualTo(id1);
    assertThat(index.assignIdForKey(key2)).isEqualTo(id2);
  }

  @Test
  public void getNewId() {
    SparseArray<String> idToKey = new SparseArray<>();
    assertThat(CachedContentIndex.getNewId(idToKey)).isEqualTo(0);
    idToKey.put(10, "");
    assertThat(CachedContentIndex.getNewId(idToKey)).isEqualTo(11);
    idToKey.put(Integer.MAX_VALUE, "");
    assertThat(CachedContentIndex.getNewId(idToKey)).isEqualTo(0);
    idToKey.put(0, "");
    assertThat(CachedContentIndex.getNewId(idToKey)).isEqualTo(1);
  }

  @Test
  public void legacyEncryption() throws Exception {
    byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key
    byte[] key2 = Util.getUtf8Bytes("Foo12345Foo12345"); // 128 bit key

    assertStoredAndLoadedEqual(newLegacyInstance(key), newLegacyInstance(key));

    // Rename the index file from the test above
    File file1 = new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC);
    File file2 = new File(cacheDir, "file2compare");
    assertThat(file1.renameTo(file2)).isTrue();

    // Write a new index file
    assertStoredAndLoadedEqual(newLegacyInstance(key), newLegacyInstance(key));

    assertThat(file1.length()).isEqualTo(file2.length());
    // Assert file content is different
    FileInputStream fis1 = new FileInputStream(file1);
    FileInputStream fis2 = new FileInputStream(file2);
    for (int b; (b = fis1.read()) == fis2.read(); ) {
      assertThat(b != -1).isTrue();
    }

    boolean threw = false;
    try {
      assertStoredAndLoadedEqual(newLegacyInstance(key), newLegacyInstance(key2));
    } catch (AssertionError e) {
      threw = true;
    }
    assertWithMessage("Encrypted index file can not be read with different encryption key")
        .that(threw)
        .isTrue();

    try {
      assertStoredAndLoadedEqual(newLegacyInstance(key), newLegacyInstance());
    } catch (AssertionError e) {
      threw = true;
    }
    assertWithMessage("Encrypted index file can not be read without encryption key")
        .that(threw)
        .isTrue();

    // Non encrypted index file can be read even when encryption key provided.
    assertStoredAndLoadedEqual(newLegacyInstance(), newLegacyInstance(key));

    // Test multiple store() calls
    CachedContentIndex index = newLegacyInstance(key);
    index.getOrAdd("key3");
    index.store();
    assertStoredAndLoadedEqual(index, newLegacyInstance(key));
  }

  @Test
  public void removeEmptyNotLockedCachedContent() {
    CachedContentIndex index = newInstance();
    CachedContent cachedContent = index.getOrAdd("key1");

    index.maybeRemove(cachedContent.key);

    assertThat(index.get(cachedContent.key)).isNull();
  }

  @Test
  public void cantRemoveNotEmptyCachedContent() throws Exception {
    CachedContentIndex index = newInstance();

    CachedContent cachedContent = index.getOrAdd("key1");
    long cacheFileLength = 20;
    File cacheFile =
        SimpleCacheSpan.getCacheFile(
            cacheDir, cachedContent.id, /* position= */ 10, /* timestamp= */ 30);
    createTestFile(cacheFile, cacheFileLength);
    SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(cacheFile, cacheFileLength, index);
    cachedContent.addSpan(span);

    index.maybeRemove(cachedContent.key);

    assertThat(index.get(cachedContent.key)).isNotNull();
  }

  @Test
  public void cantRemoveLockedCachedContent() {
    CachedContentIndex index = newInstance();
    CachedContent cachedContent = index.getOrAdd("key1");
    cachedContent.lockRange(0, 1);

    index.maybeRemove(cachedContent.key);

    assertThat(index.get(cachedContent.key)).isNotNull();
  }

  private void assertStoredAndLoadedEqual(CachedContentIndex index, CachedContentIndex index2)
      throws IOException {
    ContentMetadataMutations mutations1 = new ContentMetadataMutations();
    ContentMetadataMutations.setContentLength(mutations1, 2560);
    index.getOrAdd("KLMNO").applyMetadataMutations(mutations1);
    ContentMetadataMutations mutations2 = new ContentMetadataMutations();
    ContentMetadataMutations.setContentLength(mutations2, 10);
    ContentMetadataMutations.setRedirectedUri(mutations2, Uri.parse("abcde"));
    index.getOrAdd("ABCDE").applyMetadataMutations(mutations2);
    index.store();

    index2.initialize(/* uid= */ 0);
    Set<String> keys = index.getKeys();
    Set<String> keys2 = index2.getKeys();
    assertThat(keys2).isEqualTo(keys);
    for (String key : keys) {
      assertThat(index2.get(key)).isEqualTo(index.get(key));
    }
  }

  private CachedContentIndex newInstance() {
    return new CachedContentIndex(TestUtil.getInMemoryDatabaseProvider());
  }

  private CachedContentIndex newLegacyInstance() {
    return newLegacyInstance(null);
  }

  private CachedContentIndex newLegacyInstance(@Nullable byte[] key) {
    return new CachedContentIndex(
        /* databaseProvider= */ null,
        cacheDir,
        /* legacyStorageSecretKey= */ key,
        /* legacyStorageEncrypt= */ key != null,
        /* preferLegacyStorage= */ true);
  }
}
