#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
from typing import Dict
from typing import List
from typing import Optional
from typing import Set

from nvs_logger import NVS_Logger
from nvs_parser import nvs_const
from nvs_parser import NVS_Entry
from nvs_parser import NVS_Page
from nvs_parser import NVS_Partition


EMPTY_ENTRY = NVS_Entry(-1, bytearray(32), 'Erased')

used_namespaces: Dict[int, Optional[str]] = {}
found_namespaces: Dict[int, str] = {}
blobs: Dict = {}
blob_chunks: List[NVS_Entry] = []


def check_partition_size(nvs_partition: NVS_Partition, nvs_log: NVS_Logger, read_only: bool=False) -> bool:
    """ Checks if the partition is large enough and has enough pages
    """
    if len(nvs_partition.raw_data) // 0x1000 < 3 and not read_only:
        nvs_log.info(
            nvs_log.yellow(
                'NVS Partition size must be at least 0x3000 (4kiB * 3 pages == 12kiB)!'
            )
        )
        return False
    if len(nvs_partition.raw_data) % 0x1000 != 0:
        nvs_log.info(
            nvs_log.yellow(
                'NVS Partition size must be a multiple of 0x1000 (4kiB)!'
            )
        )
        return False
    if len(nvs_partition.pages) < 3 and not read_only:
        nvs_log.info(
            nvs_log.yellow(
                'NVS Partition must contain 3 pages (sectors) at least to function properly!'
            )
        )
        return False
    return True


def check_empty_page_present(nvs_partition: NVS_Partition, nvs_log: NVS_Logger) -> bool:
    if not any(page.header['status'] == 'Empty' for page in nvs_partition.pages):
        nvs_log.info(
            nvs_log.red(
                '''No free (empty) page found in the NVS partition,
at least one free page is required for proper function!'''
            )
        )
        nvs_log.info(nvs_log.red('NVS partition possibly truncated?\n'))
        return False
    return True


def check_empty_page_content(nvs_page: NVS_Page, nvs_log: NVS_Logger) -> bool:
    result = True
    nvs_log.info(nvs_log.cyan(f'Page {nvs_page.header["status"]}'))

    if nvs_page.raw_entry_state_bitmap != bytearray({0xFF}) * nvs_const.entry_size:
        result = False
        nvs_log.info(
            nvs_log.red(
                'The page is reported as Empty but its entry state bitmap is not empty!'
            )
        )

    if any([not e.is_empty for e in nvs_page.entries]):
        result = False
        nvs_log.info(
            nvs_log.red('The page is reported as Empty but there are data written!')
        )

    return result


def check_page_crc(nvs_page: NVS_Page, nvs_log: NVS_Logger) -> bool:
    if nvs_page.header['crc']['original'] == nvs_page.header['crc']['computed']:
        nvs_log.info(
            nvs_log.cyan(f'Page no. {nvs_page.header["page_index"]}'), '\tCRC32: OK'
        )
        return True
    else:
        nvs_log.info(
            nvs_log.cyan(f'Page no. {nvs_page.header["page_index"]}'),
            f'Original CRC32:',
            nvs_log.red(f'{nvs_page.header["crc"]["original"]:x}'),
            f'Generated CRC32:',
            nvs_log.green(f'{nvs_page.header["crc"]["computed"]:x}'),
        )
        return False


def identify_entry_duplicates(entry: NVS_Entry, entry_dict: Dict[str, List[NVS_Entry]]) -> Dict[str, List[NVS_Entry]]:
    """Identifies and logs written entries

    Part 1 of duplicate entry check mechanism
    """
    if entry.state == 'Written':
        if entry.key in entry_dict:
            entry_dict[entry.key].append(entry)
        else:
            entry_dict[entry.key] = [entry]
    return entry_dict


def check_page_entries(nvs_page: NVS_Page, nvs_log: NVS_Logger) -> Dict[str, List[NVS_Entry]]:
    """Checks entries in the given page (entry state, children CRC32, entry type, span and gathers blobs and namespaces)
    """
    seen_written_entires: Dict[str, List[NVS_Entry]] = {}

    for entry in nvs_page.entries:
        # entry: NVS_Entry
        entry.page = nvs_page

        # Entries stored in 'page.entries' are primitive data types, blob indexes or string/blob data

        # Variable length values themselves occupy whole 32 bytes (therefore metadata values are meaningless)
        # and are stored in as entries inside string/blob data entry 'entry.children' list

        # Duplicate entry check (1) - same key, different index - find duplicates
        seen_written_entires = identify_entry_duplicates(entry, seen_written_entires)

        # Entry state check - doesn't check variable length values (metadata such as state are meaningless as all 32 bytes are pure data)
        if entry.is_empty:
            if entry.state == 'Written':
                nvs_log.info(
                    nvs_log.red(
                        f' Entry #{entry.index:03d} is reported as Written but it is empty!'
                    )
                )
                continue
            elif entry.state == 'Erased':
                nvs_log.info(
                    nvs_log.yellow(
                        f' Entry #{entry.index:03d} is reported as Erased but it is empty! (Only entries reported as Empty should be empty)'
                    )
                )

        if entry.state == 'Written':
            # Entry CRC32 check
            if (
                entry.metadata['crc']['original']
                != entry.metadata['crc']['computed']
            ):
                nvs_log.info(
                    nvs_log.red(
                        f' Entry #{entry.index:03d} {entry.key} has wrong CRC32!{"": <5}'
                    ),
                    f'Written:',
                    nvs_log.red(f'{entry.metadata["crc"]["original"]:x}'),
                    f'Generated:',
                    nvs_log.green(f'{entry.metadata["crc"]["computed"]:x}'),
                )

            # Entry children CRC32 check
            if (
                entry.metadata['span'] > 1
                and (entry.metadata['crc']['data_original'] != entry.metadata['crc']['data_computed'])
            ):
                nvs_log.info(
                    nvs_log.red(
                        f' Entry #{entry.index:03d} {entry.key} data (string, blob) has wrong CRC32!'
                    ),
                    f'Written:',
                    nvs_log.red(f'{entry.metadata["crc"]["data_original"]:x}'),
                    f'Generated:',
                    nvs_log.green(f'{entry.metadata["crc"]["data_computed"]:x}'),
                )

            # Entry type check
            if entry.metadata['type'] not in [
                nvs_const.item_type[key] for key in nvs_const.item_type
            ]:
                nvs_log.info(
                    nvs_log.yellow(
                        f' Type of entry #{entry.index:03d} {entry.key} is unrecognized!'
                    ),
                    f'Type: {entry.metadata["type"]}',
                )

            # Span check
            if (
                entry.index + entry.metadata['span'] - 1
                >= int(nvs_const.page_size / nvs_const.entry_size) - 2
            ):
                nvs_log.info(
                    nvs_log.red(
                        f' Variable length entry #{entry.index:03d} {entry.key} is out of bounds!'
                    )
                )
            # Spanned entry state checks
            elif entry.metadata['span'] > 1:
                parent_state = entry.state
                for kid in entry.children:
                    if parent_state != kid.state:
                        nvs_log.info(
                            nvs_log.yellow(' Inconsistent data state!'),
                            f'Entry #{entry.index:03d} {entry.key} state: {parent_state},',
                            f'Data entry #{kid.index:03d} {entry.key} state: {kid.state}',
                        )

            # Gather blobs & namespaces
            if entry.metadata['type'] == 'blob_index':
                blobs[f'{entry.metadata["namespace"]:03d}{entry.key}'] = [entry] + [
                    EMPTY_ENTRY
                ] * entry.data['chunk_count']
            elif entry.metadata['type'] == 'blob_data':
                blob_chunks.append(entry)

            if entry.metadata['namespace'] == 0:
                found_namespaces[entry.data['value']] = entry.key
            else:
                used_namespaces[entry.metadata['namespace']] = None

    return seen_written_entires


def filter_namespaces_fake_duplicates(duplicate_entries_dict: Dict[str, List[NVS_Entry]]) -> Dict[str, List[NVS_Entry]]:
    """Takes a dictionary of entries (as written) and returns a new dictionary with "fake" duplicates,
    where entries which have the same key but under different namespaces are filtered out

    Use `filter_entry_duplicates()` to properly filter out all duplicates
    """
    new_duplicate_entries_dict: Dict[str, List[NVS_Entry]] = {}
    for key, duplicate_entries in duplicate_entries_dict.items():
        seen_entries: List[NVS_Entry] = []
        entry_same_namespace_collisions_list: Set[NVS_Entry] = set()

        # Search through the "duplicates" and see if there are real duplicates
        # E.g. the key can be the same if the namespace is different
        for entry in duplicate_entries:
            if entry.metadata['type'] in nvs_const.item_type.values():

                entry_same_namespace_collisions = set()
                for other_entry in seen_entries:
                    if entry.metadata['namespace'] == other_entry.metadata['namespace']:
                        entry_same_namespace_collisions.add(entry)
                        entry_same_namespace_collisions.add(other_entry)

                if len(entry_same_namespace_collisions) != 0:
                    entry_same_namespace_collisions_list.update(entry_same_namespace_collisions)
                seen_entries.append(entry)

        # Catch real duplicates
        new_duplicate_entries: List[NVS_Entry] = []
        if len(seen_entries) > 1:
            for entry in seen_entries:
                if entry in entry_same_namespace_collisions_list:
                    new_duplicate_entries.append(entry)

        if len(new_duplicate_entries) > 0:
            new_duplicate_entries_dict[key] = new_duplicate_entries

    return new_duplicate_entries_dict


def filter_blob_related_duplicates(duplicate_entries_dict: Dict[str, List[NVS_Entry]]) -> Dict[str, List[NVS_Entry]]:
    """Takes a dictionary of entries (as written) and returns a new dictionary with "fake" duplicates,
    where entries related to blob index and blob data under the same namespace are filtered out

    Use `filter_entry_duplicates()` to properly filter out all duplicates
    """
    new_duplicate_entries_dict: Dict[str, List[NVS_Entry]] = {}
    for key, duplicate_entries in duplicate_entries_dict.items():
        seen_blob_index: List[NVS_Entry] = []
        seen_blob_data: List[NVS_Entry] = []
        seen_another_type_data: List[NVS_Entry] = []
        blob_index_chunk_index_collisions_list: Set[NVS_Entry] = set()
        blob_data_chunk_index_collisions_list: Set[NVS_Entry] = set()

        # Search through the "duplicates" and see if there are real duplicates
        # E.g. the key can be the same for blob_index and blob_data
        # (and even for more blob_data entries if they have a different chunk_index)
        for entry in duplicate_entries:
            if entry.metadata['type'] == 'blob_index':

                blob_index_chunk_index_collisions = set()
                for other_entry in seen_blob_index:
                    if entry.metadata['namespace'] == other_entry.metadata['namespace']:
                        blob_index_chunk_index_collisions.add(entry)
                        blob_index_chunk_index_collisions.add(other_entry)

                if len(blob_index_chunk_index_collisions) != 0:
                    blob_index_chunk_index_collisions_list.update(blob_index_chunk_index_collisions)
                seen_blob_index.append(entry)

            elif entry.metadata['type'] == 'blob_data':

                blob_data_chunk_index_collisions = set()
                for other_entry in seen_blob_data:
                    if (entry.metadata['namespace'] == other_entry.metadata['namespace']
                            and entry.metadata['chunk_index'] == other_entry.metadata['chunk_index']):
                        blob_data_chunk_index_collisions.add(entry)
                        blob_data_chunk_index_collisions.add(other_entry)

                if len(blob_data_chunk_index_collisions) != 0:
                    blob_data_chunk_index_collisions_list.update(blob_data_chunk_index_collisions)
                seen_blob_data.append(entry)

            else:
                seen_another_type_data.append(entry)

        # Catch real duplicates
        new_duplicate_entries: List[NVS_Entry] = []
        if len(seen_blob_index) > 1:
            for entry in seen_blob_index:
                if entry in blob_index_chunk_index_collisions_list:
                    new_duplicate_entries.append(entry)

        if len(seen_blob_data) > 1:
            for entry in seen_blob_data:
                if entry in blob_data_chunk_index_collisions_list:
                        new_duplicate_entries.append(entry)

        for entry in seen_another_type_data:  # If there are any duplicates of other types
            new_duplicate_entries.append(entry)

        if len(new_duplicate_entries) > 0:
            new_duplicate_entries_dict[key] = new_duplicate_entries

    return new_duplicate_entries_dict


def filter_entry_duplicates(entries: Dict[str, List[NVS_Entry]]) -> Dict[str, List[NVS_Entry]]:
    """Takes a dictionary of (seen written) entries and outputs a new dictionary with "fake" duplicates filtered out, keeping only real duplicates in

    (i.e. duplicate keys under different namespaces and blob index and blob data having the same key under the same namespace are allowed
    and should be filtered out)

    Part 2 of duplicate entry check mechanism
    """
    # Only keep seen written entries which have been observerd multiple times (duplicates)
    duplicate_entries_list = {key: v for key, v in entries.items() if len(v) > 1}
    # Filter out "fake" duplicates 1 (duplicate keys under different namespaces are allowed)
    duplicate_entries_list_1 = filter_namespaces_fake_duplicates(duplicate_entries_list)
    # Filter out "fake" duplicates 2 (blob index and blob data are allowed to have the same key even in the same namespace)
    duplicate_entries_list_2 = filter_blob_related_duplicates(duplicate_entries_list_1)
    return duplicate_entries_list_2


def print_entry_duplicates(duplicate_entries_list: Dict[str, List[NVS_Entry]], nvs_log: NVS_Logger) -> None:
    if len(duplicate_entries_list) > 0:
        nvs_log.info(nvs_log.red('Found duplicate entries:'))
        nvs_log.info(nvs_log.red('Entry\tKey\t\t\tType\t\tNamespace idx\tPage\tPage status'))

    for _, duplicate_entries in duplicate_entries_list.items():
        # duplicate_entries: List[NVS_Entry]
        for entry in duplicate_entries:
            # entry: NVS_Entry
            if entry.metadata['namespace'] == 0:
                entry_type = f'namespace ({entry.data["value"]})'
            else:
                entry_type = entry.metadata['type']

            if entry.page is not None:
                page_num = entry.page.header['page_index']
                page_status = entry.page.header['status']
            else:
                page_num = 'Unknown'
                page_status = 'Unknown'

            entry_key_tab_cnt = len(entry.key) // 8
            entry_key_tab = '\t' * (3 - entry_key_tab_cnt)

            namespace_tab_cnt = len(entry_type) // 8
            namepace_tab = '\t' * (2 - namespace_tab_cnt)
            namespace_str = f'{entry.metadata["namespace"]}'

            nvs_log.info(
                nvs_log.red(
                    f'#{entry.index:03d}\t{entry.key}{entry_key_tab}{entry_type}{namepace_tab}{namespace_str}\t\t{page_num}\t{page_status}'
                )
            )


def assemble_blobs(nvs_log: NVS_Logger) -> None:
    """Assembles blob data from blob chunks
    """
    for chunk in blob_chunks:
        # chunk: NVS_Entry
        parent = blobs.get(
            f'{chunk.metadata["namespace"]:03d}{chunk.key}', [EMPTY_ENTRY]
        )[0]
        # Blob chunk without blob index check
        if parent is EMPTY_ENTRY:
            nvs_log.info(
                nvs_log.red(f'Blob {chunk.key} chunk has no blob index!'),
                f'Namespace index: {chunk.metadata["namespace"]:03d}',
                f'[{found_namespaces.get(chunk.metadata["namespace"], "undefined")}],',
                f'Chunk Index: {chunk.metadata["chunk_index"]:03d}',
            )
        else:
            blob_key = f'{chunk.metadata["namespace"]:03d}{chunk.key}'
            chunk_index = chunk.metadata['chunk_index'] - parent.data['chunk_start']
            blobs[blob_key][chunk_index + 1] = chunk


def check_blob_data(nvs_log: NVS_Logger) -> None:
    """Checks blob data for missing chunks or data
    """
    for blob_key in blobs:
        blob_index = blobs[blob_key][0]
        blob_chunks = blobs[blob_key][1:]
        blob_size = blob_index.data['size']

        for i, chunk in enumerate(blob_chunks):
            # chunk: NVS_Entry
            # Blob missing chunk check
            if chunk is EMPTY_ENTRY:
                nvs_log.info(
                    nvs_log.red(f'Blob {blob_index.key} is missing a chunk!'),
                    f'Namespace index: {blob_index.metadata["namespace"]:03d}',
                    f'[{found_namespaces.get(blob_index.metadata["namespace"], "undefined")}],',
                    f'Chunk Index: {i:03d}',
                )
            else:
                blob_size -= len(chunk.children) * nvs_const.entry_size

        # Blob missing data check
        if blob_size > 0:
            nvs_log.info(
                nvs_log.red(f'Blob {blob_index.key} is missing {blob_size} B of data!'),
                f'Namespace index: {blob_index.metadata["namespace"]:03d}',
            )


def check_blobs(nvs_log: NVS_Logger) -> None:
    # Assemble blobs
    assemble_blobs(nvs_log)
    # Blob data check
    check_blob_data(nvs_log)


def check_namespaces(nvs_log: NVS_Logger) -> None:
    """Checks namespaces (entries using undefined namespace indexes, unused namespaces)
    """
    # Undefined namespace index check
    for used_ns in used_namespaces:
        key = found_namespaces.pop(used_ns, None)
        if key is None:
            nvs_log.info(
                nvs_log.red('Undefined namespace index!'),
                f'Namespace index: {used_ns:03d}',
                f'[undefined]',
            )

    # Unused namespace index check
    for unused_ns in found_namespaces:
        nvs_log.info(
            nvs_log.yellow('Found unused namespace.'),
            f'Namespace index: {unused_ns:03d}',
            f'[{found_namespaces[unused_ns]}]',
        )


def reset_global_variables() -> None:
    """Global variables need to be cleared out before calling `integrity_check()` multiple times from a script
    (e.g. when running tests) to avoid incorrect output
    """
    global used_namespaces, found_namespaces, blobs, blob_chunks
    used_namespaces = {}
    found_namespaces = {}
    blobs = {}
    blob_chunks = []


def integrity_check(nvs_partition: NVS_Partition, nvs_log: NVS_Logger) -> None:
    """Function for multi-stage integrity check of a NVS partition
    """
    # Partition size check
    check_partition_size(nvs_partition, nvs_log)

    # Free/empty page check
    check_empty_page_present(nvs_partition, nvs_log)

    seen_written_entires_all: Dict[str, List[NVS_Entry]] = {}

    # Loop through all pages in the partition
    for page in nvs_partition.pages:
        # page: NVS_Page

        # Print a page header
        if page.header['status'] == 'Empty':
            # Check if a page is truly empty
            check_empty_page_content(page, nvs_log)
        else:
            # Check a page header CRC32
            check_page_crc(page, nvs_log)

        # Check all entries in a page
        seen_written_entires = check_page_entries(page, nvs_log)

        # Collect all seen written entries
        for key in seen_written_entires:
            if key in seen_written_entires_all:
                seen_written_entires_all[key].extend(seen_written_entires[key])
            else:
                seen_written_entires_all[key] = seen_written_entires[key]

    # Duplicate entry check (2) - same key, different index
    duplicates = filter_entry_duplicates(seen_written_entires_all)
    # Print duplicate entries
    print_entry_duplicates(duplicates, nvs_log)

    nvs_log.info()  # Empty line

    # Blob checks
    check_blobs(nvs_log)

    # Namespace checks
    check_namespaces(nvs_log)

    reset_global_variables()
