# coding: utf-8
import sys
import os
import venv
import shutil
import subprocess
import time
import json
import re
import traceback
from pathlib import Path
from typing import Any, List, Optional
from importlib import import_module
from contextlib import redirect_stdout

import click
import git

from alembic import command


import typer

from modularapi.utils import AlembicConfig, _on_rmtree_error


if sys.version_info < (3, 6, 1):
    typer.echo("This framework need at least Python 3.6.1")
    exit(code=1)


# monkey patch sys.path to test modules run features without building the package
# Shouldn't have any impact
cwd = str(Path().resolve())
if cwd not in sys.path:
    sys.path.insert(0, cwd)

cli = typer.Typer()


def result_callback(result: Any) -> None:
    ctx = click.get_current_context()
    typer.echo(
        f"{ctx.obj['style']['success']} in {time.time() - ctx.obj['start_time']:.3f}s.",
        err=True,
    )


@cli.callback(result_callback=result_callback)
def setup(
    ctx: typer.Context,
):
    ctx.ensure_object(dict)
    ctx.obj = {
        "start_time": time.time(),
        "style": {
            "info": typer.style(
                "INFO", bg=typer.colors.WHITE, fg=typer.colors.BLACK, bold=True
            ),
            "success": typer.style(
                "SUCCESS", bg=typer.colors.GREEN, fg=typer.colors.BLACK, bold=True
            ),
            "warning": typer.style(
                "WARNING", bg=typer.colors.YELLOW, fg=typer.colors.BLACK, bold=True
            ),
            "error": typer.style(
                "ERROR", bg=typer.colors.RED, fg=typer.colors.BLACK, bold=True
            ),
        },
    }


@cli.command(name="version")
def cli_version(
    ctx: typer.Context,
):
    """
    Check the version of Modular-API.
    """
    import modularapi

    typer.echo(
        f"{ctx.obj['style']['info']} Modular-API is installed : version {modularapi.__version__}",
        err=True,
    )


# db
cli_db = typer.Typer(
    name="db",
    help="Manage your PostgreSQL database.",
    short_help="Manage your PostgreSQL database.",
)
cli.add_typer(cli_db)


# db branches
@cli_db.command(name="branches")
def cli_db_branches(
    verbose: bool = typer.Option(
        False,
        "-v",
        "--verbose",
        help="Output in verbose mode",
    )
):
    """
    Show current branch points.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.branches(config=alembic_cfg, verbose=verbose)


# db current
@cli_db.command(name="current")
def cli_db_current(
    verbose: bool = typer.Option(
        False,
        "-v",
        "--verbose",
        help="Output in verbose mode",
    )
):
    """
    Display the current revision for a database.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.current(config=alembic_cfg, verbose=verbose)


# db downgrade <revision>
@cli_db.command(name="downgrade")
def cli_db_downgrade(
    revision: str = typer.Argument(
        ...,
        help="A string revision target or range for –sql mode",
    ),
    sql: bool = typer.Option(
        False,
        help="Use the SQL mode",
    ),
):
    """
    Revert to a previous version.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.downgrade(config=alembic_cfg, revision=revision, sql=sql)


# db edit <rev>
@cli_db.command(name="edit")
def cli_db_edit(
    rev: str = typer.Argument(
        ...,
        help="A string revision target",
    )
):
    """
    Edit revision script(s) using $EDITOR.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.edit(config=alembic_cfg, rev=rev)


# db heads
@cli_db.command(name="heads")
def cli_db_heads(
    verbose: bool = typer.Option(
        False,
        "-v",
        "--verbose",
        help="Output in verbose mode",
    ),
    resolve_dependencies: bool = typer.Option(
        False,
        "--resolve-dependencies",
        help="Treat dependency version as down revisions.",
    ),
):
    """
    Revert to a previous version.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.heads(
        config=alembic_cfg,
        verbose=verbose,
        resolve_dependencies=resolve_dependencies,
    )


# db history
@cli_db.command(name="history")
def cli_db_history(
    rev_range: bool = typer.Option(
        False,
        "--rev-range",
        help="String revision range.",
    ),
    verbose: bool = typer.Option(
        False,
        "-v",
        "--verbose",
        help="Output in verbose mode.",
    ),
    indicate_current: bool = typer.Option(
        False,
        "--indicate_current",
        help="Indicate current revision.",
    ),
):
    """
    List changeset scripts in chronological order.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.history(
        config=alembic_cfg,
        rev_range=rev_range,
        verbose=verbose,
        indicate_current=indicate_current,
    )


# db merge
@cli_db.command(name="merge")
def cli_db_merge(
    revisions: List[str] = typer.Argument(
        ...,
        help="You can pass to it an argument such as heads, meaning we’d like to merge all heads."
        " Or, you can pass it individual revision numbers sequentally:",
    ),
    message: Optional[str] = typer.Option(
        None,
        "-m",
        "--message",
        help="A string message to apply to the revision",
    ),
    branche_label: Optional[str] = typer.Option(
        None,
        "--branch-label",
        help="A string label name to apply to the new revision",
    ),
    rev_id: Optional[str] = typer.Option(
        None,
        "--rev-id",
        help="Hardcoded revision identifier instead of generating a new one.",
    ),
):
    """
    Merge two (or more) revisions together. Creates a new migration file.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.merge(
        config=alembic_cfg,
        revisions=revisions,
        message=message,
        branch_label=branche_label,
        rev_id=rev_id,
    )


# db revision
@cli_db.command(name="revision")
def cli_db_revision(
    message: Optional[str] = typer.Option(
        None,
        "-m",
        "--message",
        help="A message to apply to the revision.",
    ),
    autogenerate: bool = typer.Option(
        False,
        "--autogenerate",
        help="Whether or not to autogenerate the script from the database.",
    ),
    sql: bool = typer.Option(
        False,
        "--sql",
        help="Whether to dump the script out as a SQL string; when specified, the script is dumped to stdout.",
    ),
    head: str = typer.Option(
        "head",
        "--head",
        help="Head revision to build the new revision upon as a parent.",
    ),
    splice: bool = typer.Option(
        False,
        "--splice",
        help="Whether or not the new revision should be made into a new head of its own;"
        " is required when the given head is not itself a head.",
    ),
    branch_label: Optional[str] = typer.Option(
        None,
        "--branche-label",
        help="A string label to apply to the branch.",
    ),
    version_path: Optional[str] = typer.Option(
        None,
        "--version-path",
        help="String symbol identifying a specific version path from the configuration.",
    ),
    rev_id: Optional[str] = typer.Option(
        None,
        "--rev-id",
        help="Optional revision identifier to use instead of having one generated.",
    ),
):
    """
    Create a new revision file.
    """
    from modularapi.settings import get_setting

    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("sqlalchemy.url", get_setting().PG_DNS)
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.revision(
        config=alembic_cfg,
        message=message,
        autogenerate=autogenerate,
        sql=sql,
        head=head,
        splice=splice,
        branch_label=branch_label,
        version_path=version_path,
        rev_id=rev_id,
    )


# db show <rev>
@cli_db.command(name="show")
def cli_db_show(
    revisions: str = typer.Argument(
        ...,
        help="A string revision target.",
    ),
):
    """
    Show the revision(s) denoted by the given symbol.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.show(config=alembic_cfg, rev=revisions)


# db stamp <revision='head'>
@cli_db.command(name="stamp")
def cli_db_stamp(
    revisions: List[str] = typer.Argument(
        ...,
        help="target revision or list of revisions. May be a list to indicate stamping of multiple branch heads.",
    ),
    sql: bool = typer.Option(
        False,
        "--sql",
        help="Use the SQL mode.",
    ),
    purge: bool = typer.Option(
        False,
        "--purge",
        help="Delete all entries in the version table before stamping.",
    ),
):
    """
    Stamp the revision table with the given revision; don’t run any migrations.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.stamp(config=alembic_cfg, revision=revisions, sql=sql, purge=purge)


# db upgrade <revision>
@cli_db.command(name="upgrade")
def cli_db_upgrade(
    revision: str = typer.Argument(
        ...,
        help="A string revision target or range for –sql mode",
    ),
    sql: bool = typer.Option(
        False,
        "--sql",
        help="Use the SQL mode.",
    ),
):
    """
    Upgrade to a later version.
    """
    alembic_cfg = AlembicConfig("alembic.ini")
    alembic_cfg.set_main_option("script_location", str(Path() / "db_migrations"))
    command.upgrade(config=alembic_cfg, revision=revision, sql=sql)


# module
cli_modules = typer.Typer(
    name="modules",
    help="Manage your modules.",
    short_help="Manage your modules.",
)
cli.add_typer(cli_modules)


# modules add <git_remote_link>
@cli_modules.command(name="add")
def cli_modules_add(
    ctx: typer.Context,
    github_repo: str = typer.Argument(
        ...,
        help="The git remote URL",
    ),
):
    """
    Add a module from a git remote url ( github / gitlab / ect... ).
    """
    p = Path("./modules")

    if not p.is_dir():
        typer.echo(
            f"{ctx.obj['style']['warning']} The `modules` directory doesn't exist, creating one ..."
        )
    p.mkdir(parents=True, exist_ok=True)

    m = re.match(r"https?://(.+\..+)/(?P<owner>.+)/(?P<name>.+)(\.git)?", github_repo)
    if m:
        repo_name = f"{m.group('owner')}_{m.group('name')}"
    else:
        # fallback name
        if github_repo.endswith(".git"):
            repo_name = github_repo.split("/")[-1].split(".")[:-1]
        else:
            repo_name = github_repo.split("/")[-1]

    if (p / repo_name).is_dir():
        typer.echo(
            f"{ctx.obj['style']['error']} The module `{repo_name}` is already installed !"
        )
        raise typer.Exit(code=1)

    typer.echo(f"{ctx.obj['style']['info']} Downloading the module ...")

    try:
        git.Repo.clone_from(url=github_repo, to_path=p / repo_name)
    except git.exc.CommandError:
        typer.echo(
            f"{ctx.obj['style']['error']} Repository `{github_repo}` doesn't exist !"
        )
        raise typer.Exit(code=1)

    typer.echo(
        f"{ctx.obj['style']['success']} The module has been installed in `{p / repo_name}`."
    )
    typer.echo(
        f"{ctx.obj['style']['info']} Searching for a `requirements.txt` file ..."
    )
    req_file = p / repo_name / "requirements.txt"
    if req_file.is_file():
        typer.echo(
            f"{ctx.obj['style']['info']} requirements file found, installing dependencies on the venv ..."
        )

        # on Windows
        if os.name == "nt":
            python_path = Path("venv") / "Scripts" / "python.exe"

        # on Unix
        else:
            python_path = Path("venv") / "bin" / "python"

        subprocess.run(
            [python_path, "-m", "pip", "install", "-r", req_file, "--upgrade"],
            check=True,
        )
    else:
        typer.echo(f"{ctx.obj['style']['info']} requirements files not found.")


# modules remove <module_name>
@cli_modules.command(name="remove")
def cli_modules_remove(
    ctx: typer.Context,
    module_name: str = typer.Argument(
        ...,
        help="The module name to remove",
    ),
):
    """
    Remove a module.
    """
    p = Path("./modules") / Path(module_name)
    if not p.is_dir():
        typer.echo(
            f"{ctx.obj['style']['error']} The module `{module_name}` doesn't exist !"
        )
        raise typer.Exit(code=1)

    typer.echo(f"{ctx.obj['style']['info']} Uninstalling `{module_name}` ...")
    shutil.rmtree(p, onerror=_on_rmtree_error)
    typer.echo(
        f"{ctx.obj['style']['success']} `{module_name}` has been correctly removed."
    )


# modules purge
@cli_modules.command(name="purge")
def cli_modules_purge(
    ctx: typer.Context,
):
    """
    Remove all modules.
    """
    p = Path("./modules")
    if not p.is_dir():
        typer.echo(f"{ctx.obj['style']['error']} There is no modules directory !")
        raise typer.exit(code=1)

    shutil.rmtree(p, onerror=_on_rmtree_error)
    p.mkdir(parents=True, exist_ok=True)


# modules update <module_name>
@cli_modules.command(name="update")
def cli_modules_update(
    ctx: typer.Context,
    module_name: str = typer.Argument(
        ...,
        help="The module name to update",
    ),
):
    """
    Update a module from a git remote.
    """
    p = Path("./modules") / module_name

    if not p.is_dir():
        typer.echo(
            f"{ctx.obj['style']['error']} The module `{module_name}` doesn't exist"
        )
        raise typer.Exit(code=1)

    typer.echo(f"{ctx.obj['style']['info']} Updating `{module_name}` ...")

    try:
        repo = git.Repo(p)
    except git.exc.InvalidGitRepositoryError:
        typer.echo(
            f"{ctx.obj['style']['error']} the module `{p}` isn't valid (probably missing the .git folder) !",
        )
        raise typer.Exit(code=1)

    try:
        repo.remotes.origin.pull()
    except git.exc.GitCommandError:
        # In the case the repo was deleted from github/gitlab/ect
        typer.echo(
            f"{ctx.obj['style']['error']} Unable to update, check the remote origin !"
        )
        raise typer.Exit(code=1)

    typer.echo(f"{ctx.obj['style']['info']} `{module_name}` has been updated.")
    typer.echo(
        f"{ctx.obj['style']['info']} Searching for a `requirements.txt` file ..."
    )

    req_file = p / "requirements.txt"
    if req_file.is_file():
        typer.echo(
            f"{ctx.obj['style']['info']} requirements file found, installing dependencies on the venv ..."
        )

        # on Windows
        if os.name == "nt":
            python_path = Path("venv") / "Scripts" / "python.exe"

        # on Unix
        else:
            python_path = Path("venv") / "bin" / "python"

        subprocess.run(
            [python_path, "-m", "pip", "install", "-r", req_file, "--upgrade"],
            check=True,
        )
    else:
        typer.echo(f"{ctx.obj['style']['info']} requirements files not found.")


# modules update-all
@cli_modules.command(name="update-all")
def cli_modules_update_all(
    ctx: typer.Context,
):
    """
    Update all modules.
    """
    p = Path("./modules")
    for module_name in p.glob("*"):
        if module_name.is_dir():
            try:
                repo = git.Repo(p / module_name)
                repo.remotes.origin.pull()
            except git.exc.InvalidGitRepositoryError:
                typer.echo(
                    f"{ctx.obj['style']['warning']} the module `{module_name}` couldn't be updated !",
                )
            except git.exc.GitCommandError:
                # In the case the repo was deleted from github/gitlab/ect
                typer.echo(
                    f"{ctx.obj['style']['error']} Unable to update, check the remote origin !"
                )
            typer.echo(
                f"{ctx.obj['style']['info']} Searching for a `requirements.txt` file ..."
            )

            req_file = module_name / "requirements.txt"
            if req_file.is_file():
                typer.echo(
                    f"{ctx.obj['style']['info']} requirements file found, installing dependencies on the venv ..."
                )

                # on Windows
                if os.name == "nt":
                    python_path = Path("venv") / "Scripts" / "python.exe"

                # on Unix
                else:
                    python_path = Path("venv") / "bin" / "python"

                subprocess.run(
                    [python_path, "-m", "pip", "install", "-r", req_file, "--upgrade"],
                    check=True,
                )
            else:
                typer.echo(f"{ctx.obj['style']['info']} requirements files not found.")


# modules export <file.json=sys.stdout>
@cli_modules.command(name="export")
def cli_modules_export(
    ctx: typer.Context,
    output_file: typer.FileTextWrite = typer.Argument(
        sys.stdout,
        help="The output file (sys.stdout by default).",
    ),
):
    """
    Export all modules as json format.
    """
    repos = {}
    for module_name in Path("./modules").glob("*"):
        if module_name.is_dir():
            try:
                repos[module_name.parts[-1]] = git.Repo(module_name)
            except git.exc.InvalidGitRepositoryError:
                typer.echo(
                    f"{ctx.obj['style']['warning']} the module `{module_name}` couldn't be exported !"
                )

    res = {str(path): list(repo.remotes.origin.urls) for path, repo in repos.items()}
    json.dump(res, fp=output_file, indent=4, sort_keys=True)
    if output_file is sys.stdout:
        sys.stdout.flush()


# modules import <file.json=sys.stdin>
@cli_modules.command(name="import")
def cli_modules_import(
    ctx: typer.Context,
    input_file: typer.FileText = typer.Argument(
        sys.stdin,
        help="The input file (sys.stdin by default).",
    ),
):
    """
    Import all modules from json format file.
    """
    p = Path("./modules")
    for module, repo_urls in json.load(fp=input_file).items():
        try:
            git.Repo.clone_from(url=repo_urls[0], to_path=(p / module))
        except (IndexError, git.exc.GitCommandError):
            typer.echo(
                f"{ctx.obj['style']['warning']} the module `{module}` couldn't be imported !"
            )


# modules update list
@cli_modules.command(name="list")
def cli_modules_list(
    ctx: typer.Context,
):
    """
    List all installed modules.
    """
    p = Path("modules")
    if not p.is_dir():
        typer.echo(
            f"{ctx.obj['style']['error']} There is no modules directory !", err=True
        )
        raise typer.Exit(code=1)

    modules = [m.parts[-1] for m in p.glob("*") if m.is_dir()]
    if modules:
        typer.echo("\n".join(f"{m} is installed" for m in modules))
    else:
        typer.echo(f"{ctx.obj['style']['warning']} There is no module installed !")


# modules create <module_name>
@cli_modules.command(name="create")
def cli_modules_create(
    ctx: typer.Context,
    module_name: str = typer.Argument(..., help="The module name to create."),
    readme: bool = typer.Option(
        False,
        "--readme",
        "--with-readmy",
        help="Keep the readme.md file in your module.",
    ),
):
    """
    Create a module from the official template
    """
    template_url = "https://github.com/Modular-Lab/module_template.git"

    p = Path("modules")

    # ensure the modules directory exists
    p.mkdir(exist_ok=True)

    if (p / module_name).is_dir():
        typer.echo(f"{ctx.obj['style']['error']} The module already exists !", err=True)
        raise typer.Exit(code=1)

    try:
        git.Repo.clone_from(url=template_url, to_path=p / module_name)
    except git.exc.CommandError:
        typer.echo(
            f"{ctx.obj['style']['error']} Unable to clone the template !", err=True
        )
        typer.echo(traceback.format_exc())
        raise typer.Exit(code=1)

    shutil.rmtree(p / module_name / ".git", onerror=_on_rmtree_error)
    (p / module_name / "LICENSE").unlink(missing_ok=True)

    if not readme:
        (p / module_name / "readme.md").unlink(missing_ok=True)

    typer.echo(
        f"{ctx.obj['style']['success']} The module has been created in `{p / module_name}`."
    )


# init <project_name>
@cli.command(name="init")
def cli_projet_init(
    ctx: typer.Context,
    project_path: Path = typer.Argument(
        ...,
        dir_okay=True,
        writable=True,
        help="The project name",
    ),
):
    """
    Init a new Modular project
    """
    # check if the path already exists
    if project_path.is_dir():
        typer.echo(f"{ctx.obj['style']['error']} the path `{project_path}` already exists !")
        raise typer.Exit(code=1)

    # Initialization
    typer.echo(
        f"{ctx.obj['style']['info']} Initializing a new project at `{project_path}` ..."
    )
    (project_path / "modules").mkdir(parents=True)

    alembic_cfg = AlembicConfig(file_=project_path / "alembic.ini")
    alembic_cfg.set_main_option("script_location", str(project_path / "db_migrations"))

    # initialize the db migrations
    with open(os.devnull, "w") as f:
        with redirect_stdout(f):
            command.init(
                config=alembic_cfg,
                directory=project_path / "db_migrations",
                template="default",
            )

    # creating the venv
    typer.echo(f"{ctx.obj['style']['info']} Creating the venv ...")
    venv.EnvBuilder(with_pip=True).create(project_path / "venv")

    # install dependancies
    typer.echo(f"{ctx.obj['style']['info']} Installing dependancies in the venv ...")

    # on Windows
    if os.name == "nt":
        python_path = project_path / "venv" / "Scripts" / "python.exe"

    # on Unix
    else:
        python_path = project_path / "venv" / "bin" / "python"

    subprocess.run([python_path, "-m", "pip", "install", "-U", "pip"], check=True)
    subprocess.run(
        [
            python_path,
            "-m",
            "pip",
            "install",
            "-r",
            Path(__file__).parent / "venv_requirements.txt",
        ],
        check=True,
    )

    shutil.copyfile(
        src=Path(__file__).parent / "venv_requirements.txt",
        dst=project_path / "requirements.txt",
    )

    with open(project_path / ".env", "w", encoding="utf-8") as f:
        print('ENVIRONMENT="developpment"', file=f)
        print('PG_DNS="postgres://user:password@host:port/database"', file=f)

    cd_message = typer.style(f"cd {project_path}", bold=True, fg="green")
    modularAPI_message = typer.style("ModularAPI", bold=True, fg="green")
    typer.echo(
        f"\n{ctx.obj['style']['success']} You can now do `{cd_message}` and start using {modularAPI_message}"
    )
    typer.echo(f"\nDon't forget to edit `{project_path / '.env'}` !")


cli_modules_run = typer.Typer(
    name="run",
    help="Run your module CLI.",
    short_help="Run your module CLI.",
)
cli_modules.add_typer(cli_modules_run)


for cli_file in Path("modules").glob("*/cli.py"):
    if cli_file.is_file():
        module_path = ".".join(cli_file.parts[:-1] + (cli_file.stem,))
        module = import_module(module_path)
        cli_modules_run.add_typer(module.cli, name=cli_file.parent.parts[-1])


if __name__ == "__main__":
    cli()
