import argparse
import os
import sys
from pathlib import Path

from pipenv import environments
from pipenv.cli.options import (
    apply_default_categories,
    apply_env_vars,
    build_parser,
    build_state,
)
from pipenv.utils import console, err
from pipenv.utils.environment import load_dot_env
from pipenv.utils.processes import subprocess_run

with console.capture() as capture:
    console.print("[bold]pipenv[/bold]", end="")

prog_name = capture.get()

LOCK_HEADER = """\
#
# These requirements were autogenerated by pipenv
# To regenerate from the project's Pipfile, run:
#
#    pipenv requirements {options}
#
"""


LOCK_DEV_NOTE = """\
# Note: in pipenv 2020.x, "--dev" changed to emit both default and development
# requirements. To emit only development requirements, pass "--dev-only".
"""


def do_py(project, system=False, bare=False):
    if not project.venv_locator.exists:
        err.print(
            "[red]No virtualenv has been created for this project[/red] "
            f"[yellow bold]{project.pipfile.project_directory}[/yellow bold] "
            "[red] yet![/red]"
        )
        sys.exit(1)

    try:
        (print if bare else console.print)(project.venv_locator._which("python", allow_global=system))
    except AttributeError:
        console.print("No project found!", style="red")
        sys.exit(1)


def _open_editor(filename):
    """Open filename in the user's editor (replaces click.edit)."""
    import subprocess

    editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
    subprocess.call([editor, str(filename)])


def cmd_install(args, state):
    from pipenv.routines.context import RoutineContext
    from pipenv.routines.install import do_install

    if state.installstate.all_categories:
        state.installstate.categories = state.project.pipfile.get_package_categories()
    else:
        apply_default_categories(args, state)

    ctx = RoutineContext.from_cli(
        # target_env
        system=state.system,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        site_packages=state.site_packages,
        # install_policy
        pre=state.installstate.pre,
        deploy=state.installstate.deploy,
        skip_lock=state.installstate.skip_lock,
        ignore_pipfile=state.installstate.ignore_pipfile,
        # T17: propagate ``--clear`` through so the inner ``do_lock``
        # (reached via ``do_init``) can wipe pipenv's parsed-manifest
        # cache.  Previously ``state.clear`` only had an effect for
        # the top-level ``pipenv --clear`` command.
        clear=state.clear,
        # package_selection
        packages=state.installstate.packages,
        editable_packages=state.installstate.editables,
        categories=state.installstate.categories,
        dev=state.installstate.dev,
        index=state.index,
        requirementstxt=state.installstate.requirementstxt,
        # execution_options
        extra_pip_args=state.installstate.extra_pip_args,
        resolver=state.resolver,
    )
    do_install(state.project, ctx)


def cmd_remove(args, state):
    from pipenv.utils.virtualenv import cleanup_virtualenv

    if state.project.s.PIPENV_USE_SYSTEM or environments.is_in_virtualenv():
        console.print(
            "You are attempting to remove a virtualenv that "
            "Pipenv did not create. Aborting.",
            style="red",
        )
        sys.exit(1)
    if state.project.venv_locator.exists:
        loc = state.project.venv_locator.location
        console.print(f"[bold]Removing virtualenv[/bold] ([green]{loc}[/green])...")
        with console.status("Running..."):
            cleanup_virtualenv(state.project, bare=True)
        return 0
    else:
        err.print(
            "No virtualenv has been created for this project yet!",
            style="red bold",
        )
        sys.exit(1)


def cmd_upgrade(args, state):
    from pipenv.routines.update import upgrade
    from pipenv.utils.project import ensure_project

    if state.installstate.all_categories:
        state.installstate.categories = state.project.pipfile.get_package_categories()
    else:
        apply_default_categories(args, state)

    ensure_project(
        state.project,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        warn=(not state.quiet),
        site_packages=state.site_packages,
        clear=state.clear,
    )

    upgrade(
        state.project,
        pre=state.installstate.pre,
        packages=state.installstate.packages,
        editable_packages=state.installstate.editables,
        categories=state.installstate.categories,
        index_url=state.index,
        dev=state.installstate.dev,
        system=state.system,
        lock_only=state.installstate.lock_only,
        extra_pip_args=state.installstate.extra_pip_args,
        resolver=state.resolver,
    )


def cmd_uninstall(args, state):
    from pipenv.routines.context import RoutineContext
    from pipenv.routines.uninstall import do_uninstall

    # --dev means "operate on dev-packages"; translate it to a category so that
    # do_uninstall looks in the right Pipfile section.
    if getattr(args, "dev", None) and not state.installstate.categories:
        state.installstate.categories = ["dev-packages"]

    apply_default_categories(args, state)

    ctx = RoutineContext.from_cli(
        # target_env
        system=state.system,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        # install_policy
        pre=state.installstate.pre,
        lock=False,
        # package_selection
        packages=state.installstate.packages,
        editable_packages=state.installstate.editables,
        categories=state.installstate.categories,
        all_dev=args.all_dev,
        all=args.all,
        # execution_options
        resolver=state.resolver,
    )
    retcode = do_uninstall(state.project, ctx)
    if retcode:
        sys.exit(retcode)


def cmd_lock(args, state):
    from pipenv.routines.context import RoutineContext
    from pipenv.routines.lock import do_lock
    from pipenv.utils.project import ensure_project

    apply_default_categories(args, state)

    ensure_project(
        state.project,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        warn=(not state.quiet),
        site_packages=state.site_packages,
    )

    ctx = RoutineContext.from_cli(
        # target_env
        pypi_mirror=state.pypi_mirror,
        # install_policy
        pre=state.installstate.pre,
        clear=state.clear,
        # package_selection
        categories=state.installstate.categories,
        # execution_options
        quiet=state.quiet,
        write=True,
        resolver=state.resolver,
    )
    do_lock(state.project, ctx)


def cmd_shell(args, state):
    from pipenv.routines.shell import do_shell

    if "PIPENV_ACTIVE" in os.environ:
        venv_name = os.environ.get("VIRTUAL_ENV", "UNKNOWN_VIRTUAL_ENVIRONMENT")
        if not args.anyway:
            err.print(
                f"Shell for [green bold]{venv_name}[/green bold] "
                "[bold]already activated[/bold].\n"
                "New shell not activated to avoid nested environments."
            )
            sys.exit(1)

    fancy = args.fancy
    if (
        os.name == "nt"
        or Path(os.environ.get("PIPENV_SHELL") or "").name == "pwsh"
        or Path(os.environ.get("SHELL") or "").name == "pwsh"
        or os.environ.get("POSH_THEME")
    ):
        fancy = True
    do_shell(
        state.project,
        python=state.python,
        fancy=fancy,
        shell_args=args.shell_args,
        pypi_mirror=state.pypi_mirror,
        quiet=state.quiet,
    )


def cmd_activate(args, state):
    from pipenv.shells import ShellDetectionFailure, _get_activate_script, detect_info
    from pipenv.utils.project import ensure_project

    ensure_project(
        state.project,
        python=state.python,
        validate=False,
        pypi_mirror=state.pypi_mirror,
    )

    if not state.project.venv_locator.exists:
        err.print(
            "No virtualenv has been created for this project yet!\n"
            "Run [green bold]pipenv install[/green bold] to create one.",
            style="red bold",
        )
        sys.exit(1)

    try:
        shell_type, shell_cmd = detect_info(state.project)
    except ShellDetectionFailure:
        err.print(
            "Unable to detect shell. Set PIPENV_SHELL environment variable.",
            style="red bold",
        )
        sys.exit(1)

    venv_path = state.project.venv_locator.location
    activate_cmd = _get_activate_script(shell_cmd, venv_path)
    print(activate_cmd.strip())


def cmd_run(args, state, extra_args=None):
    from pipenv.routines.shell import do_run

    if not args.run_command:
        err.print("Error: Missing argument 'command'.")
        sys.exit(1)

    # run_args captures everything after run_command via nargs=REMAINDER so that
    # flags like ``-h`` pass through to the command instead of being consumed by
    # argparse.  extra_args (root parse_known_args leftovers) is retained as a
    # fallback for unusual cases.
    run_args = list(getattr(args, "run_args", None) or []) + list(extra_args or [])
    do_run(
        state.project,
        command=args.run_command,
        args=run_args,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        system=state.system,
    )


def cmd_check(args, state):
    from pipenv.routines.check import do_check

    do_check(
        state.project,
        python=state.python,
        system=state.system,
        db=args.db,
        ignore=args.ignore,
        output=args.output,
        key=args.key,
        quiet=state.quiet,
        verbose=state.verbose,
        exit_code=args.exit_code,
        policy_file=args.policy_file,
        save_json=args.save_json,
        audit_and_monitor=args.audit_and_monitor,
        safety_project=args.project,
        pypi_mirror=state.pypi_mirror,
        use_installed=args.use_installed,
        categories=args.categories,
        auto_install=args.auto_install,
        scan=args.scan,
    )


def cmd_audit(args, state):
    from pipenv.routines.audit import do_audit

    do_audit(
        state.project,
        python=state.python,
        system=state.system,
        output=args.output,
        quiet=state.quiet,
        verbose=state.verbose,
        strict=args.strict,
        ignore=args.ignore,
        fix=args.fix,
        dry_run=args.dry_run,
        skip_editable=args.skip_editable,
        no_deps=args.no_deps,
        local_only=args.local,
        vulnerability_service=args.vulnerability_service,
        descriptions=args.desc,
        aliases=args.aliases,
        output_file=args.output_file,
        pypi_mirror=state.pypi_mirror,
        use_lockfile=args.locked,
    )


def cmd_update(args, state):
    from pipenv.routines.context import RoutineContext
    from pipenv.routines.update import do_update

    if state.installstate.all_categories:
        state.installstate.categories = state.project.pipfile.get_package_categories()
    else:
        apply_default_categories(args, state)

    # ``--outdated`` and ``--dry-run`` collapse downstream into a single
    # boolean (``outdated || bool(dry_run)``). ``RoutineContext`` does
    # not carry an ``outdated`` field of its own, so we OR the two here
    # and surface the combined intent via ``install_policy.dry_run``.
    dry_run_intent = bool(getattr(args, "outdated", False)) or bool(
        getattr(args, "dry_run", False)
    )

    ctx = RoutineContext.from_cli(
        # target_env
        system=state.system,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        site_packages=state.site_packages,
        # install_policy
        pre=state.installstate.pre,
        clear=state.clear,
        lock_only=state.installstate.lock_only,
        dry_run=dry_run_intent,
        # package_selection
        packages=state.installstate.packages,
        editable_packages=state.installstate.editables,
        categories=state.installstate.categories,
        dev=state.installstate.dev,
        index=state.index,
        # execution_options
        extra_pip_args=state.installstate.extra_pip_args,
        bare=args.bare,
        quiet=state.quiet,
        resolver=state.resolver,
    )
    do_update(state.project, ctx)


def cmd_graph(args, state):
    from pipenv.routines.graph import do_graph

    do_graph(
        state.project,
        bare=args.bare,
        json=args.json,
        json_tree=args.json_tree,
        reverse=args.reverse,
    )


def cmd_open(args, state):
    from pipenv.utils.project import ensure_project
    from pipenv.utils.virtualenv import inline_activate_virtual_environment

    ensure_project(
        state.project,
        python=state.python,
        validate=False,
        pypi_mirror=state.pypi_mirror,
    )
    c = subprocess_run(
        [
            state.project.venv_locator._which("python"),
            "-c",
            f"import {args.module}; print({args.module}.__file__)",
        ]
    )
    if c.returncode:
        console.print("Module not found!", style="red")
        sys.exit(1)
    if "__init__.py" in c.stdout:
        p = Path(c.stdout.strip().rstrip("cdo")).parent
    else:
        p = c.stdout.strip().rstrip("cdo")
    console.print(f"Opening {p!r} in your EDITOR.", style="bold")
    inline_activate_virtual_environment(state.project)
    _open_editor(p)
    return 0


def cmd_sync(args, state):
    from pipenv.routines.context import RoutineContext
    from pipenv.routines.sync import do_sync

    if state.installstate.all_categories:
        state.installstate.categories = state.project.pipfile.get_package_categories()
    else:
        apply_default_categories(args, state)

    ctx = RoutineContext.from_cli(
        # target_env
        system=state.system,
        python=state.python,
        pypi_mirror=state.pypi_mirror,
        site_packages=state.site_packages,
        # install_policy
        clear=state.clear,
        # package_selection
        categories=state.installstate.categories,
        dev=state.installstate.dev,
        # execution_options
        extra_pip_args=state.installstate.extra_pip_args,
        bare=args.bare,
    )
    retcode = do_sync(state.project, ctx)
    if retcode:
        sys.exit(1)


def cmd_clean(args, state):
    from pipenv.routines.clean import do_clean

    do_clean(
        state.project,
        python=state.python,
        dry_run=args.dry_run,
        system=state.system,
    )


def cmd_scripts(args, state):
    if not state.project.pipfile.exists:
        err.print("No Pipfile present at project home.")
        sys.exit(1)
    scripts_dict = state.project.pipfile.parsed.get("scripts", {})
    first_column_width = max(len(word) for word in ["Command"] + list(scripts_dict))
    second_column_width = max(
        len(word) for word in ["Script"] + list(scripts_dict.values())
    )
    lines = [f"{'Command':<{first_column_width}}  Script"]
    lines.append(f"{'-' * first_column_width}  {'-' * second_column_width}")
    lines.extend(
        f"{name:<{first_column_width}}  {script}" for name, script in scripts_dict.items()
    )
    console.print("\n".join(lines))


def cmd_verify(args, state):
    if not state.project.pipfile.exists:
        err.print("No Pipfile present at project home.")
        sys.exit(1)
    if state.project.lockfile.hash() != state.project.pipfile.calculate_hash():
        err.print(
            "Pipfile.lock is out-of-date. Run [yellow bold]$ pipenv lock[/yellow bold] to update."
        )
        sys.exit(1)
    console.print("Pipfile.lock is up-to-date.", style="green")
    sys.exit(0)


def cmd_requirements(args, state):
    from pipenv.routines.requirements import generate_requirements

    no_lock = args.no_lock
    from_pipfile = args.from_pipfile
    if no_lock:
        from_pipfile = True

    generate_requirements(
        project=state.project,
        dev=args.dev,
        dev_only=args.dev_only,
        include_hashes=args.hash,
        include_markers=not args.exclude_markers,
        categories=args.categories,
        from_pipfile=from_pipfile,
        no_lock=no_lock,
        include_index=not args.exclude_index,
    )


def cmd_pylock(args, state):
    from pipenv.utils.pylock import PylockFile, PylockFormatError, PylockVersionError

    project = state.project
    groups = [g.strip() for g in args.dev_groups.split(",") if g.strip()]

    if args.generate:
        if not project.lockfile.exists:
            err.print("[bold red]No Pipfile.lock found.[/bold red]")
            sys.exit(1)
        try:
            output_path = args.output or project.lockfile.pylock_output_path
            pylock_file = PylockFile.from_lockfile(
                lockfile_path=project.lockfile.location,
                pylock_path=output_path,
                dev_groups=groups,
            )
            pylock_file.write()
            console.print(
                f"[bold green]Generated pylock.toml at {output_path}[/bold green]"
            )
        except Exception as e:
            err.print(f"[bold red]Error generating pylock.toml: {e}[/bold red]")
            sys.exit(1)

    elif args.from_pyproject:
        pyproject_path = Path(project.pipfile.project_directory) / "pyproject.toml"
        if not pyproject_path.exists():
            err.print("[bold red]No pyproject.toml found.[/bold red]")
            sys.exit(1)
        try:
            output_path = args.output or project.lockfile.pylock_output_path
            pylock_file = PylockFile.from_pyproject(
                pyproject_path=pyproject_path,
                pylock_path=output_path,
            )
            pylock_file.write()
            console.print(
                f"[bold green]Generated pylock.toml skeleton at {output_path}[/bold green]"
            )
            console.print(
                "[yellow]Note: This is a skeleton file. Package versions and hashes "
                "need to be resolved by running 'pipenv lock'.[/yellow]"
            )
        except Exception as e:
            err.print(f"[bold red]Error generating pylock.toml: {e}[/bold red]")
            sys.exit(1)

    elif args.validate:
        pylock_path = project.lockfile.pylock_location
        if not pylock_path:
            err.print("[bold red]No pylock.toml found.[/bold red]")
            sys.exit(1)
        try:
            pylock_file = PylockFile.from_path(pylock_path)
            console.print(
                f"[bold green]✓ Valid pylock.toml (version {pylock_file.lock_version})[/bold green]"
            )
            console.print(f"  Created by: {pylock_file.created_by}")
            console.print(f"  Packages: {len(pylock_file.packages)}")
            if pylock_file.requires_python:
                console.print(f"  Requires Python: {pylock_file.requires_python}")
            if pylock_file.extras:
                console.print(f"  Extras: {', '.join(pylock_file.extras)}")
            if pylock_file.dependency_groups:
                console.print(
                    f"  Dependency Groups: {', '.join(pylock_file.dependency_groups)}"
                )
        except PylockVersionError as e:
            err.print(f"[bold red]Version error: {e}[/bold red]")
            sys.exit(1)
        except PylockFormatError as e:
            err.print(f"[bold red]Format error: {e}[/bold red]")
            sys.exit(1)
        except Exception as e:
            err.print(f"[bold red]Error validating pylock.toml: {e}[/bold red]")
            sys.exit(1)

    else:
        pylock_path = project.lockfile.pylock_location
        if pylock_path:
            try:
                pylock_file = PylockFile.from_path(pylock_path)
                console.print(f"[bold]pylock.toml[/bold]: {pylock_path}")
                console.print(f"  Version: {pylock_file.lock_version}")
                console.print(f"  Created by: {pylock_file.created_by}")
                console.print(f"  Packages: {len(pylock_file.packages)}")
            except Exception as e:
                err.print(f"[yellow]Found pylock.toml but could not parse: {e}[/yellow]")
        else:
            console.print("[dim]No pylock.toml found.[/dim]")
            console.print(
                "Use [bold]pipenv pylock --generate[/bold] to create one from Pipfile.lock"
            )
            console.print(
                "Use [bold]pipenv pylock --from-pyproject[/bold] to create from pyproject.toml"
            )


_DISPATCH = {
    "install": cmd_install,
    "remove": cmd_remove,
    "upgrade": cmd_upgrade,
    "uninstall": cmd_uninstall,
    "lock": cmd_lock,
    "shell": cmd_shell,
    "activate": cmd_activate,
    "run": cmd_run,
    "check": cmd_check,
    "audit": cmd_audit,
    "update": cmd_update,
    "graph": cmd_graph,
    "open": cmd_open,
    "sync": cmd_sync,
    "clean": cmd_clean,
    "scripts": cmd_scripts,
    "verify": cmd_verify,
    "requirements": cmd_requirements,
    "pylock": cmd_pylock,
}


def _get_subparser(parser, name):
    """Return the subparser for *name*, or None if not found."""
    for action in parser._actions:
        if isinstance(action, argparse._SubParsersAction):
            return action.choices.get(name)
    return None


def cli():
    """Main argparse-based entry point for pipenv."""
    from pipenv.utils.display import format_help
    from pipenv.utils.shell import system_which
    from pipenv.utils.virtualenv import do_where, warn_in_virtualenv

    # Handle legacy _PIPENV_COMPLETE env var from the Click-based CLI.
    # Since pipenv 2026.5.0, shell completion uses argcomplete instead.
    # Detect the old mechanism and provide a helpful migration message.
    if os.environ.get("_PIPENV_COMPLETE"):
        print(
            "Shell completion has changed in pipenv >= 2026.5.0.\n"
            "The _PIPENV_COMPLETE environment variable is no longer supported.\n"
            "\n"
            "To enable shell completion, install the completion extra and use:\n"
            "  pip install pipenv[completion]\n"
            '  eval "$(register-python-argcomplete pipenv)"\n'
            "\n"
            "For more details, see: https://pipenv.pypa.io/en/latest/shell.html#shell-completion",
            file=sys.stderr,
        )
        sys.exit(1)

    parser = build_parser()

    # Enable shell completion when argcomplete is installed.
    # Users activate it once with: eval "$(register-python-argcomplete pipenv)"
    # Install the optional dependency with: pip install pipenv[completion]
    try:
        import argcomplete

        argcomplete.autocomplete(parser)
    except ImportError:
        pass

    argv = sys.argv[1:]

    # Use parse_known_args so that extra args for 'run'/'shell' pass through.
    args, remaining = parser.parse_known_args(argv)

    # Shared options use argparse.SUPPRESS as their default so that subparser
    # defaults never overwrite values already parsed by the root parser
    # (e.g. ``pipenv --python 3.11 sync``).  Ensure the attributes exist
    # before env-var overlay and state building.
    for _attr in ("python", "pypi_mirror", "verbose", "quiet", "clear", "system"):
        if not hasattr(args, _attr):
            setattr(args, _attr, None)

    # Overlay PIPENV_* env vars for any attribute still at its sentinel (None).
    apply_env_vars(args)

    # Build the State object (validates python path, pypi mirror, verbosity).
    state = build_state(args)

    load_dot_env(state.project, quiet=state.quiet)

    if "PIPENV_COLORBLIND" in os.environ:
        err.print(
            "PIPENV_COLORBLIND is deprecated, use NO_COLOR"
            " per https://no-color.org/ instead",
        )

    warn_in_virtualenv(state.project)

    cmd = args.command

    # ── Root-level (no subcommand) ────────────────────────────────────────────
    if cmd is None:
        if getattr(args, "help", False):
            console.print(format_help(parser.format_help()))
            return 0

        if args.man:
            if system_which("man"):
                path = Path(__file__).parent.parent / "pipenv.1"
                os.execle(system_which("man"), "man", str(path), os.environ)
                return 0
            else:
                err.print(
                    "man does not appear to be available on your system.",
                    style="bold yellow",
                )
                return 1

        if args.envs:
            console.print(
                "The following environment variables can be set, to do various things:\n"
            )
            for key in state.project.s.__dict__:
                if key.startswith("PIPENV"):
                    console.print(f"  - {key}", style="bold")
            console.print(
                "\nYou can learn more at:\n   "
                "[green]https://pipenv.pypa.io/en/latest/advanced/#configuration-with-environment-variables[/green]",
            )
            return 0

        if args.where:
            do_where(state.project, bare=True)
            return 0

        if args.py:
            do_py(state.project, bare=True)
            return 0

        if args.support:
            from pipenv.help import get_pipenv_diagnostics

            get_pipenv_diagnostics(state.project)
            return 0

        if state.clear:
            from pipenv.routines.clear import do_clear

            do_clear(state.project)
            return 0

        if args.venv:
            if not state.project.venv_locator.exists:
                err.print(
                    "[red]No virtualenv has been created for this project[/red]"
                    f"[bold]{state.project.pipfile.project_directory}[/bold]"
                    " [red]yet![/red]"
                )
                sys.exit(1)
            print(state.project.venv_locator.location)
            return 0

        if args.rm:
            err.print(
                "Warning: [yellow]--rm[/yellow] is deprecated and will be removed in a future release. "
                "Use [green]`pipenv remove`[/green] instead.",
            )
            cmd_remove(args, state)
            return 0

        if state.python or state.site_packages:
            from pipenv.utils.project import ensure_project

            ensure_project(
                state.project,
                python=state.python,
                warn=True,
                site_packages=state.site_packages,
                pypi_mirror=state.pypi_mirror,
                clear=state.clear,
            )
            return 0

        # No flags: show help
        console.print(format_help(parser.format_help()))
        return 0

    # ── Subcommand --help ─────────────────────────────────────────────────────
    if getattr(args, "help", False):
        sub = _get_subparser(parser, cmd)
        if sub is not None:
            console.print(format_help(sub.format_help()))
        else:
            console.print(format_help(parser.format_help()))
        return 0

    # ── Unrecognised arguments (except for 'run' which passes them through) ──
    if remaining and cmd != "run":
        parser.error(f"unrecognized arguments: {' '.join(remaining)}")

    # ── Dispatch ──────────────────────────────────────────────────────────────
    handler = _DISPATCH.get(cmd)
    if handler is None:
        parser.error(f"No such command: {cmd!r}.")

    if cmd == "run":
        handler(args, state, extra_args=remaining)
    else:
        handler(args, state)


if __name__ == "__main__":
    cli()
