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


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


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]

    @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]

    @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 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

    directory_name_collision: bool = False

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

    def set_directory_name_collision(self):
        self.directory_name_collision = True

    def get_restore_path(self, forceWinPathSanitization: bool = False):
        # Convert Windows drive to letter folder
        value = re.sub(r"^([A-Z]):", r"\1", self.path)
        # remove root slash "/" if present
        value = value.lstrip("/")

        if self.fileType == FileTypes.MAC_RSRC:
            # In theory this should only do this not on Mac but in practice mac does not like python
            # adding resource forks to things

            # Remove the named fork section of the path
            value = re.sub(r"/\.\.namedfork/rsrc$", "", value)
            # Replace the last slash (so before the file name) with a `/._` to prepend that `._` to the filename
            value = re.sub(r"(\/)(?!.*\/)", "/._", value)
            # Make the file extension a txt
            value = re.sub(r"(\w+)$", "rsrc", value)

        if platform.system() == "Windows" or forceWinPathSanitization:
            path_parts = value.rsplit("/", 1)

            if len(path_parts) == 1:
                sanitized_path = ""
                sanitized_filename = re.sub(
                    r"(<|>|\*|\||\?|\"| +(?=\.))", "_", path_parts[0]
                ).strip()
                sanitized_filename = re.sub(r"\.*$", "", sanitized_filename)
            else:
                sanitized_path = re.sub(
                    r"(<|>|:|\*|\||\?|\"|(?<=\/) +| +(?=\/)| +$)", "_", path_parts[0]
                )
                sanitized_filename = re.sub(
                    r"(<|>|\*|\||\?|\"| +(?=\.))", "_", path_parts[1]
                ).strip()
                sanitized_filename = re.sub(r"\.*$", "", sanitized_filename)

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

        # deal with : in folder name
        path_parts = value.rsplit("/", 1)
        if len(path_parts) == 1:
            path_parts.insert(0, "")

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

        # deal with : in file name (in some cases)
        if self.fileType != FileTypes.WINDOWS_NAMED_DATA_STREAM or (
            self.fileType == FileTypes.WINDOWS_NAMED_DATA_STREAM
            and not (platform.system() == "Windows" or forceWinPathSanitization)
        ):
            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]}"

        # Handle reserved file names in Windows
        # Python is not happy with this attached to 'RestorableFile'. Inlining
        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 forceWinPathSanitization:
            path_parts = value.split("/")
            new_path_parts = []
            for part in path_parts:
                # check the full name
                if part.upper() in reserved_windows_filenames:
                    p2 = "__" + part

                # If the path part starts with disallowed name, followed by a period..
                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)

        if self.directory_name_collision:
            value += "__directory_name_collision"

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

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


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.
    * **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]
    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 deletedAgeMillis(self):
        if self.deletedMaxAge is not None:
            return int(self.deletedMaxAge.astimezone(timezone.utc).timestamp() * 1000)


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 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

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