import logging
import sys
import traceback
from pathlib import Path

import click
from bulk_restore_tool import models
from rich.console import Console

from bulk_restore_tool.__about__ import __version__
from bulk_restore_tool._client import Client
from bulk_restore_tool._db import V1BackupSetException
from bulk_restore_tool._job import RestoreJob
from bulk_restore_tool.models import DetailType
from bulk_restore_tool.models import JobDefinition
from bulk_restore_tool.models import FileSelection

console = Console(stderr=True)


def print_entry_message(logger, command_type):
    logger.info(f"BRT {command_type} command called, version: {__version__}")
    command_string = " ".join(sys.argv)
    logger.info(f"BRT invoked: {command_string}")


def store_in_context(ctx, param, arg):
    """
    Callback for `click.option`s that stores option value in the `ctx.obj` (also ensures this is a dict).

    Useful for enabling "global" options that can be passed at any stage of the command or sub-commands.
    """
    if arg:
        ctx.ensure_object(dict)
        ctx.obj.update({param.name: arg})
    return arg


log_file_option = click.option(
    "--log-file",
    help="Specify location where logging gets written. Can also be set by BRT_LOG_FILE environment variable. "
    "Default='bulk_restore.log' in current directory.",
    callback=store_in_context,
    expose_value=False,
)
log_level_option = click.option(
    "--log-level",
    type=click.Choice(["CRITICAL", "WARN", "INFO", "DEBUG"]),
    help="Specify logging level for main logger. Can also be set by BRT_LOG_LEVEL environment variable. Default=INFO.",
    callback=store_in_context,
    expose_value=False,
)


def logging_options(f):
    f = log_file_option(f)
    f = log_level_option(f)
    return f


@click.group(
    context_settings={
        "help_option_names": ["-h", "--help"],
        "max_content_width": 200,
    },
    invoke_without_command=True,
    no_args_is_help=True,
)
@click.option(
    "--version", is_flag=True, help="Print the currently installed version of the tool."
)
@logging_options
def brt(version, **kwargs):
    """
    \b
    Code42 Bulk Restore Tool

    To authenticate, set the following variables either in your shell environment or an .env file in your
    current working directory:

    \b
        BRT_URL
        BRT_API_CLIENT_ID
        BRT_API_CLIENT_SECRET

    For details on API Client authentication, see: https://support.code42.com/Incydr/Admin/Code42_console_reference/API_clients
    """
    if version:
        print(__version__)


@brt.command()
@click.argument("id_", metavar="ID")
@click.option(
    "--name",
    help="Specify a name for the restore job. This name will be visible in audit log entries for the restore session. "
    "Default = A generated UUID.",
)
@click.option(
    "--type",
    "type_",
    type=click.Choice([d.value for d in DetailType]),
    default="archive",
    help="Specify what type of identifier to gather archive metadata for. Default = archive",
)
@click.option(
    "--target-folder",
    type=click.Path(),
    help="Specify target folder where archive metadata will be stored (will be created if it doesn't exist). "
    "Default = Directory `brt` is executed in.",
)
@click.option(
    "--database-target-folder",
    type=click.Path(),
    help="Specify target folder where the metadata database will be stored (will be created if it doesn't exist). "
    "Default = --target-folder location or Directory `brt` is executed in.",
)
@click.option(
    "--definition-folder",
    type=click.Path(),
    help="Specify target folder the job definition .json file will be written to. "
    "Default = Directory `brt` is executed in.",
)
@click.option(
    "--zip",
    "zip_",
    is_flag=True,
    help="Restore files into .zip archives, one per device restored.",
    hidden=True,
)
@click.option(
    "--include-pattern",
    multiple=True,
    type=str,
    help="Specify regex pattern to select which file paths to include in the restore. Can be passed multiple times.",
)
@click.option(
    "--exclude-pattern",
    multiple=True,
    type=str,
    help="Specify regex pattern to select which file paths to exclude in the restore. Can be passed multiple times. A "
    "match on an exclude pattern will take precedence over any matching include patterns.",
)
@click.option(
    "--include-pattern-file",
    multiple=False,
    type=str,
    help="Path to a line delimited file containing a list of inclusion regex patterns.",
)
@click.option(
    "--exclude-pattern-file",
    multiple=False,
    type=str,
    help="Path to a line delimited file containing a list of exclusion regex patterns. A "
    "match on an exclude pattern will take precedence over any matching include patterns.",
)
@click.option(
    "--include-deleted",
    is_flag=True,
    help="Include files that have been marked as deleted in the archive file metadata. Default = false",
)
@click.option(
    "--include-only-deleted",
    is_flag=True,
    help="Restore only files that are marked as deleted in the archive file metadata. Default = false",
    hidden=True,
)
@click.option(
    "--include-empty-directories",
    is_flag=True,
    help="Restore all directories, even those without files. Default = false",
    hidden=False,
)
@click.option(
    "--max-deleted-date",
    type=click.DateTime(),
    help="Specify date for maximum age of deleted files to restore. Any files that were marked as deleted before this "
    "date will be excluded from restore. Default = None",
)
@click.option(
    "--as-of-date",
    type=click.DateTime(),
    help="Restore files as of specified date. Default = None",
)
@click.option(
    "--backup-set-ids",
    type=int,
    multiple=True,
    help="(V3 archives only) Specify which backup sets from the archive should be included, can be passed multiple "
    "times to include files from multiple sets. Default = Include files from all backup sets.",
)
@click.option(
    "--no-calculate",
    is_flag=True,
    help="Skip gathering of archive metadata and calculation of restore totals and only create job file. "
    "Default = false",
)
@click.option(
    "--force-windows-safe-paths",
    is_flag=True,
    help="Certain characters and patterns that are acceptable on other systems are not on Windows so a sanitization of the path "
    "occurs. This forces that reguardless of OS the tool is run on. "
    "Default = false",
)
@logging_options
@click.pass_context
def create_job(
    ctx,
    id_,
    target_folder,
    database_target_folder,
    definition_folder,
    type_,
    zip_,
    include_pattern,
    exclude_pattern,
    include_pattern_file,
    exclude_pattern_file,
    include_deleted,
    include_only_deleted,
    include_empty_directories,
    backup_set_ids,
    max_deleted_date,
    as_of_date,
    name,
    no_calculate,
    force_windows_safe_paths,
):
    """
    Creates a restore job for a set of archives.

    Archives can be included in the job by specifying the identifier type (archive, device, or username)
    via the --type option (defaults to archive) and providing the identifier as the main command-line argument.
    Some examples:

        \b
        brt create-job --type archive id1
        brt create-job --type username user@example.com
        brt create-job --type device 1234

    If the --name option is not provided, a new UUID will be created as the job name and the parameters for the job will
    be saved in restore_<job_name>.json

    A <job_name> folder will also be created in the --target-folder path to store the archive metadata and restored
    files (if --target-folder option is not provided, it defaults to the current working directory).

    In the <job_name> folder, a human-readable .json file will be generated for each archive containing the file
    metadata from that archive. Additionally, a sqlite .db file will also be generated for each device in the job to
    track restore progress per device. Because a device can have multiple (destination) archives and/or multiple backup
    sets within a single archive, it's possible that some file paths will be duplicated within the full set of a single
    device's metadata. While duplicates will remain in the individual archive metadata files, they will be de-duplicated
    when restoring and when calculating the total file count and byte count of the restore job (the fileId with the most
    recent modification timestamp will be restored).
    """
    target_folder = Path(target_folder) if target_folder else Path(".")

    if include_pattern_file is not None:
        include_pattern = models.FileSelection.load_patterns_from_file(
            include_pattern_file, include_pattern
        )
    if exclude_pattern_file is not None:
        exclude_pattern = models.FileSelection.load_patterns_from_file(
            exclude_pattern_file, exclude_pattern
        )
    params = {
        "includeDeleted": include_deleted,
        "includeOnlyDeleted": include_only_deleted,
        "backupSetIds": backup_set_ids,
        "deletedMaxAge": max_deleted_date,
        "restoreAsOf": as_of_date,
        "includePatterns": include_pattern,
        "excludePatterns": exclude_pattern,
    }
    log_options = ctx.obj or {}
    client = Client(**log_options)
    print_entry_message(client.settings.logger, "create-job")
    try:
        ids = id_.split(",")
        archives = []
        for _id in ids:
            archives.extend(client.get_archive_details(_id, id_type=type_))
        job_definition = JobDefinition(
            type=type_,
            identifier=id_,
            selection=FileSelection(**params),
            archives=archives,
            targetFolder=target_folder.absolute(),
            dbTargetFolder=database_target_folder,
            zipResults=zip_,
            forceWinPathSanitization=force_windows_safe_paths,
            restore_all_directories=include_empty_directories,
            **log_options,
        )
        if name:
            job_definition.jobId = name
        definition_folder = Path(definition_folder) if definition_folder else Path(".")
        definition_file = definition_folder / job_definition.filename
        client.settings.logger.info(f"Creating job file: {definition_file}")
        with open(definition_file, "w") as f:
            f.write(job_definition.json(indent=2))
        if no_calculate:
            return
        job = RestoreJob(definition=job_definition, **log_options)

        try:
            job.prepare(True)
            console.print(f"Job definition saved to: {definition_file}")
            return job.get_job_status()
        except V1BackupSetException as err:
            console.print(f"[red]Error:[/red] {err.args[0]}")
    except Exception as err:
        client.log_util.log(traceback.format_exc(), logging.ERROR)
        raise err


@brt.command()
@click.option(
    "--quiet",
    "-q",
    is_flag=True,
    help="Silence output to stdout. Restore progress can be tracked via log files.",
)
@click.argument("JOB_FILE", type=click.File())
@logging_options
@click.option(
    "--device-log-level",
    type=click.Choice(["CRITICAL", "ERROR", "WARN", "INFO", "DEBUG"]),
    help="Specify level for loggers that write to the individual <deviceGuid>.log and <deviceGuid>_error.log files in "
    "the restore target directory. Can also be set by BRT_DEVICE_LOG_LEVEL environment variable. Defaults to INFO. "
    "Setting to CRITICAL effectively disables all device-specific logging.",
    callback=store_in_context,
    expose_value=False,
)
@click.pass_context
def start_job(ctx, job_file, quiet):
    """Begins restoring from archives defined in a given job file."""
    log_options = ctx.obj or {}
    job_definition = JobDefinition.parse_raw(job_file.read())
    if len(job_definition.archives) < 1:
        console.print(
            "Exiting restore job. There were no archives identified to restore."
        )
        return 100
    job = RestoreJob(definition=job_definition, quiet=quiet, **log_options)
    print_entry_message(job.settings.logger, "start-job")
    try:
        job.start()
        return job.get_job_status()

    except V1BackupSetException as err:
        console.print(f"[red]Error:[/red] {err.args[0]}")

    except Exception as err:
        job.settings.logger.error(traceback.format_exc())
        raise err
