#!/usr/bin/env python3
#
# Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#  - Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#
#  - Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
#  - Neither the name of the software nor the names of its contributors
#    may be used to endorse or promote products derived from this software
#    without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

__doc__ = """
Contains the Distributor class for distributing files.
"""

import argparse
import fnmatch
import os
import time
from dataclasses import dataclass
from typing import List, Optional, Sequence, Tuple, Union

from distman import config, util
from distman.logger import log, setup_logging
from distman.pipeline import (
    validate_pipeline_spec,
    get_pipeline_for_target,
    run_pipeline,
    ValidationError,
)
from distman.source import GitRepo
from distman.transform import TransformError


@dataclass
class Target:
    """Represents a distribution target with its name, source path, destination
    path, type (folder, directory or link) and dist options.

    name: The name of the target.
    source: The source path of the target.
    dest: The destination path of the target.
    type: The type of the target (e.g., file, directory, link).
    pipeline: Optional dictionary of pipeline steps to apply to the target.
    options: Optional dictionary of additional options for the target.
    """

    name: str
    source: str
    dest: str
    type: str
    pipeline: Optional[dict] = None
    options: Optional[dict] = None


def get_source_and_dest(target_dict: dict) -> Optional[Tuple[str, str]]:
    """Resolve the source and destination paths from the target dictionary.

    :param target_dict: Dictionary containing target information.
    :return: Tuple of source and destination paths, or None if not found.
    """
    source = target_dict.get(config.TAG_SOURCEPATH)
    dest = target_dict.get(config.TAG_DESTPATH)
    if source is None or dest is None:
        return None
    try:
        source = util.normalize_path(source)
        dest = util.sanitize_path(util.replace_vars(dest))
    except Exception as e:
        log.error(f"Error resolving paths: {e}")
        return None
    return source, dest


def confirm(prompt: str, yes: bool, dryrun: bool) -> bool:
    """Prompt the user for confirmation.

    :param prompt: The confirmation prompt message.
    :param yes: If True, automatically confirm without prompting.
    :param dryrun: If True, simulate the action without making changes.
    :return: True if confirmed, False otherwise.
    """
    return dryrun or yes or util.yesNo(prompt)


def update_symlink(dest: str, target: str, dryrun: bool) -> bool:
    """Update the symbolic link at `dest` to point to `target`.

    :param dest: The destination path where the symlink should be created.
    :param target: The target path that the symlink should point to.
    :param dryrun: If True, simulate the action without making changes.
    :return: True if the symlink was updated or would be updated in dry run mode.
    """
    if os.path.lexists(dest) and not dryrun:
        util.remove_object(dest)
    if dryrun:
        return True
    version_dest = os.path.join(config.DIR_VERSIONS, os.path.basename(target))
    return util.link_object(version_dest, dest, target)


def get_version_dest(dest: str, version_num: int, short_head: Optional[str]) -> str:
    """Generate the destination path for a versioned file.

    :param dest: The original destination path.
    :param version_num: The version number to append.
    :param short_head: Optional short hash of the latest commit.
    :return: The versioned destination path.
    """
    versions_dir = os.path.join(os.path.dirname(dest), config.DIR_VERSIONS)
    version_dest = os.path.join(
        versions_dir, os.path.basename(dest) + f".{version_num}"
    )
    if short_head:
        version_dest += f".{short_head}"
    return util.sanitize_path(version_dest)


def should_skip_target(
    target_name: str, patterns: Optional[Union[str, List[str]]]
) -> bool:
    """Check if a target should be skipped based on the provided patterns.

    :param target_name: The name of the target to check.
    :param patterns: One or more patterns to match against the target name.
    :return: True if the target should be skipped, False otherwise.
    """
    if isinstance(patterns, str):
        patterns = [patterns]
    return patterns is not None and not any(
        fnmatch.fnmatch(target_name, pattern) for pattern in patterns
    )


class Distributor(GitRepo):
    """Handles distribution of files based on a configuration file."""

    def __init__(self):
        """Initializes the Distributor class."""
        super().__init__()
        if not callable(getattr(os, "symlink", None)):
            util.add_symlink_support()

    def dist(
        self,
        target: Optional[Union[str, List[str]]] = None,
        show: bool = False,
        force: bool = False,
        all: bool = False,
        yes: bool = False,
        ignore_missing: bool = config.IGNORE_MISSING,
        dryrun: bool = False,
        versiononly: bool = False,
        verbose: int = 0,
    ) -> bool:
        """Distributes files based on targets defined in the dist file.

            {
                "author": "<email>",
                "targets": {
                    "<target>": {
                        "source": "<source-path>",
                        "destination": "<target-path>"
                    },
                }
            }

        :param target: One or more target patterns to filter dist targets.
        :param show: If True, shows distribution information without making changes.
        :param force: If True, forces the distribution even if there are uncommitted changes.
        :param all: If True, processes all files, ignoring changes.
        :param yes: If True, automatically confirms prompts.
        :param ignore_missing: If True, ignores missing source paths.
        :param dryrun: If True, simulates the distribution without making changes.
        :param versiononly: If True, only updates the version without changing the symlink.
        :param verbose: Verbosity level.
        :return: True if distribution was successful, False otherwise.
        """
        if not self.root:
            log.error(f"{config.DIST_FILE} not found or invalid")
            return False
        if not self.read_git_info():
            return False
        if verbose >= 2:
            self.log_distribution_info()

        targets_node = self.get_targets()
        if not targets_node:
            return False

        changed_files = self.git_changed_files()
        changed_dirs = util.get_common_root_dirs(changed_files)
        global_pipeline = self.root.get(config.TAG_PIPELINE)
        validate_pipeline_spec(global_pipeline, context="global")
        global_options = self.root.get(config.TAG_OPTIONS, {})
        did_mutate = False

        if config.DIST_FILE in changed_files:
            log.warning(f"Uncommitted changes in {config.DIST_FILE}")

        # build the list of targets to process
        target_list: List[Target] = []
        for name, entry in targets_node.items():
            if should_skip_target(name, target):
                continue

            source = entry.get(config.TAG_SOURCEPATH)
            dest = entry.get(config.TAG_DESTPATH)
            if source is None:
                log.info(f"Target {name}: Missing source path")
                continue
            if dest is None:
                log.info(f"Target {name}: Missing dest path")
                continue

            # get target pipeline and options
            target_pipeline = get_pipeline_for_target(
                global_pipeline, entry.get(config.TAG_PIPELINE)
            )
            validate_pipeline_spec(target_pipeline, context=f"target '{name}'")
            target_options = util.get_effective_options(
                global_options, entry.get(config.TAG_OPTIONS, {})
            )

            # check for wildcard in source
            if "*" in source:
                for src_path, dst_path in util.expand_wildcard_entry(source, dest):
                    target_type = util.get_path_type(src_path)[0]
                    try:
                        dst_resolved = util.sanitize_path(util.replace_vars(dst_path))
                        target_list.append(
                            Target(
                                name,
                                src_path,
                                dst_resolved,
                                target_type,
                                target_pipeline,
                                target_options,
                            )
                        )
                    except Exception as e:
                        log.error(f"{e} resolving wildcard target {name}")
                        return False
            else:
                try:
                    dest_resolved = util.sanitize_path(util.replace_vars(dest))
                except Exception as e:
                    log.error(f"{e} in <dest> for {name}")
                    return False

                src_path = (
                    self.directory
                    if source == "."
                    else util.normalize_path(os.path.join(self.directory, source))
                )

                if not os.path.exists(src_path):
                    if target_options.get("ignore_missing", ignore_missing):
                        log.info(
                            f"Target {name}: Source '{source}' not found, skipping"
                        )
                        continue
                    else:
                        log.error(f"Target {name}: Source '{source}' does not exist")
                        return False

                if (
                    not show
                    and not force
                    and (src_path in changed_files or src_path in changed_dirs)
                ):
                    log.info(
                        f"Target {name}: Source '{source}' has uncommitted changes. Commit or use --force."
                    )
                    return False

                if (
                    not show
                    and not dryrun
                    and not os.path.exists(os.path.dirname(dest_resolved))
                ):
                    question = f"Target {name}: Destination dir '{os.path.dirname(dest_resolved)}' doesn't exist. Create?"
                    if not confirm(question, yes, dryrun):
                        return False
                    try:
                        os.makedirs(os.path.dirname(dest_resolved), exist_ok=True)
                    except Exception as err:
                        log.error(str(err))
                        return False

                target_type = util.get_path_type(src_path)[0]
                target_list.append(
                    Target(
                        name,
                        src_path,
                        dest_resolved,
                        target_type,
                        target_pipeline,
                        target_options,
                    )
                )

        if not target_list:
            log.info(f"No matching targets in {config.DIST_FILE}")
            return False

        if not show and not force and self.is_git_behind():
            return False

        if dryrun:
            log.info(config.DRYRUN_MESSAGE)

        for t in target_list:
            util.create_dest_folder(t.dest, dryrun, yes)

            # determine if the commit hash will be used for matching versions
            match_option = t.options.get("match", "commit")

            # FIXME: fallback shim until match option is fully implemented
            if match_option not in ["commit", "content"]:
                raise ValueError(f"Unsupported version match option '{match_option}'")

            if match_option == "commit":
                commit_hash = self.short_head
            else:
                commit_hash = None  # don't use commit_hash in version matching

            if not dryrun and not show:
                util.write_dist_file(
                    t.dest,
                    {
                        "name": self.name,
                        "origin": self.path,
                        "branch": self.branch_name,
                        "commit": self.short_head,
                        "source": t.source,
                        "author": self.author,
                    },
                )
                did_mutate = True

            source_path = t.source

            # run the pipeline if defined
            if not dryrun and not show and t.pipeline:
                try:
                    source_path = run_pipeline(
                        target=t,
                        pipeline=t.pipeline,
                        input_path=t.source,
                        build_dir=config.BUILD_DIR,
                    )
                except ValidationError as e:
                    log.error(f"Pipeline validation error: {e}")
                    raise
                except TransformError as e:
                    log.error(f"Pipeline transform error: {e}")
                    raise
                except Exception as e:
                    log.error(f"Pipeline unhandled error: {e}")
                    raise

            # get existing versions for the target
            if show:
                if verbose >= 1:
                    version_list = util.get_file_versions(t.dest)
                else:
                    version_list = []
                self.show_distribution_info(t.source, t.dest, version_list, verbose)
                continue
            else:
                version_list = util.get_file_versions(t.dest, limit=config.MAX_VERSIONS)

            # determine the next version number
            version_num = version_list[-1][1] + 1 if version_list else 0

            # look for matches in existing versions unless forced
            matches = []
            if not force:
                matches = util.find_matching_versions(
                    source_path=source_path,
                    dest=t.dest,
                    commit_hash=commit_hash,
                    version_list=version_list,
                )

            # if a match is found, update the symlink if needed
            if matches:
                match_file, _, _ = matches[-1]
                if os.path.islink(t.dest) and os.readlink(t.dest).endswith(
                    os.path.basename(match_file)
                ):
                    log.info(f"Unchanged: {t.source} ={t.type}> {match_file}")
                    continue
                if versiononly and not force:
                    log.info(f"Match: {t.source} ={t.type}> {match_file}")
                    continue
                if confirm(
                    f"Match: {t.source} ={t.type}> {match_file}. Update link?",
                    yes,
                    dryrun,
                ):
                    if update_symlink(t.dest, match_file, dryrun):
                        log.info(f"Updated: {t.source} ={t.type}> {match_file}")
                        did_mutate = True
                    continue

            # get the destination path for the versioned file
            version_dest = get_version_dest(t.dest, version_num, self.short_head)

            # copy the source file to the versioned destination
            if not dryrun:
                util.copy_object(source_path, version_dest, all_files=all)
                did_mutate = True
                if not versiononly:
                    if not update_symlink(t.dest, version_dest, dryrun):
                        continue

            log.info(f"Updated: {t.source} ={t.type}> {version_dest}")

        if self.repo:
            try:
                self.repo.close()
            except Exception:
                pass

        if did_mutate and not dryrun and not show:
            util.write_epoch_file(config.DEPLOY_ROOT)

        return True

    def reset_file_version(
        self,
        target: Optional[Union[str, List[str]]] = None,
        dryrun: bool = False,
    ) -> bool:
        """Reset the file version for the specified target.

        :param target: One or more target patterns to filter dist targets.
        :param dryrun: If True, simulates the reset without making changes.
        :return: True if any targets were reset, False otherwise.
        """
        targets_node = self.get_targets()
        if not targets_node:
            return False
        if dryrun:
            log.info(config.DRYRUN_MESSAGE)

        any_found = False
        target_list = []

        for target_name, target_dict in targets_node.items():
            if should_skip_target(target_name, target):
                continue
            pair = get_source_and_dest(target_dict)
            if not pair:
                continue

            source, dest = pair
            any_found = True

            # check for wildcard in source
            if "*" in source:
                for src_path, dst_path in util.expand_wildcard_entry(source, dest):
                    target_type = util.get_path_type(src_path)[0]
                    try:
                        dest = util.sanitize_path(util.replace_vars(dst_path))
                        target_list.append((src_path, dest))
                    except Exception as e:
                        log.error(f"{e} resolving wildcard target {target}")
                        return False

            else:
                target_list.append((source, dest))

        for source, dest in target_list:
            version_list = util.get_file_versions(dest)
            if not version_list:
                log.info(
                    f"Target {target_name}: No versioned files found for '{source}'"
                )
                continue
            latest_ver = version_list[-1][0]
            target_type = util.get_path_type(latest_ver)[0]
            if dryrun:
                log.info(f"{source} ={target_type}> {latest_ver}")
            else:
                if update_symlink(dest, latest_ver, dryrun):
                    log.info(f"{source} ={target_type}> {latest_ver}")

        if not any_found:
            log.info("No targets found to reset")

        return any_found

    def show_distribution_info(
        self,
        source: str,
        dest: str,
        version_list: List[Tuple[str, int, str]],
        verbose: int = 0,
    ) -> None:
        """Display distribution information for a target.

        :param source: The source path of the target.
        :param dest: The destination path of the target.
        :param version_list: List of versioned files with their metadata.
        :param verbose: Verbosity level.
        :return: None
        """
        if callable(getattr(os, "readlink", None)):
            if not os.path.lexists(dest):
                log.info(f"Missing: {dest}")
            else:
                target_type = util.get_path_type(source)[0]
                log.info(f"{source} ={target_type}> {os.readlink(dest)}")
        else:
            log.info(f"{source}")
        for version_file, version_num, version_commit in version_list:
            log.info(
                f"{version_num}: {version_file} - {time.ctime(os.path.getmtime(version_file))}"
            )
            if self.repo and verbose >= 2:
                try:
                    commit = self.repo.commit(version_commit)
                    log.info(f"    {commit.message.strip()}")
                    log.info(
                        f"    {time.ctime(commit.committed_date)} - {commit.author}"
                    )
                except Exception:
                    pass

    def change_file_version(
        self,
        target: Optional[Union[str, List[str]]] = None,
        target_commit: Optional[str] = None,
        target_version: Optional[int] = None,
        dryrun: bool = False,
    ) -> bool:
        """Change the version of a file for the specified target.

        :param target: One or more target patterns to filter dist targets.
        :param target_commit: Optional commit hash to reset to.
        :param target_version: Optional version number to reset to.
        :param dryrun: If True, simulates the change without making changes.
        :return: True if any targets were changed, False otherwise.
        """
        targets_node = self.get_targets()
        if not targets_node:
            return False
        if dryrun:
            log.info(config.DRYRUN_MESSAGE)

        any_found = False
        target_list = []

        for target_name, target_dict in targets_node.items():
            if should_skip_target(target_name, target):
                continue

            pair = get_source_and_dest(target_dict)
            if not pair:
                continue
            source, dest = pair

            # check for wildcard in source
            if "*" in source:
                for src_path, dst_path in util.expand_wildcard_entry(source, dest):
                    target_type = util.get_path_type(src_path)[0]
                    try:
                        dest = util.sanitize_path(util.replace_vars(dst_path))
                        target_list.append((src_path, dest))
                    except Exception as e:
                        log.error(f"{e} resolving wildcard target {target}")
                        return False

            else:
                target_list.append((source, dest))

        for source, dest in target_list:
            version_list = util.get_file_versions(dest)
            if not version_list:
                log.info(f"Target {target_name}: No versioned files found for {source}")
                continue

            dest_target_version = target_version
            if isinstance(target_version, int) and target_version < 0:
                if abs(target_version) > len(version_list) - 1:
                    log.warning(
                        f"Requested to roll back {abs(target_version)} versions but only {len(version_list) - 1} exist for {source}"
                    )
                    continue
                # get the version number to roll back to for this source
                dest_target_version = version_list[target_version - 1][1]

            matched = False
            for verfile, vernum, vercommit in version_list:
                if (target_commit and util.hashes_equal(target_commit, vercommit)) or (
                    target_version is not None and vernum == dest_target_version
                ):
                    any_found = True
                    matched = True
                    target_type = util.get_path_type(verfile)[0]
                    log.info(f"{source} ={target_type}> {verfile}")
                    if not dryrun:
                        update_symlink(dest, verfile, dryrun)
                    break

            if not matched:
                if target_commit:
                    log.info(f"Target commit {target_commit} not found for {source}")
                else:
                    log.info(f"Target version {target_version} not found for {source}")

        if not any_found:
            log.info("No targets found to change version")

        return any_found

    def delete_target(
        self,
        target: Optional[Union[str, List[str]]] = None,
        target_version: Optional[int] = None,
        target_commit: Optional[str] = None,
        yes: bool = False,
        dryrun: bool = False,
    ) -> bool:
        """Delete the specified target and its versions.

        :param target: One or more target patterns to filter dist targets.
        :param target_version: Optional version number to delete.
        :param target_commit: Optional commit hash to delete.
        :param yes: If True, automatically confirms prompts.
        :param dryrun: If True, simulates the deletion without making changes.
        :return: True if any targets were deleted, False otherwise.
        """
        targets_node = self.get_targets()
        if not targets_node:
            return False
        if dryrun:
            log.info(config.DRYRUN_MESSAGE)

        target_list = []
        any_found = False

        for target_name, target_dict in targets_node.items():
            if should_skip_target(target_name, target):
                continue

            pair = get_source_and_dest(target_dict)
            if not pair:
                continue
            source, dest = pair

            # check for wildcard in source
            if "*" in source:
                for src_path, dst_path in util.expand_wildcard_entry(source, dest):
                    try:
                        dest = util.sanitize_path(util.replace_vars(dst_path))
                        target_list.append((src_path, dest))
                    except Exception as e:
                        log.error(f"{e} resolving wildcard target {target_name}")
                        return False

            else:
                target_list.append((source, dest))

            for source, dest in target_list:
                version_list = util.get_file_versions(dest)
                if version_list:
                    if target_version is not None:
                        version_list = [
                            v for v in version_list if int(v[1]) == int(target_version)
                        ]
                    elif target_commit:
                        version_list = [
                            v
                            for v in version_list
                            if util.hashes_equal(target_commit, v[2])
                        ]

                if version_list:
                    if len(version_list) == 1:
                        question = f"Delete {version_list[0][0]}?"
                    else:
                        question = f"Delete {len(version_list)} versions for target '{target_name}'?"
                    if not confirm(question, yes, dryrun):
                        continue
                else:
                    continue

                any_found = True
                dist_file = util.get_dist_file(dest=dest)
                link_path = util.get_link_full_path(dest)

                if (target_commit or target_version) and link_path in [
                    v[0] for v in version_list
                ]:
                    log.warning(
                        f"Cannot delete target '{target_name}' because it is linked to the version being deleted"
                    )
                    continue

                if target_commit is None and target_version is None:
                    if os.path.lexists(dest):
                        log.info(f"Delete: {dest}")
                        if not dryrun:
                            util.remove_object(dest)
                    else:
                        log.info(f"Missing: {dest}")
                    if os.path.lexists(dist_file):
                        log.info(f"Delete: {dist_file}")
                        if not dryrun:
                            os.remove(dist_file)
                    else:
                        log.info(f"Missing: {dist_file}")

                for verfile, _, _ in version_list:
                    log.info(f"Delete: {verfile}")
                    if not dryrun:
                        util.remove_object(verfile, recurse=True)

        if not any_found:
            log.info("No targets found to delete")

        return any_found


def build_parser(prog: str = "dist") -> argparse.ArgumentParser:
    """Parse arguments for the deploy/version-management CLI (legacy 'dist' behavior)."""
    from distman import __version__

    parser = argparse.ArgumentParser(
        prog=prog,
        description="dist: distribute files based on a dist.json file",
        formatter_class=argparse.RawTextHelpFormatter,
    )
    parser.add_argument(
        "location",
        metavar="LOCATION",
        nargs="?",
        default=".",
        help="directory containing dist file (default is cwd)",
    )
    parser.add_argument(
        "-t",
        "--target",
        nargs="+",
        metavar="TARGET",
        help="source TARGET in the dist file (supports wildcards)",
    )
    parser.add_argument(
        "-s",
        "--show",
        action="store_true",
        help="show current distributed versions only",
    )
    parser.add_argument(
        "-c",
        "--commit",
        metavar="HASH",
        help="change TARGET version number to point to commit HASH",
    )
    parser.add_argument(
        "-n",
        "--number",
        metavar="NUMBER",
        help="change TARGET version number to point to NUMBER",
    )
    parser.add_argument(
        "--reset",
        action="store_true",
        help="reset version for TARGET to point to latest version",
    )
    parser.add_argument(
        "--delete",
        action="store_true",
        help="delete dist TARGET from deployment folder",
    )
    parser.add_argument(
        "-y",
        "--yes",
        action="store_true",
        help="answer yes to all questions, skipping user interaction",
    )
    parser.add_argument(
        "--all",
        action="store_true",
        help="dist all files, including ignorable files",
    )
    parser.add_argument(
        "--ignore-missing",
        action="store_true",
        default=config.IGNORE_MISSING,
        help="ignore missing files in dist file",
    )
    parser.add_argument(
        "-f",
        "--force",
        action="store_true",
        help="force action, e.g. disting uncommitted file changes",
    )
    parser.add_argument(
        "--version-only",
        action="store_true",
        help="distribute files only, do not create links",
    )
    parser.add_argument(
        "-d",
        "--dryrun",
        action="store_true",
        help="do a dry run, no actions will be performed",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="output verbosity (-v for INFO, -vv for DEBUG)",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"distman {__version__}",
    )

    return parser


def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
    """Parse arguments for the dist utility."""
    parser = build_parser()
    return parser.parse_args(list(argv) if argv is not None else None)


def run(args: argparse.Namespace) -> int:
    """Run the dist utility with the provided arguments."""

    # validate
    if not os.path.isdir(args.location):
        print(f"{args.location} is not a directory")
        return 1
    if sum([bool(args.commit), bool(args.number), bool(args.reset)]) > 1:
        print("--commit,--number and --reset are mutually exclusive")
        return 1

    distributor = Distributor()

    if not distributor.read_dist_file(args.location):
        return 1

    # delete target(s)
    if args.delete:
        ok = distributor.delete_target(
            target=args.target,
            target_version=args.number,
            target_commit=args.commit,
            yes=args.yes,
            dryrun=args.dryrun,
        )
        return 0 if ok else 1

    # windows: ensure symlink privilege
    if os.name == "nt":
        if not util.check_symlinks():
            return 1

    # change/reset versions
    if args.number or args.commit or args.reset:
        if args.reset:
            ok = distributor.reset_file_version(args.target, dryrun=args.dryrun)
            return 0 if ok else 1

        if args.number:
            try:
                target_version = int(args.number)
            except Exception:
                print(f"Invalid version number: {args.number}")
                return 2
            target_commit = ""
        else:
            target_version = args.number
            target_commit = args.commit
            if len(target_commit) < config.LEN_MINHASH:
                print(f"Hashes must be at least {config.LEN_MINHASH} characters")
                return 2

        ok = distributor.change_file_version(
            target=args.target,
            target_commit=target_commit,
            target_version=target_version,
            dryrun=args.dryrun,
        )
        return 0 if ok else 1

    # run dist
    try:
        ok = distributor.dist(
            target=args.target,
            show=args.show,
            force=args.force,
            all=args.all,
            yes=args.yes,
            ignore_missing=args.ignore_missing,
            dryrun=args.dryrun,
            versiononly=args.version_only,
            verbose=args.verbose,
        )
        return 0 if ok else 1
    except KeyboardInterrupt:
        print("Stopping dist...")
        return 2


def main(argv: Optional[Sequence[str]] = None) -> int:
    """Main entry point for dist utility."""

    args = parse_args(argv)

    setup_logging(dryrun=args.dryrun)

    return run(args)
