import re
import threading
from pathlib import Path
from queue import Queue
from typing import Dict
from typing import Generator
from typing import List

import sqlite_utils

from bulk_restore_tool._log_util import LogUtil
from bulk_restore_tool.models import Archive
from bulk_restore_tool.models import FileMetadata
from bulk_restore_tool.models import FileSelection
from bulk_restore_tool.models import RestorableFile
from bulk_restore_tool.models import FileStatus

LATEST_VERSION_VIEW_SQL = """
    WITH latest_versions AS (
        SELECT fileId, planId, max(sourceLastModifiedMillis) as sourceLastModifiedMillis
        FROM file
        WHERE status != 'EXCLUDED'
        GROUP BY fileId
    )
    SELECT
        f.archiveId,
        f.fileId,
        f.planId,
        f.backupSetId,
        f.fileType,
        f.path,
        f.versionTimeMillis,
        f.sourceLastModifiedMillis,
        f.sourceLengthBytes,
        f.sourceSHA256Checksum,
        f.sourceMD5Checksum,
        f.deleted,
        f.deletionTimeMillis,
        f.status,
        f.excludeReason,
        f.directoryDepth
    FROM file f
    JOIN latest_versions v on (f.fileId = v.fileId)
    WHERE f.sourceLastModifiedMillis = v.sourceLastModifiedMillis
"""

RESTORABLE_VIEW_SQL = """
    SELECT
        a.deviceId,
        a.archiveId,
        a.destinationName,
        a.storageNodeWebUrl,
        f.planId,
        f.fileId,
        f.path,
        f.backupSetId,
        f.versionTimemillis,
        f.sourceLastModifiedMillis,
        f.sourceLengthBytes,
        f.sourceSHA256Checksum,
        f.sourceMD5Checksum,
        f.status,
        f.fileType,
        f.directoryDepth
    FROM latest_versions f
    JOIN archive a on (a.archiveId = f.archiveId)
    WHERE f.fileType != 'DIRECTORY'
    AND f.status = 'SELECTED'
"""

reload_lock = threading.Semaphore()


def select_files(
    archiveId: str, files: List[Dict], selection: FileSelection
) -> Generator[Dict, None, None]:
    """
    Marks files as 'selected' if they meet file selection criteria.
    If not selected, the 'excludeReason' field will be populated.
    """
    include_patterns = [re.compile(pat) for pat in selection.includePatterns]
    exclude_patterns = [re.compile(pat) for pat in selection.excludePatterns]
    for file in files:
        model = FileMetadata(**file, archiveId=archiveId)

        # determine the directory depth
        model.directoryDepth = model.path.count("/")

        if model.excludeReason is not None:
            yield model.dict()

        else:
            # apply include patterns
            if include_patterns:
                for regex in include_patterns:
                    if regex.match(model.path):
                        model.status = FileStatus.SELECTED
                        model.excludeReason = None
            else:
                model.status = FileStatus.SELECTED
                model.excludeReason = None

            # skip other filter logic if file isn't included
            if not model.status == FileStatus.SELECTED:
                model.excludeReason = "Didn't match any include patterns."
                yield model.dict()

            # apply exclude patterns
            if exclude_patterns:
                for regex in exclude_patterns:
                    if regex.match(model.path):
                        model.status = FileStatus.EXCLUDED
                        model.excludeReason = (
                            f"Matched exclude pattern: {regex.pattern}"
                        )

            # apply backup set filter
            if selection.backupSetIds:
                if model.backupSetId is None:
                    raise V1BackupSetException(
                        f"{model.archiveId} is a V1 archive and backup sets are not accessible for restores. Unable to filter by backup set ID."
                    )
                if model.backupSetId not in selection.backupSetIds:
                    model.status = FileStatus.EXCLUDED
                    model.excludeReason = (
                        f"Not in selected backupSetIds: {selection.backupSetIds}"
                    )

            # apply deleted file age filter
            if selection.includeDeleted and selection.deletedAgeMillis is not None:
                if (
                    model.deleted
                    and model.deletionTimeMillis < selection.deletedAgeMillis
                ):
                    model.status = FileStatus.EXCLUDED
                    model.excludeReason = (
                        f"Deleted prior to maxDeletedAge: {selection.deletedMaxAge}"
                    )

            # handle only keeping deleted files
            if selection.includeOnlyDeleted and not model.deleted:
                model.status = FileStatus.EXCLUDED
                model.excludeReason = "Do NOT restore non-deleted files."

            yield model.dict()


def ensure_tables_sqlite(archive: Archive, db_target: Path, log_util_: LogUtil = None):
    file_path = str(db_target) + "/" + archive.db_filename
    if log_util_ is not None:
        log_util_.log(f"Opening/Creating database. path={file_path}")
    db = sqlite_utils.Database(file_path)

    # ===== Create the archive table, if it does not already exist
    archive_table = db["archive"].create(  # type: ignore
        columns={name: field.type_ for name, field in Archive.__fields__.items()},
        pk="archiveId",
        if_not_exists=True,
    )
    # ===== If the archive table was pre-existing, ensure any new columns get added
    # (BRT 1.7.5) archiveStatus column was added
    result = db.execute(
        sql="SELECT COUNT(*) FROM pragma_table_info('archive') WHERE name='archiveStatus'",
    ).fetchone()
    if result[0] == 0:
        db.execute("ALTER TABLE archive ADD COLUMN archiveStatus TEXT default null")

    # ===== Create the file table, if it does not already exist
    file_table = db["file"].create(  # type: ignore
        columns={name: field.type_ for name, field in FileMetadata.__fields__.items()},
        pk=("archiveId", "fileId"),
        if_not_exists=True,
    )
    # ===== If the file table was pre-existing, ensure any new columns get added
    # (BRT 1.7.1) PlanId column was added
    result = db.execute(
        sql="SELECT COUNT(*) FROM pragma_table_info('file') WHERE name='planId'",
    ).fetchone()
    if result[0] == 0:
        db.execute("ALTER TABLE file ADD COLUMN planId TEXT default nope")
    # (BRT 1.7.9) directoryDepth was added
    result = db.execute(
        sql="SELECT COUNT(*) FROM pragma_table_info('file') WHERE name='directoryDepth'",
    ).fetchone()
    if result[0] == 0:
        db.execute("ALTER TABLE file ADD directoryDepth INT DEFAULT NULL")
        db.execute(
            "UPDATE file SET directoryDepth = (length(path) - length(replace(path, '/', ''))) / length('/')"
        )

    # Add new archive information
    archive_table.upsert(archive.dict(), pk="archiveId")

    # Create views
    db.create_view("latest_versions", LATEST_VERSION_VIEW_SQL, replace=True)
    db.create_view("restorable", RESTORABLE_VIEW_SQL, replace=True)

    return file_path, db, archive_table, file_table


def save_archive_metadata_sqlite(
    archive: Archive,
    files: List[dict],
    db_target: Path,
    selection: FileSelection,
    log_util_: LogUtil = None,
) -> Path:
    file_path, db, archive_table, file_table = ensure_tables_sqlite(
        archive, db_target, log_util_
    )

    selected_files = select_files(
        archiveId=archive.archiveId, files=files, selection=selection
    )
    file_table.upsert_all(selected_files, pk=("archiveId", "fileId"))

    return file_path


def add_indices(db: sqlite_utils.Database):
    db["file"].create_index(columns=["path"], if_not_exists=True)
    db["file"].create_index(columns=["path", "archiveId"], if_not_exists=True)


def restore_queue(db: sqlite_utils.Database, archive_id: str) -> Queue:
    queue: Queue = Queue()
    load_restorable_files_into_queue(db, archive_id, queue)
    return queue


def restore_directory_queue(db: sqlite_utils.Database, archive: Archive) -> Queue:
    queue: Queue = Queue()
    load_directories_into_queue(
        db, archive.archiveId, archive.deviceId, archive.storageNodeWebUrl, queue
    )
    return queue


def has_file_versions(db: sqlite_utils.Database, archive_id: str) -> bool:
    """
    Checks to see if an archive has any file versions

    Returns:
        True - has at least 1 file version
        False - has no file versions
    """
    result = db.execute(
        # Dedup using the path value.
        # Note: A single path, will have different FileIds across plans in the same archive.
        sql="select COUNT(*) from restorable where archiveId = :archiveId",
        parameters={"archiveId": archive_id},
    ).fetchone()
    return result[0] > 0


def load_restorable_files_into_queue(
    db: sqlite_utils.Database, archive_id: str, queue: Queue
) -> bool:
    """
    Loads the next batch of file versions from the database into the given queue.

    Returns:
        True - data was loaded into the queue.
        False - no more data available.
    """
    with reload_lock:
        # some other thread beat us to reloading the queue. do not double up on the work
        if not queue.empty():
            return True

        for file in db.query(
            """
            SELECT *
            FROM restorable
            WHERE archiveId = :archiveId
            -- ORDER: FILE -> MAC_RESOURCE_FORK -> SYMLINK -> WINDOWS_NAMED_DATA_STREAM
            ORDER BY fileType ASC, directoryDepth DESC
            LIMIT 5000
            """,
            {"archiveId": archive_id},
        ):
            queue.put(RestorableFile(**file))

        # if the queue is still empty, then there is nothing left to load. let the caller know.
        return not queue.empty()


def load_directories_into_queue(
    db: sqlite_utils.Database,
    archive_id: str,
    deviceId: str,
    storageNodeWebUrl: str,
    queue: Queue,
) -> bool:
    """
    Loads the next batch of directories from the database into the given queue.

    Returns:
        True - data was loaded into the queue.
        False - no more data available.
    """
    with reload_lock:
        # some other thread beat us to reloading the queue. do not double up on the work
        if not queue.empty():
            return True

        for directory in db.query(
            """
            SELECT *,
            :deviceId as deviceId,
            :storageNodeWebUrl as storageNodeWebUrl,
            'destName' as destinationName
            FROM file
            WHERE archiveId = :archiveId
            AND fileType = "DIRECTORY"
            AND status = "SELECTED"
            LIMIT 5000
            """,
            {
                "archiveId": archive_id,
                "deviceId": deviceId,
                "storageNodeWebUrl": storageNodeWebUrl,
            },
        ):
            queue.put(RestorableFile(**directory))

        # if the queue is still empty, then there is nothing left to load. let the caller know.
        return not queue.empty()


def get_file_status(file: RestorableFile, db: sqlite_utils.Database) -> FileStatus:
    result = db.execute(
        # Dedup using the path value.
        # Note: A single path, will have different FileIds across plans in the same archive.
        sql="select status from file where path = :path and archiveId = :archiveId",
        parameters={"path": file.path, "archiveId": file.archiveId},
    ).fetchone()
    return FileStatus(result[0])


def set_file_status(
    file: RestorableFile, status: FileStatus, db: sqlite_utils.Database
):
    with db.conn:
        # Dedup using the path value.
        # Note: A single path, will have different FileIds across plans in the same archive.
        db.execute(
            sql="update file set status = :status where path = :path",
            parameters={
                "status": status,
                "path": file.path,
            },
        )


def set_dir_status(directory: str, status: FileStatus, db: sqlite_utils.Database):
    with db.conn:
        # Dedup using the path value.
        # Note: A single path, will have different FileIds across plans in the same archive.
        db.execute(
            sql="update file set status = :status where path = :path",
            parameters={
                "status": status,
                "path": directory,
            },
        )


def close_database(db: sqlite_utils.Database):
    db.close()


class V1BackupSetException(Exception): ...
