# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors


import asyncio
import copy
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
import threading

import pytest
import pyarrow as pa
import lancedb


# These are all keys that are accepted by storage_options
CONFIG = {
    "allow_http": "true",
    "aws_access_key_id": "ACCESSKEY",
    "aws_secret_access_key": "SECRETKEY",
    "aws_endpoint": "http://localhost:4566",
    "dynamodb_endpoint": "http://localhost:4566",
    "aws_region": "us-east-1",
}


def get_boto3_client(*args, **kwargs):
    import boto3

    return boto3.client(
        *args,
        region_name=CONFIG["aws_region"],
        aws_access_key_id=CONFIG["aws_access_key_id"],
        aws_secret_access_key=CONFIG["aws_secret_access_key"],
        **kwargs,
    )


@pytest.fixture(scope="module")
def s3_bucket():
    s3 = get_boto3_client("s3", endpoint_url=CONFIG["aws_endpoint"])
    bucket_name = "lance-integtest"
    # if bucket exists, delete it
    try:
        delete_bucket(s3, bucket_name)
    except s3.exceptions.NoSuchBucket:
        pass
    s3.create_bucket(Bucket=bucket_name)
    yield bucket_name

    delete_bucket(s3, bucket_name)


def delete_bucket(s3, bucket_name):
    # Delete all objects first
    for obj in s3.list_objects(Bucket=bucket_name).get("Contents", []):
        s3.delete_object(Bucket=bucket_name, Key=obj["Key"])
    s3.delete_bucket(Bucket=bucket_name)


@pytest.mark.s3_test
def test_s3_lifecycle(s3_bucket: str):
    storage_options = copy.copy(CONFIG)

    uri = f"s3://{s3_bucket}/test_lifecycle"
    data = pa.table({"x": [1, 2, 3]})

    async def test():
        db = await lancedb.connect_async(uri, storage_options=storage_options)

        table = await db.create_table("test", schema=data.schema)
        assert await table.count_rows() == 0

        table = await db.create_table("test", data, mode="overwrite")
        assert await table.count_rows() == 3

        await table.add(data, mode="append")
        assert await table.count_rows() == 6

        table = await db.open_table("test")
        assert await table.count_rows() == 6

        await db.drop_table("test")

        await db.drop_database()

    asyncio.run(test())


@pytest.mark.s3_test
def test_concurrent_open_table(s3_bucket: str):
    uri = f"s3://{s3_bucket}/test_concurrent_open_table"
    db = lancedb.connect(uri, storage_options=copy.copy(CONFIG))
    db.create_table("test", pa.table({"x": [1, 2, 3]}))

    num_workers = 32
    barrier = threading.Barrier(num_workers)

    def open_and_count(_):
        barrier.wait()
        return db.open_table("test").count_rows()

    with ThreadPoolExecutor(max_workers=num_workers) as pool:
        row_counts = list(pool.map(open_and_count, range(num_workers)))

    assert row_counts == [3] * num_workers


@pytest.fixture()
def kms_key():
    kms = get_boto3_client("kms", endpoint_url=CONFIG["aws_endpoint"])
    key_id = kms.create_key()["KeyMetadata"]["KeyId"]
    yield key_id
    kms.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)


def validate_objects_encrypted(bucket: str, path: str, kms_key: str):
    s3 = get_boto3_client("s3", endpoint_url=CONFIG["aws_endpoint"])
    objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)["Contents"]
    for obj in objects:
        info = s3.head_object(Bucket=bucket, Key=obj["Key"])
        assert info["ServerSideEncryption"] == "aws:kms", (
            "object %s not encrypted" % obj["Key"]
        )
        assert info["SSEKMSKeyId"].endswith(kms_key), (
            "object %s not encrypted with correct key" % obj["Key"]
        )


@pytest.mark.s3_test
def test_s3_sse(s3_bucket: str, kms_key: str):
    storage_options = copy.copy(CONFIG)

    uri = f"s3://{s3_bucket}/test_lifecycle"
    data = pa.table({"x": [1, 2, 3]})

    async def test():
        # Create a table with SSE
        db = await lancedb.connect_async(uri, storage_options=storage_options)

        table = await db.create_table(
            "table1",
            schema=data.schema,
            storage_options={
                "aws_server_side_encryption": "aws:kms",
                "aws_sse_kms_key_id": kms_key,
            },
        )
        await table.add(data)
        await table.update({"x": "1"})

        path = "test_lifecycle/table1.lance"
        validate_objects_encrypted(s3_bucket, path, kms_key)

        # Test we can set encryption at connection level too.
        db = await lancedb.connect_async(
            uri,
            storage_options=dict(
                aws_server_side_encryption="aws:kms",
                aws_sse_kms_key_id=kms_key,
                **storage_options,
            ),
        )

        table = await db.create_table("table2", schema=data.schema)
        await table.add(data)
        await table.update({"x": "1"})

        path = "test_lifecycle/table2.lance"
        validate_objects_encrypted(s3_bucket, path, kms_key)

    asyncio.run(test())


@pytest.fixture(scope="module")
def commit_table():
    ddb = get_boto3_client("dynamodb", endpoint_url=CONFIG["dynamodb_endpoint"])
    table_name = "lance-integtest"
    try:
        ddb.delete_table(TableName=table_name)
    except ddb.exceptions.ResourceNotFoundException:
        pass
    ddb.create_table(
        TableName=table_name,
        KeySchema=[
            {"AttributeName": "base_uri", "KeyType": "HASH"},
            {"AttributeName": "version", "KeyType": "RANGE"},
        ],
        AttributeDefinitions=[
            {"AttributeName": "base_uri", "AttributeType": "S"},
            {"AttributeName": "version", "AttributeType": "N"},
        ],
        ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1},
    )
    yield table_name
    ddb.delete_table(TableName=table_name)


@pytest.mark.s3_test
def test_s3_dynamodb(s3_bucket: str, commit_table: str):
    storage_options = copy.copy(CONFIG)

    uri = f"s3+ddb://{s3_bucket}/test?ddbTableName={commit_table}"
    data = pa.table({"x": [1, 2, 3]})

    async def test():
        db = await lancedb.connect_async(
            uri,
            storage_options=storage_options,
            read_consistency_interval=timedelta(0),
        )

        table = await db.create_table("test", data)

        # Five concurrent writers
        async def insert():
            # independent table refs for true concurrent writes.
            table = await db.open_table("test")
            await table.add(data, mode="append")

        tasks = [insert() for _ in range(5)]
        await asyncio.gather(*tasks)

        row_count = await table.count_rows()
        assert row_count == 3 * 6

    asyncio.run(test())


@pytest.mark.s3_test
def test_s3_dynamodb_sync(s3_bucket: str, commit_table: str, monkeypatch):
    # Sync API doesn't support storage_options, so we have to provide as env vars
    for key, value in CONFIG.items():
        monkeypatch.setenv(key.upper(), value)

    uri = f"s3+ddb://{s3_bucket}/test2?ddbTableName={commit_table}"
    data = pa.table({"x": ["a", "b", "c"]})

    db = lancedb.connect(
        uri,
        read_consistency_interval=timedelta(0),
    )

    table = db.create_table("test_ddb_sync", data)

    # Five concurrent writers
    def insert():
        table = db.open_table("test_ddb_sync")
        table.add(data, mode="append")

    threads = []
    for _ in range(5):
        thread = threading.Thread(target=insert)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    row_count = table.count_rows()
    assert row_count == 3 * 6

    # FTS indices should error since they are not supported yet.
    with pytest.raises(
        ValueError,
        match="Tantivy-based FTS has been removed",
    ):
        table.create_fts_index("x", use_tantivy=True)

    # make sure list tables still works
    assert db.table_names() == ["test_ddb_sync"]
    db.drop_table("test_ddb_sync")
    assert db.table_names() == []
    db.drop_database()


@pytest.mark.s3_test
def test_s3_dynamodb_drop_all_tables(s3_bucket: str, commit_table: str, monkeypatch):
    for key, value in CONFIG.items():
        monkeypatch.setenv(key.upper(), value)

    uri = f"s3+ddb://{s3_bucket}/test2?ddbTableName={commit_table}"
    db = lancedb.connect(uri, read_consistency_interval=timedelta(0))
    data = pa.table({"x": ["a", "b", "c"]})

    db.create_table("foo", data)
    db.create_table("bar", data)
    assert db.table_names() == ["bar", "foo"]

    # dropping all tables should clear multiple tables
    db.drop_all_tables()
    assert db.table_names() == []

    # create a new table with the same name to ensure DDB is clean
    db.create_table("foo", data)
    assert db.table_names() == ["foo"]

    db.drop_all_tables()
