# gophian -- tools to help with Debianizing Go software
# Copyright (C) 2024-2025 Maytham Alsudany <maytha8thedev@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from pathlib import Path
from typing import List, Optional, Union

import click
import graphviz
import requests
from graphviz.dot import Dot

from gophian.__about__ import BUGS_URL
from gophian.error import ExecutionError, GophianError
from gophian.hosts import KNOWN_HOSTS
from gophian.name import debianize_name, shorten_host
from gophian.packages import DebianGolangPackages
from gophian.session import Session
from gophian.trace import trace
from gophian.vcs import package_from_import_path


@click.command()
@click.argument("importpath")
@click.argument(
    "source_output", type=click.Path(exists=False, writable=True), required=False
)
@click.option("--quiet/--no-quiet", default=False, help="Don't print warnings.")
@click.option(
    "--open/--no-open",
    "should_open",
    default=True,
    help=(
        "View the rendered output in the most suitable application. Ignore if"
        "--no-render is passed."
    ),
)
@click.option(
    "--render/--no-render",
    default=True,
    help="Render the graph using graphviz.",
)
@click.option(
    "--warn-packaged/--no-warn-packaged",
    default=True,
    help="Warn if the given Go package has already been packaged for Debian.",
)
@click.option(
    "--include-packaged-deps/--no-include-packaged-deps",
    default=True,
    help="Include packaged dependencies in the estimate.",
)
@click.option(
    "--include-version-conflicts/--no-include-version-conflicts",
    default=False,
    help=(
        "Include any version conflicts between the package's go.mod and the"
        "version in Debian unstable. Ignored if --no-include-packaged-deps is"
        "passed."
    ),
)
@click.option(
    "--max-depth",
    required=False,
    help="Maximum recursion depth of unpackaged dependencies.",
    type=int,
)
@click.pass_context
def estimate_graphviz(
    context: click.Context,
    importpath: str,
    source_output: Optional[Path],
    quiet: bool,
    should_open: bool,
    render: bool,
    warn_packaged: bool,
    include_packaged_deps: bool,
    include_version_conflicts: bool,
    max_depth: Optional[int],
) -> None:
    """
    Visualize the dependency tree of a given Go package.
    """

    requests_session: requests.Session = context.obj

    if not quiet:
        click.secho("gophian is experimental software!", bold=True, fg="yellow")
        click.secho("Please report any problems to:", fg="yellow")
        click.secho(BUGS_URL, fg="yellow")

    try:
        package, _ = package_from_import_path(requests_session, importpath)
    except Exception as error:
        click.echo(error)
        click.secho("Did you specify a Go package import path?", fg="yellow")
        context.exit(1)

    if package != importpath:
        click.echo(f"Continuing with repo root {package} instead of {importpath}")

    debian_packages = DebianGolangPackages(requests_session)

    if debian_packages._check_for_package(package, quiet) and warn_packaged:
        if not quiet:
            click.secho("To ignore, pass the --no-warn-packaged flag.", fg="cyan")
        return

    with Session(requests_session) as session:
        seen: List[str] = []
        errors: List[ExecutionError] = []

        dot = graphviz.Digraph()
        dot.attr("graph", ratio="fill", size="180,30!", margin="0")
        dot.node(
            "comment",
            "Node colours\\nWhite: not packaged\\nGreen: packaged",
            shape="none",
        )
        dot.node(package, package)

        dependency_tree(
            session,
            seen,
            errors,
            package,
            include_packaged_deps,
            include_version_conflicts,
            debian_packages,
            dot,
            max_depth,
        )

        if not source_output:
            host = package.split("/")[0]
            short_host = (
                KNOWN_HOSTS[host] if host in KNOWN_HOSTS else shorten_host(host)
            )
            name = debianize_name(package, short_host)
            source_output = Path(name + ".gv")

        if render:
            output = source_output.with_suffix(".pdf")
            dot.render(
                source_output,
                view=should_open,
                format="pdf",
                outfile=output,
                overwrite_source=True,
            )
        else:
            dot.save(source_output)

        for error in errors:
            click.echo(error)


def dependency_tree(
    session: Session,
    seen: List[str],
    errors: List[Union[ExecutionError, GophianError]],
    package: str,
    include_packaged_deps: bool,
    include_version_conflicts: bool,
    debian_packages: DebianGolangPackages,
    dot: Dot,
    max_depth: Optional[int],
    level: int = 1,
):
    try:
        trace("Fetching package: " + package)
        go_package = session.go_get(package)
        trace("Finding dependencies: " + package)
        dep_packages = go_package.find_dependencies()
        trace("Found dependencies: " + package)
        trace(dep_packages)
    except ExecutionError as error:
        trace("Execution error: " + package)
        errors.append(error)
        click.secho("Error getting dependencies for " + package, fg="red")
        dot.node(package, package + "\\n(Error getting dependencies)")
        return
    except GophianError as error:
        trace("Gophian error: " + package)
        errors.append(error)
        click.secho("Error getting dependencies for " + package, fg="red")
        dot.node(package, package + "\\n(Error getting dependencies)")
        return
    for dep_package, _ in dep_packages:
        result = debian_packages.library_is_packaged(dep_package)
        if result is None:
            try:
                dep_go_package = session.go_get(dep_package)
            except ExecutionError as error:
                trace("Execution error: " + package)
                errors.append(error)
                click.secho("Error fetching package " + package, fg="red")
                dot.node(package, package + "\\n(Error fetching package)")
                continue
            except GophianError as error:
                trace("Gophian error: " + package)
                errors.append(error)
                click.secho("Error fetching package " + package, fg="red")
                dot.node(package, package + "\\n(Error fetching package)")
                continue
            # This handles the case where the module has a version suffix
            # e.g. github/go-git/go-billy/v5
            if (
                dep_go_package.module != dep_package
                and dep_go_package.module.startswith(dep_package + "/")
            ):
                result = debian_packages.library_is_packaged(dep_go_package.module)
        if result is not None:
            trace(dep_package + " is packaged in Debian")
            debian_package, debian_package_version, suite = result
            if include_packaged_deps:
                trace(dep_package + " will be included in output")
                if dep_package not in seen:
                    trace(dep_package + " is in " + suite)
                    suite_text = ""
                    if suite == "experimental" or suite == "NEW":
                        suite_text = f"\n[{suite}]"
                    dot.node(
                        dep_package,
                        f"{dep_package}\\n({debian_package}){suite_text}",
                        fillcolor="green",
                        style="filled",
                    )
                label = ""
                if include_version_conflicts:
                    trace(dep_package + " will be checked for version conflicts")
                    [go_mod_major, go_mod_minor] = go_package.deps[
                        dep_package
                    ].vstring.split(".")[:2]
                    [debian_major, debian_minor] = debian_package_version.vstring.split(
                        "."
                    )[:2]
                    if (
                        (
                            go_package.deps[dep_package].commit
                            and go_package.deps[dep_package].vstring
                            != debian_package_version.vstring
                        )
                        or (go_mod_major != debian_major)
                        or (go_mod_major == 0 and go_mod_minor != debian_minor)
                    ):
                        trace(
                            dep_package
                            + " has a version conflict: "
                            + f"[{debian_package_version} ≠ {go_package.deps[dep_package]}]"
                        )
                        label = (
                            f"{debian_package_version} ≠ {go_package.deps[dep_package]}"
                        )
                dot.edge(package, dep_package, label=label)
                if dep_package not in seen:
                    seen.append(dep_package)
        elif dep_package in seen:
            trace(dep_package + " has already been seen")
            dot.edge(package, dep_package)
        else:
            trace(dep_package + " is not in Debian")
            trace("Depth reached: " + str(level) + " < " + str(max_depth))
            dot.node(dep_package, fillcolor="white", style="filled")
            dot.edge(package, dep_package)
            seen.append(dep_package)
            if max_depth is None or level < max_depth:
                dependency_tree(
                    session,
                    seen,
                    errors,
                    dep_package,
                    include_packaged_deps,
                    include_version_conflicts,
                    debian_packages,
                    dot,
                    max_depth,
                    level + 1,
                )
