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 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"
    LEGAL_HOLD = "legalHold"


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 _remove_spaces(cls, value):
        return value.replace(" ", "_")

    @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 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[bool]
    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
    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")
            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
    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

    @property
    def restore_path(self):
        # Convert Windows drive to letter folder
        value = re.sub(r"^([A-Z]):", r"\1", self.path)
        # remove root slash "/" if present
        value = value.lstrip("/")
        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
    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}"
            )

    @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 completedFiles(self):
        result = next(
            self.db.query(
                """
                SELECT coalesce(count(distinct(fileId)), 0) as completedFiles
                FROM file
                WHERE fileType != 'DIRECTORY'
                AND status = 'COMPLETED'
                """
            )
        )
        return result["completedFiles"]

    @property
    def completedBytes(self):
        result = next(
            self.db.query(
                """
                with distinct_files as (
                    SELECT max(fileId), sourceLengthBytes
                    FROM latest_versions
                    WHERE fileType != 'DIRECTORY'
                    AND status = 'COMPLETED'
                    GROUP BY fileId
                )
                SELECT coalesce(sum(sourceLengthBytes), 0) as completedBytes
                FROM distinct_files
                """
            )
        )
        return result["completedBytes"]

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

    @property
    def totalBytes(self):
        result = next(
            self.db.query(
                """
                WITH dedupe as (
                    SELECT
                        fileId,
                        sourceLengthBytes,
                        row_number() OVER (PARTITION BY fileId) as num
                    FROM latest_versions
                    WHERE status != 'EXCLUDED'
                )
                SELECT coalesce(sum(sourceLengthBytes), 0) as totalBytes
                FROM dedupe
                WHERE num = 1
                """
            )
        )
        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())
    zipResults: bool = False
    archives: List[Archive]
    totalFileCount: int = 0
    totalByteCount: int = 0
    _devices: Dict[str, Device] = PrivateAttr(default_factory=dict)

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

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