import logging
from typing import List
import json

import boto3
from botocore.exceptions import ClientError

from algo.data.common import PricePoint, PriceStream


log = logging.getLogger(__name__)


TABLE_ALREADY_EXISTS_ERROR_CODE = 'ResourceInUseException'


def create_dynamo_cache_table(table_name, dynamodb):
    """
    # dynamodb: DynamoDBServiceResource

    ignoring billing arguments:
    ProvisionedThroughput
    and
    BillingMode
    for now setting billing mode to PAY_PER_REQUEST and will need to check cost of running and switch to
    Provision whenever it will make sense based on available load data
    """
    try:
        # TBD:  debug better on table creation it fails, on second run table already exists
        create_table_response = dynamodb.create_table(
            TableName=table_name,
            KeySchema=[
                {
                    'AttributeName': 'key',
                    'KeyType': 'HASH'
                }
            ],
            AttributeDefinitions=[
                {
                    'AttributeName': 'key',
                    'AttributeType': 'S'
                }
            ],
            BillingMode='PAY_PER_REQUEST'  # see comments
        )
        log.info('table-created-result', extra={'tr': create_table_response})
    except ClientError as exc:
        log.error(
            'error creating dynamo table',
            extra={
                'e': exc, 't': type(exc),
                'error-details': exc.response
            }
        )
        if exc.response['Error']['Code'] != TABLE_ALREADY_EXISTS_ERROR_CODE:
            raise


# 400K is actual max 300K is somewhat with safety range of 100K
MAX_CHAR_COUNT_PER_ENTRY = 300000


class DynamoCache():
    def __init__(self) -> None:
        self._client = boto3.client('dynamodb')
        self._res = boto3.resource('dynamodb')

        # consider moving to config ...
        self._dynamo_table_name = 'day-stream-data'  # prod value
        # self._dynamo_table_name = 'price-cache-day-experiement'
        self._table_exists = False
        self._dynamo_table = self._res.Table(self._dynamo_table_name)
        self._cache_data = {}

    def _verify_table_exists(self):
        if not self._table_exists:
            create_dynamo_cache_table(self._dynamo_table_name, self._res)
            self._table_exists = True

    def _has(self, cache_key: str):
        response = self._dynamo_table.get_item(
            Key={'key': cache_key}
        )
        return 'Item' in response

    def has(self, cache_key: str):
        """
        :PERF-IMPROVE-POTENTIAL:
        making repetitive read.... first reading to check, then reading to read if exists
        """
        self._verify_table_exists()
        chunked_key = format_chunked_key(cache_key, 1)
        return self._get_or_none(chunked_key) is not None

    def _get(self, cache_key: str) -> str:
        response = self._dynamo_table.get_item(
            Key={'key': cache_key}
        )
        return response['Item']['value']

    def _get_or_none(self, cache_key: str) -> str:
        response = self._dynamo_table.get_item(
            Key={'key': cache_key}
        )
        return response.get('Item', {'value': None})['value']

    def _set(self, cache_key: str, data: str):
        self._dynamo_table.put_item(
            Item={
                'key': cache_key,
                'value': data
            }
        )

    def has_lock(self, cache_key: str):
        self._verify_table_exists()
        return self._has(cache_key)

    def set_lock(self, cache_key: str):
        self._verify_table_exists()
        return self._set(cache_key, 1)

    def clear_lock(self, cache_key: str):
        self._verify_table_exists()
        self._dynamo_table.delete_item(
            Key={'key': cache_key}
        )

    def get(self, cache_key: str) -> PriceStream:
        """
        consider making iterative
        """
        self._verify_table_exists()
        chunk_number = 1
        formatted_cache_key = format_chunked_key(cache_key, chunk_number)
        data = self._get_or_none(formatted_cache_key)
        while data is not None:
            for data_portion in json.loads(data):
                yield PricePoint.from_json_ready(data_portion)
            chunk_number += 1
            formatted_cache_key = format_chunked_key(cache_key, chunk_number)
            data = self._get_or_none(formatted_cache_key)

    def has_meta(self, meta_key: str):
        self._verify_table_exists()
        return self._get_or_none(meta_key) is not None

    def set_meta(self, meta_key: str, data: any):
        self._verify_table_exists()
        serialized_data = json.dumps(data)
        self._set(meta_key, serialized_data)

    def set(self, cache_key: str, data: List[PricePoint]):
        """
        good oportunity to play OOP and decople
        chunk writer in a nice testable way :rofl:
        """
        self._verify_table_exists()
        data_chunk = JsonSizedArray()
        chunk_number = 1
        for data_portion in data:
            data_chunk.append(data_portion.to_json_ready())
            if data_chunk.size > MAX_CHAR_COUNT_PER_ENTRY:
                serialized_data = json.dumps(data_chunk.data)
                self._set(
                    format_chunked_key(cache_key, chunk_number),
                    serialized_data)
                data_chunk = JsonSizedArray()
                chunk_number += 1

        if len(data_chunk.data) > 0:
            serialized_data = json.dumps(data_chunk.data)
            self._set(format_chunked_key(
                cache_key, chunk_number), serialized_data
            )


def format_chunked_key(key, chunk_num: int):
    return f'{key}_{chunk_num}'


class JsonSizedArray():
    def __init__(self) -> None:
        self._data = []
        self._json_data_length_counter = 0

    def append(self, item: any):
        # 1 is for coma
        self._json_data_length_counter += len(json.dumps(item)) + 1
        self._data.append(item)

    @ property
    def size(self) -> int:
        # 2 for opening and closing braces
        return self._json_data_length_counter + 2

    @ property
    def data(self):
        return self._data
