import re
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import List, Optional, Dict
from uuid import uuid4

import click
import platform
import sqlite_utils
from pydantic import BaseModel, root_validator, PrivateAttr, Extra, validator
from pydantic import Field
from rich import filesize

from bulk_restore_tool._log_util import LogUtil


class DetailType(str, Enum):
    ARCHIVE = "archive"
    DEVICE = "device"
    USER = "username"


class ArchiveFileVersionStatus(str, Enum):
    FAILED = "failedToRetrieveVersions"
    READY = "ready"


class Archive(BaseModel):
    """Class representing an archive."""

    sessionId: Optional[str]
    archiveId: str
    principalId: Optional[str]
    username: Optional[str]
    deviceId: Optional[str]
    deviceName: Optional[str]
    destinationId: Optional[str]
    destinationName: Optional[str]
    storageNodeWebUrl: Optional[str]
    archiveStatus: Optional[str]

    @validator("deviceName")
    def _sanitize_device_name(cls, value):
        sanitized_device_name = re.sub(r"(<|>|:|\"|\/|\\|\||\?|\*)", "_", value)
        sanitized_device_name = sanitized_device_name.replace(" ", "_")
        return sanitized_device_name

    @property
    def json_filename(self):
        return (
            f"{self.username}_{self.deviceName}_{self.deviceId}_{self.archiveId}.json"
        )

    @property
    def db_filename(self):
        return f"{self.username}_{self.deviceName}_{self.deviceId}.db"


class FileStatus(str, Enum):
    EXCLUDED = "EXCLUDED"
    SELECTED = "SELECTED"
    IN_PROGRESS = "IN_PROGRESS"
    RETRY = "RETRY"
    COMPLETED = "COMPLETED"


class FileTypes(str, Enum):
    UNKNOWN = "UNKNOWN"
    FILE = "FILE"
    DIR = "DIR"
    WINDOWS_NAMED_DATA_STREAM = "WINDOWS_NAMED_DATA_STREAM"
    MAC_RSRC = "MAC_RESOURCE_FORK"
    SYMLINK = "SYMLINK"
    FIFO = "FIFO"
    BLOCK_DEVICE = "BLOCK_DEVICE"
    CHAR_DEVICE = "CHAR_DEVICE"
    SOCKET = "SOCKET"
    BUNDLE = "BUNDLE"
    DIRECTORY = "DIRECTORY"


class MacMetadata(BaseModel):
    os: str = "Macintosh"
    creationDate: Optional[int]
    contentModificationDate: Optional[int]
    attributeModificationDate: Optional[int]
    isPackage: Optional[bool]
    isHidden: Optional[bool]
    hasHiddenExtension: Optional[bool]
    labelNumber: Optional[int]
    uid: Optional[int]
    gid: Optional[int]
    mode: Optional[int]
    aclText: Optional[str]
    bsdFlags: Optional[int]
    specialDeviceId: Optional[int]
    xAttrs: Optional[List[str]]


class LinuxMetadata(BaseModel):
    os: str = "Linux"
    uid: Optional[int]
    gid: Optional[int]
    mode: Optional[int]
    cTime: Optional[int]


class WindowsMetadata(BaseModel):
    os: str = "Windows"
    fileAttributeFlags: Optional[int]
    aclText: Optional[List[str]]
    accessDate: Optional[int]
    createDate: Optional[int]
    changeDate: Optional[int]
    lastModifiedDate: Optional[int]


metadata_map = {
    "Windows": WindowsMetadata,
    "Linux": LinuxMetadata,
    "Macintosh": MacMetadata,
}


class FileMetadata(BaseModel, extra=Extra.ignore):
    archiveId: str
    fileId: str
    planId: Optional[str]
    backupSetId: Optional[int]
    fileType: str
    path: str
    versionTimeMillis: int
    sourceLastModifiedMillis: int
    sourceLengthBytes: int
    sourceMD5Checksum: Optional[str]
    sourceSHA256Checksum: Optional[str]
    deleted: bool
    deletionTimeMillis: Optional[int]
    status: str = FileStatus.EXCLUDED
    excludeReason: Optional[str]
    directoryDepth: Optional[int]

    @root_validator(pre=True)
    def handle_deleted_file(cls, values: dict):  # noqa
        if values["deleted"]:
            last_non_deleted = values.pop("lastNonDeletedEntry")
            # if there aren't any non-deleted versions, we want to exclude it as there's nothing restorable
            if last_non_deleted is None:
                values["excludeReason"] = "No non-deleted versions to restore"
            else:
                values["deletionTimeMillis"] = values["versionTimeMillis"]
                values.update(last_non_deleted)
        return values

    @validator("status")
    def validate_status(cls, value):
        FileStatus(value)
        return value


class Device:
    def __init__(self, username: str, name: str, deviceId: str, db_file: Path):
        self.username = username
        self.name = name
        self.deviceId = deviceId
        self.db = sqlite_utils.Database(db_file)
        self.progress = None
        self.archive_progress = None
        self.task_id = None

    @property
    def archiveCount(self):
        result = next(
            self.db.query("SELECT count(distinct(archiveId)) as count from archive")
        )
        return result["count"]

    @property
    def completedFiles(self):
        result = next(
            self.db.query(
                """
                SELECT coalesce(count(distinct(path)), 0) as completedFiles
                FROM file
                WHERE fileType != 'DIRECTORY'
                AND status = 'COMPLETED'
                """
            )
        )
        return result["completedFiles"]

    @property
    def completedBytes(self):
        result = next(
            self.db.query(
                """
                WITH dedup as (
                    SELECT f.path, max(f.sourceLengthBytes) as size
                    FROM file f
                    WHERE status = 'COMPLETED'
                    AND f.fileType != 'DIRECTORY'
                    GROUP By f.path
                )
                SELECT coalesce(sum(dedup.size), 0) as completedBytes
                FROM dedup
                """
            )
        )
        return result["completedBytes"]

    @property
    def totalFiles(self):
        result = next(
            self.db.query(
                """
                SELECT coalesce(count(distinct(path)), 0) as totalFiles
                FROM file
                WHERE fileType != 'DIRECTORY'
                AND status != 'EXCLUDED'
                """
            )
        )
        return result["totalFiles"]

    @property
    def totalBytes(self):
        totalBytesQuery = """
            WITH dedup as (
                SELECT f.path, max(f.sourceLengthBytes) as size
                FROM file f
                WHERE status != 'EXCLUDED'
                AND f.fileType != 'DIRECTORY'
                GROUP By f.path
            )
            SELECT sum(dedup.size) as totalBytes
            FROM dedup
            """
        result = next(self.db.query(totalBytesQuery))
        return result["totalBytes"]


class FileSelection(BaseModel):
    """
    Class that defines how to restore files from an archive.

    **Fields**

    * **includeDeleted**: `bool` Include deleted files in the requested archive metadata. Default=False
    * **backupSetIds**: `Optional[List[int]]` Optional list of backup set Ids to select for restore (V3 archives only).
    * **deletedMaxAge**: `Optional[datetime]` Filter deleted files by age of deletion. Files that were marked
        deleted in the archive prior to provided date will be excluded from restore.
    * **restoreAsOf**: `Optional[datetime]` Restore file versions as of the provided date.
    * **restoreModOnOrAfter**: `Optional[datetime]` Restore only files that were created or modified
        on or after this date.
    * **includePatterns**: `List[str]` Optional list of regex patterns to include, any files _not matching_ one of these
        patterns will be excluded from restore.
    * **excludePatterns**: `List[str]` Optional list of regex patterns to exclude, any files _matching_ one of these
        patterns will be excluded from restore.
    """

    includeDeleted: bool = False
    includeOnlyDeleted: bool = False
    backupSetIds: Optional[List[int]]
    deletedMaxAge: Optional[datetime]
    restoreAsOf: Optional[datetime]
    restoreModOnOrAfter: Optional[datetime]
    includePatterns: List[str] = Field(default_factory=list)
    excludePatterns: List[str] = Field(default_factory=list)

    @validator("includePatterns")
    def validate_include_regexes(cls, value):
        try:
            [re.compile(v) for v in value]
            return value
        except re.error as regex_error:
            raise click.ClickException(
                f"Unable to parse regex for include pattern '{regex_error.pattern}': {regex_error}"
            )

    @validator("excludePatterns")
    def validate_exclude_regexes(cls, value):
        try:
            [re.compile(v) for v in value]
            return value
        except re.error as regex_error:
            raise click.ClickException(
                f"Unable to parse regex for exclude pattern '{regex_error.pattern}': {regex_error}"
            )

    @staticmethod
    def load_patterns_from_file(filename: str, existing_patterns: tuple = None):
        if existing_patterns is None:
            existing_patterns = ()

        new_patterns = []
        with open(filename, "r") as file_ptr:
            for line in file_ptr.readlines():
                new_patterns.append(
                    line.strip()
                )  # remove newline character at the end of the line

        return existing_patterns + tuple(new_patterns)

    @property
    def endTimeMillis(self):
        if self.restoreAsOf is not None:
            return int(self.restoreAsOf.astimezone(timezone.utc).timestamp() * 1000)

    @property
    def modifiedOnOrAfterDateMillis(self):
        if self.restoreModOnOrAfter is not None:
            return int(
                self.restoreModOnOrAfter.astimezone(timezone.utc).timestamp() * 1000
            )

    @property
    def deletedAgeMillis(self):
        if self.deletedMaxAge is not None:
            return int(self.deletedMaxAge.astimezone(timezone.utc).timestamp() * 1000)


class JobDefinition(BaseModel):
    """
    Class that defines a RestoreJob parameters.

    **Fields**

    * **jobId**: `str` Identifier for job (will show in Audit Log entries). If not provided a new UUID will be generated.
    * **type**: `Optional[str]` Optional indicator of how the archives in the job were selected, i.e. by archive,
        device, username, or legal hold ID.
    * **identifier**: `Optional[str]` Optional indicator of what identifiers were provided of the given `type`.
    * **selection**: `FileSelection` Selection criteria for what files to restore from the archive.
    * **targetFolder**: `Path` Directory where the job metadata and restored files should be saved to (defaults to
        current working directory).
    * **zipResults**: `bool` Indicates if the restored files should be compressed to a zip archive per device in the job.
    """

    jobId: str = Field(default_factory=lambda: str(uuid4()))
    type: Optional[str]
    identifier: Optional[str]
    selection: FileSelection = Field(default_factory=FileSelection)
    targetFolder: Path = Field(default_factory=lambda: Path().absolute())
    dbTargetFolder: Optional[str]
    zipResults: bool = False
    archives: List[Archive]
    totalFileCount: int = 0
    totalByteCount: int = 0
    _devices: Dict[str, Device] = PrivateAttr(default_factory=dict)
    forceWinPathSanitization: bool = False
    log_file: Optional[str]
    log_level: Optional[str]
    device_log_level: Optional[str]
    restore_all_directories: bool = False

    @property
    def filename(self):
        return f"restore_{self.jobId}.json"

    @property
    def folder(self):
        return self.targetFolder / self.jobId

    @property
    def db_folder(self):
        if self.dbTargetFolder is None:
            return self.folder

        return Path().absolute() / self.dbTargetFolder / self.jobId


NAME_COLLISION: str = "-nameCollisionResolver"


def restore_path_suffix_from_source_path(
    path_unique: str,
    file_type: FileTypes,
    force_win_path_sanitization: bool,
) -> str:
    """
    Compute the device-relative restore path suffix (no deviceId prefix) from a logical
    source path, applying the same rules as ``RestorableFile.get_restore_path`` without
    mutating any model instance.

    ``path_unique`` is typically the backup ``path`` or a collision-disambiguated variant.
    """
    # Convert Windows drive to letter folder
    value = re.sub(r"^([A-Z]):", r"\1", path_unique)
    # remove root slash "/" if present
    value = value.lstrip("/")

    # Remove control characters for all file types on all platforms
    ascii_control_characters = r"[\x00-\x1F\x7F]"
    value = re.sub(ascii_control_characters, "_", value)

    if file_type == FileTypes.MAC_RSRC:
        resource_fork_string_identifier = r"/\.\.namedfork/rsrc$"
        if re.search(resource_fork_string_identifier, value) is not None:
            value = re.sub(resource_fork_string_identifier, "", value)
            value = re.sub(r"(\/)(?!.*\/)", "/._", value)
            value += ".rsrc"

    if platform.system() == "Windows" or force_win_path_sanitization:
        value_2 = replace_period_folders(value)

        path_parts = value_2.rsplit("/", 1)

        file_name_illegal_character_sanitization_regex = (
            r"(<|>|\^|\*|\||\?|\"|\\| +(?=\.))"
        )
        folder_name_illegal_character_sanitization_regex = (
            r"(<|>|\^|:|\*|\||\?|\"|\\|(?<=\/) +| +(?=\/)| +$)"
        )
        find_trailing_periods_regex = r"\.*$"

        if len(path_parts) == 1:
            sanitized_path = ""
            sanitized_filename = re.sub(
                file_name_illegal_character_sanitization_regex, "_", path_parts[0]
            ).strip()
            sanitized_filename = re.sub(
                find_trailing_periods_regex, "", sanitized_filename
            )
        else:
            sanitized_path = re.sub(
                folder_name_illegal_character_sanitization_regex, "_", path_parts[0]
            )
            sanitized_path = re.sub(find_trailing_periods_regex, "", sanitized_path)
            sanitized_filename = re.sub(
                file_name_illegal_character_sanitization_regex, "_", path_parts[1]
            ).strip()
            sanitized_filename = re.sub(
                find_trailing_periods_regex, "", sanitized_filename
            )

        value = f"{sanitized_path}/{sanitized_filename}"

    path_parts = value.rsplit("/", 1)
    if len(path_parts) == 1:
        path_parts.insert(0, "")

    new_dir = path_parts[0].replace(":", "_")

    if file_type != FileTypes.WINDOWS_NAMED_DATA_STREAM or (
        file_type == FileTypes.WINDOWS_NAMED_DATA_STREAM
        and not (platform.system() == "Windows" or force_win_path_sanitization)
    ):
        path_parts[1] = path_parts[1].replace(":", "_")

    if len(new_dir) > 0:
        value = f"{new_dir}/{path_parts[1]}"
    else:
        value = f"{path_parts[1]}"

    reserved_windows_filenames = (
        "CON",
        "PRN",
        "AUX",
        "NUL",
        "COM0",
        "COM1",
        "COM2",
        "COM3",
        "COM4",
        "COM5",
        "COM6",
        "COM7",
        "COM8",
        "COM9",
        "LPT0",
        "LPT1",
        "LPT2",
        "LPT3",
        "LPT4",
        "LPT5",
        "LPT6",
        "LPT7",
        "LPT8",
        "LPT9",
    )
    if platform.system() == "Windows" or force_win_path_sanitization:
        path_parts = value.split("/")
        new_path_parts = []
        for part in path_parts:
            if part.upper() in reserved_windows_filenames:
                p2 = "__" + part
            elif "." in part:
                filename_parts = part.split(".", 1)
                if filename_parts[0].upper() in reserved_windows_filenames:
                    filename_parts[0] = "__" + filename_parts[0]
                p2 = ".".join(filename_parts)
            else:
                p2 = part
            new_path_parts.append(p2)
        value = "/".join(new_path_parts)

    return value


def replace_period_folders(file_path):
    """
    Normalise Windows path segments for trailing/illegal period rules.

    * Backslashes are treated like slashes only when the path has no ``/`` (pure
      Windows-style). If any ``/`` is present, only ``/`` splits segments so a
      backslash inside a POSIX path (e.g. ``steve\\jobs``) stays one folder name
      until illegal-character sanitization runs.
    * A segment that is only ``.`` characters becomes the same-length underscore run.
    * For directory segments (not the final path component), a trailing run of ``.``
      becomes the same number of ``_`` (Windows disallows trailing periods in folder names).
    """
    parts = file_path.split("/")
    last_idx = len(parts) - 1
    updated_parts = []
    for i, part in enumerate(parts):
        if not part:
            updated_parts.append(part)
            continue
        if set(part) == {"."}:
            updated_parts.append("_" * len(part))
        elif i < last_idx:
            updated_parts.append(re.sub(r"\.+$", lambda m: "_" * len(m.group(0)), part))
        else:
            updated_parts.append(part)
    return "/".join(updated_parts)


class RestorableFile(BaseModel):
    deviceId: str
    archiveId: str
    planId: Optional[str]
    storageNodeWebUrl: str
    destinationName: str
    fileId: str
    path: str
    versionTimeMillis: int
    sourceLastModifiedMillis: int
    sourceLengthBytes: int
    sourceMD5Checksum: Optional[str]
    sourceSHA256Checksum: Optional[str]
    backupSetId: Optional[int]
    error: Optional[str]
    failure_count: int = 0
    status: FileStatus
    fileType: FileTypes
    pathUnique: Optional[str] = None

    path_unique: str = None

    def __init__(self, **kwargs):
        path_unique_override = kwargs.pop("pathUnique", None)
        # clear any keys from kwargs that are passed as None, which allows fallback to values in ENV
        kwargs = {k: v for k, v in kwargs.items() if v is not None}
        super().__init__(**kwargs)
        if path_unique_override is not None:
            self.pathUnique = path_unique_override
            self.path_unique = path_unique_override
        else:
            self.path_unique = self.path

    @property
    def restore_path(self):
        return self.get_restore_path()

    def resolve_path_name_collision(
        self, colliding_path: str, target_folder: str, log_util: LogUtil
    ):
        before_path = self.path_unique
        partial_path = colliding_path.replace(target_folder, "", 1)
        if partial_path == "":
            self.path_unique = self.path_unique + NAME_COLLISION
        else:
            self.path_unique = self.path_unique.replace(
                partial_path, partial_path + NAME_COLLISION, 1
            )

        after_path = self.path_unique

        if log_util is not None:
            log_util.log(
                f"Filename collision detected, renaming target file. archive={self.archiveId}  fileId={self.fileId} old_target={before_path} new_target={after_path}"
            )

    def get_restore_path(self, forceWinPathSanitization: bool = False):
        """
        This method modifies the original path to make it compatible with the host OS, then prepends
        it the deviceGuid; creating the "restore path".

        Args:
             forceWinPathSanitization (bool): Force  Windows compatible file paths

        Returns:
            str: The directory and file name (within the "target folder) to be created in the restore.
        """
        if self.path_unique is None:
            self.path_unique = self.path

        value = restore_path_suffix_from_source_path(
            self.path_unique, self.fileType, forceWinPathSanitization
        )

        # save the new structure of the path.
        self.path_unique = "/" + value

        return f"{self.deviceId}/{value}"

    def build_target_path(self, definition: JobDefinition) -> Path:
        """
        Helper method to get the final target path on disk, for a file that is about to be restored.

        Args:
             definition (JobDefinition): Job details containing where the target folder resides

        Returns:
            Path: Pointer to the file system location that can be written to
        """
        return definition.folder / self.get_restore_path(
            definition.forceWinPathSanitization
        )

    @property
    def size(self):
        """Human-readable file size. 1KB == 1000 Bytes."""
        return filesize.decimal(self.sourceLengthBytes)
