import logging
import re
import threading
from collections import defaultdict
from pathlib import Path
from queue import Queue
from typing import DefaultDict
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 FileStatus
from bulk_restore_tool.models import FileTypes
from bulk_restore_tool.models import RestorableFile
from bulk_restore_tool.models import restore_path_suffix_from_source_path

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,
        f.pathUnique
    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,
        f.pathUnique
    FROM latest_versions f
    JOIN archive a on (a.archiveId = f.archiveId)
    WHERE f.fileType != 'DIRECTORY'
    AND f.status = 'SELECTED'
"""

reload_lock = threading.Semaphore()

open_databases = []


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]
    modified_on_or_after_date = selection.modifiedOnOrAfterDateMillis
    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 the "only restore files created or modified after date" filter
            if selection.restoreModOnOrAfter is not None:
                if model.sourceLastModifiedMillis < modified_on_or_after_date:
                    model.status = FileStatus.EXCLUDED
                    model.excludeReason = f"Version existed before cut off date: cutOff={modified_on_or_after_date} modTime={model.sourceLastModifiedMillis}"

            # 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:
        # Keep track of databases opened. Only log at INFO once per DB.
        if file_path in open_databases:
            log_util_.log(f"Opening/Creating database. path={file_path}", logging.DEBUG)
        else:
            log_util_.log(f"Opening/Creating database. path={file_path}")
            open_databases.append(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('/')"
        )

    # (BRT 1.9.3) pathUnique was added
    result = db.execute(
        sql="SELECT COUNT(*) FROM pragma_table_info('file') WHERE name='pathUnique'",
    ).fetchone()
    if result[0] == 0:
        db.execute("ALTER TABLE file ADD COLUMN pathUnique TEXT DEFAULT NULL")

    # 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 build_normalization_collision_cohorts(
    db: sqlite_utils.Database,
    force_win_path_sanitization: bool,
) -> Dict[str, List[Dict]]:
    """
    Map normalized restore path suffix -> file rows for the **device** SQLite database.

    Each device has one DB that may hold multiple archives; restores for that device share
    the same on-disk tree under ``deviceId/``, so collisions must be detected across all
    archives. Rows are deduplicated by ``fileId`` (one entry per logical file).

    Only keys with two or more distinct ``fileId`` values are returned (paths that collide
    after Windows sanitization).
    """
    buckets: DefaultDict[str, Dict[str, Dict]] = defaultdict(dict)
    cohort_sql = """
        SELECT fileId, archiveId, path, fileType, sourceSHA256Checksum, sourceMD5Checksum
        FROM latest_versions
        WHERE fileType != 'DIRECTORY'
        AND status != 'EXCLUDED'
    """
    for row in db.query(cohort_sql):
        rowd = dict(row)
        key = restore_path_suffix_from_source_path(
            rowd["path"],
            FileTypes(rowd["fileType"]),
            force_win_path_sanitization,
        )
        buckets[key][rowd["fileId"]] = rowd
    return {k: list(v.values()) for k, v in buckets.items() if len(v) >= 2}


def set_file_path_unique(
    file: RestorableFile, path_unique: str, db: sqlite_utils.Database
) -> None:
    """Persist collision-disambiguated logical path for all rows with this fileId (device DB)."""
    file.pathUnique = path_unique
    with db.conn:
        db.execute(
            "UPDATE file SET pathUnique = :pu WHERE fileId = :fid",
            {"pu": path_unique, "fid": file.fileId},
        )


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): ...
