from __future__ import annotations

import contextlib
import functools
import json
import logging
import os
import pathlib
import shlex
import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable, Iterable, Mapping
from typing import Any, Generator, Literal, Union, overload

from . import _pretty, _yaml
from ._task import Task
from ._version import Version
from .modeltypes import ModelInfo
from .secrettypes import RevealedSecret, Secret, SecretURI
from .statustypes import Status

logger = logging.getLogger('jubilant')
logger_wait = logging.getLogger('jubilant.wait')


class CLIError(subprocess.CalledProcessError):
    """Subclass of ``CalledProcessError`` that includes stdout and stderr in the ``__str__``."""

    def __str__(self) -> str:
        s = super().__str__()
        if self.stdout:
            s += '\nStdout:\n' + self.stdout
        if self.stderr:
            s += '\nStderr:\n' + self.stderr
        return s


class WaitError(Exception):
    """Raised when :meth:`Juju.wait`'s *error* callable returns False."""


ConfigValue = Union[bool, int, float, str, SecretURI]
"""The possible types a charm config value can be."""


class Juju:
    """Instantiate this class to run Juju commands.

    Most methods directly call a single Juju CLI command. If a CLI command doesn't yet exist as a
    method, use :meth:`cli`.

    Example::

        juju = jubilant.Juju()
        juju.deploy('snappass-test')

    Args:
        model: If specified, operate on this Juju model, otherwise use the current Juju model.
            If the model is in another controller, prefix the model name with ``<controller>:``.
        wait_timeout: The default timeout for :meth:`wait` (in seconds) if that method's *timeout*
            parameter is not specified.
        cli_binary: Path to the Juju CLI binary. If not specified, uses ``juju`` and assumes it is
            in the PATH.
    """

    model: str | None
    """If not None, operate on this Juju model, otherwise use the current Juju model.

    If the model is in another controller, prefix the model name with ``<controller>:``; for
    example, ``juju = jubilant.Juju(model='mycontroller:my-model')``.
    """

    wait_timeout: float
    """The default timeout for :meth:`wait` (in seconds) if that method's *timeout* parameter is
    not specified.
    """

    cli_binary: str
    """Path to the Juju CLI binary. If None, uses ``juju`` and assumes it is in the PATH."""

    def __init__(
        self,
        *,
        model: str | None = None,
        wait_timeout: float = 3 * 60.0,
        cli_binary: str | pathlib.Path | None = None,
    ):
        self.model = model
        self.wait_timeout = wait_timeout
        self.cli_binary = str(cli_binary or 'juju')

    def __repr__(self) -> str:
        args = [
            f'model={self.model!r}',
            f'wait_timeout={self.wait_timeout}',
            f'cli_binary={self.cli_binary!r}',
        ]
        return f'Juju({", ".join(args)})'

    # Keep the public methods in alphabetical order, so we don't have to think
    # about where to put each new method.

    @overload
    def add_credential(
        self,
        cloud: str,
        credential: str | pathlib.Path | Mapping[str, Any],
        *,
        client: Literal[True],
        controller: None = None,
        region: str | None = None,
    ) -> None: ...

    @overload
    def add_credential(
        self,
        cloud: str,
        credential: str | pathlib.Path | Mapping[str, Any],
        *,
        client: bool = False,
        controller: str,
        region: str | None = None,
    ) -> None: ...

    def add_credential(
        self,
        cloud: str,
        credential: str | pathlib.Path | Mapping[str, Any],
        *,
        client: bool = False,
        controller: str | None = None,
        region: str | None = None,
    ) -> None:
        """Add a credential for a cloud.

        Args:
            cloud: Name of the cloud to add credentials for.
            credential: Path to a YAML file containing credential to add, or a mapping
                representing the parsed credential YAML (``{'credentials': ...}``).
            client: Set to True to save credentials on the client, meaning controllers
                created later will have access to them. You must specify ``client=True``
                or provide *controller* (or both).
            controller: If specified, save credentials to the named controller.
            region: Cloud region that the credential is valid for.
        """
        if not client and controller is None:
            raise TypeError('"client" must be True or "controller" must be specified (or both)')

        args = ['add-credential', cloud]

        if client:
            args.append('--client')
        if controller is not None:
            args.extend(['--controller', controller])
        if region is not None:
            args.extend(['--region', region])

        if isinstance(credential, (str, pathlib.Path)):
            args.extend(['--file', str(credential)])
            self.cli(*args, include_model=False)
        else:
            with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as temp_file:
                _yaml.safe_dump(credential, temp_file)
                temp_file.flush()
                args.extend(['--file', temp_file.name])
                self.cli(*args, include_model=False)

    def add_model(
        self,
        model: str,
        cloud: str | None = None,
        *,
        controller: str | None = None,
        config: Mapping[str, ConfigValue] | None = None,
        credential: str | None = None,
    ) -> None:
        """Add a named model and set this instance's model to it.

        To avoid interfering with CLI users, this won't switch the Juju CLI to the
        newly-created model. However, because :attr:`model` is set to the name of the new
        model, all subsequent operations on this instance will use the new model.

        Args:
            model: Name of model to add.
            cloud: Name of cloud or region (or cloud/region) to use for the model.
            controller: Name of controller to operate in. If not specified, use the current
                controller.
            config: Model configuration as key-value pairs, for example,
                ``{'image-stream': 'daily'}``.
            credential: Name of cloud credential to use for the model.
        """
        args = ['add-model', '--no-switch', model]

        if cloud is not None:
            args.append(cloud)

        if controller is None:
            model_name = model
        else:
            args.extend(['--controller', controller])
            model_name = f'{controller}:{model}'
        if config is not None:
            for k, v in config.items():
                args.extend(['--config', _format_config(k, v)])
        if credential is not None:
            args.extend(['--credential', credential])

        self.cli(*args, include_model=False)
        self.model = model_name

    def add_secret(
        self,
        name: str,
        content: Mapping[str, str],
        *,
        info: str | None = None,
    ) -> SecretURI:
        """Add a new named secret and return its secret URI.

        Args:
            name: Name for the secret.
            content: Key-value pairs that represent the secret content, for example
                ``{'password': 'hunter2'}``.
            info: Description for the secret.
        """
        args = ['add-secret', name]
        if info is not None:
            args.extend(['--info', info])

        with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as file:
            _yaml.safe_dump(content, file)
            file.flush()
            args.extend(['--file', file.name])
            output = self.cli(*args)

        return SecretURI(output.strip())

    def add_ssh_key(self, *keys: str) -> None:
        """Add one or more SSH keys to the model.

        The SSH keys are added to all current and future machines in the model.

        Args:
            keys: SSH public key or keys to add. Each key should be the full
                SSH public key string (for example, ``ssh-rsa AAAAB3... user@host``).
        """
        self.cli('add-ssh-key', *keys)

    def add_unit(
        self,
        app: str,
        *,
        attach_storage: str | Iterable[str] | None = None,
        num_units: int = 1,
        to: str | Iterable[str] | None = None,
    ):
        """Add one or more units to a deployed application.

        Args:
            app: Name of application to add units to.
            attach_storage: Existing storage(s) to attach to the deployed unit, for example,
                ``foo/0`` or ``mydisk/1``. Not available for Kubernetes models.
            num_units: Number of units to add.
            to: Machine or container to deploy the unit in (bypasses constraints). For example,
                to deploy to a new LXD container on machine 25, use ``lxd:25``.
        """
        args = ['add-unit', app]

        if attach_storage:
            if isinstance(attach_storage, str):
                args.extend(['--attach-storage', attach_storage])
            else:
                args.extend(['--attach-storage', ','.join(attach_storage)])
        if num_units != 1:
            args.extend(['--num-units', str(num_units)])
        if to:
            if isinstance(to, str):
                args.extend(['--to', to])
            else:
                args.extend(['--to', ','.join(to)])

        self.cli(*args)

    def bootstrap(
        self,
        cloud: str,
        controller: str,
        *,
        bootstrap_base: str | None = None,
        bootstrap_constraints: Mapping[str, str] | None = None,
        config: Mapping[str, ConfigValue] | None = None,
        constraints: Mapping[str, str] | None = None,
        credential: str | None = None,
        force: bool = False,
        model_defaults: Mapping[str, ConfigValue] | None = None,
        storage_pool: Mapping[str, str] | None = None,
        to: str | Iterable[str] | None = None,
    ) -> None:
        """Bootstrap a controller on a cloud environment.

        To avoid interfering with CLI users, this does not switch the Juju CLI
        to the newly-created controller. In addition, ``self.model`` is not updated.

        If you want to create a new controller with a model, use :meth:`add_model`
        after calling this method, which will set ``self.model`` for future commands::

            juju = jubilant.Juju()
            juju.bootstrap('lxd', 'myctrl')
            juju.add_model('mymodel', controller='myctrl')
            # self.model will be 'myctrl.mymodel' here

        Args:
            cloud: Name of cloud to bootstrap on. Initialization consists of creating a
                "controller" model and provisioning a machine to act as controller.
            controller: Name for the controller.
            bootstrap_base: Specify the base of the bootstrap machine, for example
                ``"24.04"``.
            bootstrap_constraints: Specify bootstrap machine constraints, for example,
                ``{'mem': '8G'}``. If used, its values will also apply to any future
                controllers provisioned for high availability (HA).
            config: Controller configuration options. Model config keys only affect the
                controller model.
            constraints: Set model constraints, for example, ``{'mem': '8G', 'cores': '4'}``.
                If used, its values will be set as the default constraints for all future
                workload machines in the model, exactly as if the constraints were set with
                ``juju set-model-constraints``.
            credential: Name of cloud credential to use when bootstrapping.
            force: If True, allow bypassing of checks such as supported bases.
            model_defaults: Configuration options to set for all models.
            storage_pool: Options for an initial storage pool as key-value pairs. ``name``
                and ``type`` are required, plus any additional attributes.
            to: Placement directive indicating an instance to bootstrap.
        """
        args = ['bootstrap', cloud, controller, '--no-switch']
        if bootstrap_base is not None:
            args.extend(['--bootstrap-base', bootstrap_base])
        if bootstrap_constraints is not None:
            for k, v in bootstrap_constraints.items():
                args.extend(['--bootstrap-constraints', f'{k}={v}'])
        if config is not None:
            for k, v in config.items():
                args.extend(['--config', _format_config(k, v)])
        if constraints is not None:
            for k, v in constraints.items():
                args.extend(['--constraints', f'{k}={v}'])
        if credential is not None:
            args.extend(['--credential', credential])
        if force:
            args.append('--force')
        if model_defaults is not None:
            for k, v in model_defaults.items():
                args.extend(['--model-default', _format_config(k, v)])
        if storage_pool is not None:
            for k, v in storage_pool.items():
                args.extend(['--storage-pool', f'{k}={v}'])
        if to is not None:
            if isinstance(to, str):
                args.extend(['--to', to])
            else:
                args.extend(['--to', ','.join(to)])

        _, stderr = self._cli(*args, include_model=False)
        logger.info('bootstrap output:\n%s', stderr)

    def cli(self, *args: str, include_model: bool = True, stdin: str | None = None) -> str:
        """Run a Juju CLI command and return its standard output.

        Args:
            args: Command-line arguments (excluding ``juju``).
            include_model: If true and :attr:`model` is set, insert the ``--model`` argument
                after the first argument in *args*.
            stdin: Standard input to send to the process, if any.
        """
        stdout, _ = self._cli(*args, include_model=include_model, stdin=stdin)
        return stdout

    def _cli(
        self,
        *args: str,
        include_model: bool = True,
        stdin: str | None = None,
        log: bool = True,
        timeout: float | None = None,
    ) -> tuple[str, str]:
        """Run a Juju CLI command and return its standard output and standard error."""
        if include_model and self.model is not None:
            args = (args[0], '--model', self.model, *args[1:])
        if log:
            logger.info('cli: juju %s', shlex.join(args))
        try:
            process = subprocess.run(
                [self.cli_binary, *args],
                check=True,
                capture_output=True,
                encoding='utf-8',
                input=stdin,
                timeout=timeout,
            )
        except subprocess.CalledProcessError as e:
            raise CLIError(e.returncode, e.cmd, e.stdout, e.stderr) from None
        return (process.stdout, process.stderr)

    @overload
    def config(self, app: str, *, app_config: bool = False) -> Mapping[str, ConfigValue]: ...

    @overload
    def config(
        self,
        app: str,
        values: Mapping[str, ConfigValue],
        *,
        reset: str | Iterable[str] = (),
    ) -> None: ...

    @overload
    def config(self, app: str, *, reset: str | Iterable[str]) -> None: ...

    def config(
        self,
        app: str,
        values: Mapping[str, ConfigValue] | None = None,
        *,
        app_config: bool = False,
        reset: str | Iterable[str] = (),
    ) -> Mapping[str, ConfigValue] | None:
        """Get or set the configuration of a deployed application.

        If called with only the *app* argument, get the config and return it.

        If called with the *values* or *reset* arguments, set the config values and return None,
        and reset any keys in *reset* to their defaults.

        Args:
            app: Application name to get or set config for.
            values: Mapping of config names to values to set.
            app_config: When getting config, set this to True to get the
                (poorly-named) "application-config" values instead of charm config.
            reset: Key or list of keys to reset to their defaults.
        """
        if values is None and not reset:
            stdout = self.cli('config', '--format', 'json', app)
            outer = json.loads(stdout)
            inner = outer['application-config'] if app_config else outer['settings']
            result = {
                k: SecretURI(v['value']) if v['type'] == 'secret' else v['value']
                for k, v in inner.items()
                if 'value' in v
            }
            return result

        args = ['config', app]
        if values:
            args.extend(_format_config(k, v) for k, v in values.items())
        if reset:
            if not isinstance(reset, str):
                reset = ','.join(reset)
            args.extend(['--reset', reset])

        self.cli(*args)

    def consume(
        self,
        model_and_app: str,
        alias: str | None = None,
        *,
        controller: str | None = None,
        owner: str | None = None,
    ) -> None:
        """Add a remote offer to the model.

        Examples::

            juju.consume('othermodel.mysql', 'sql')
            juju.consume('othermodel.mysql', controller='ctrl2', owner='admin')

        Args:
            model_and_app: Dotted application and model name to offer endpoints for, for example
                ``othermodel.mysql``.
            alias: A local alias for the offer, for use with :meth:`integrate`. Defaults to the
                application name.
            controller: Remote offer's controller. Defaults to the current controller.
            owner: Remote model's owner. Defaults to the user that is currently logged in to the
                controller providing the offer.
        """
        offer_path = model_and_app
        if owner is not None:
            offer_path = f'{owner}/{offer_path}'
        if controller is not None:
            offer_path = f'{controller}:{offer_path}'
        args = ['consume', offer_path]
        if alias is not None:
            args.append(alias)

        self.cli(*args)

    def debug_log(self, *, limit: int = 0) -> str:
        """Return debug log messages from a model.

        For example, to create a pytest fixture which shows the last 1000 log lines if any tests
        fail::

            @pytest.fixture(scope='module')
            def juju(request: pytest.FixtureRequest):
                with jubilant.temp_model() as juju:
                    yield juju  # run the test
                    if request.session.testsfailed:
                        log = juju.debug_log(limit=1000)
                        print(log, end='')

        Args:
            limit: Limit the result to the most recent *limit* lines. Defaults to 0, meaning
                return all lines in the log.
        """
        args = ['debug-log', '--limit', str(limit)]
        return self.cli(*args)

    def deploy(
        self,
        charm: str | pathlib.Path,
        app: str | None = None,
        *,
        attach_storage: str | Iterable[str] | None = None,
        base: str | None = None,
        bind: Mapping[str, str] | str | None = None,
        channel: str | None = None,
        config: Mapping[str, ConfigValue] | None = None,
        constraints: Mapping[str, str] | None = None,
        force: bool = False,
        num_units: int = 1,
        overlays: Iterable[str | pathlib.Path] = (),
        resources: Mapping[str, str] | None = None,
        revision: int | None = None,
        storage: Mapping[str, str] | None = None,
        to: str | Iterable[str] | None = None,
        trust: bool = False,
    ) -> None:
        """Deploy an application or bundle.

        Args:
            charm: Name of charm or bundle to deploy, or path to a local file (must start with
                ``/`` or ``.``).
            app: Custom application name within the model. Defaults to the charm name.
            attach_storage: Existing storage(s) to attach to the deployed unit, for example,
                ``foo/0`` or ``mydisk/1``. Not available for Kubernetes models.
            base: The base on which to deploy, for example, ``ubuntu@22.04``.
            bind: Either a mapping of endpoint-to-space bindings, for example
                ``{'database-peers': 'internal-space'}``, or a single space name, which is
                equivalent to binding all endpoints to that space.
            channel: Channel to use when deploying from Charmhub, for example, ``latest/edge``.
            config: Application configuration as key-value pairs, for example,
                ``{'name': 'My Wiki'}``.
            constraints: Hardware constraints for new machines, for example, ``{'mem': '8G'}``.
            force: If true, bypass checks such as supported bases.
            num_units: Number of units to deploy for principal charms.
            overlays: File paths of bundles to overlay on the primary bundle, applied in order.
            resources: Specify named resources to use for deployment, for example:
                ``{'bin': '/path/to/some/binary'}``.
            revision: Charmhub revision number to deploy.
            storage: Constraints for named storage(s), for example, ``{'data': 'tmpfs,1G'}``.
            to: Machine or container to deploy the unit in (bypasses constraints). For example,
                to deploy to a new LXD container on machine 25, use ``lxd:25``.
            trust: If true, allows charm to run hooks that require access to cloud credentials.
        """
        # Need this check because str is also an iterable of str.
        if isinstance(overlays, str):
            raise TypeError('overlays must be an iterable of str or pathlib.Path, not str')

        with self._deploy_tempdir(charm, resources) as (_charm, resources):
            assert _charm is not None
            args = ['deploy', _charm]

            if app is not None:
                args.append(app)

            if attach_storage:
                if isinstance(attach_storage, str):
                    args.extend(['--attach-storage', attach_storage])
                else:
                    args.extend(['--attach-storage', ','.join(attach_storage)])
            if base is not None:
                args.extend(['--base', base])
            if bind is not None:
                if not isinstance(bind, str):
                    bind = ' '.join(f'{k}={v}' for k, v in bind.items())
                args.extend(['--bind', bind])
            if channel is not None:
                args.extend(['--channel', channel])
            if config is not None:
                for k, v in config.items():
                    args.extend(['--config', _format_config(k, v)])
            if constraints is not None:
                for k, v in constraints.items():
                    args.extend(['--constraints', f'{k}={v}'])
            if force:
                args.append('--force')
            if num_units != 1:
                args.extend(['--num-units', str(num_units)])
            for overlay in overlays:
                args.extend(['--overlay', str(overlay)])
            if resources is not None:
                for k, v in resources.items():
                    args.extend(['--resource', f'{k}={v}'])
            if revision is not None:
                args.extend(['--revision', str(revision)])
            if storage is not None:
                for k, v in storage.items():
                    args.extend(['--storage', f'{k}={v}'])
            if to:
                if isinstance(to, str):
                    args.extend(['--to', to])
                else:
                    args.extend(['--to', ','.join(to)])
            if trust:
                args.append('--trust')

            self.cli(*args)

    def destroy_model(
        self,
        model: str,
        *,
        destroy_storage: bool = False,
        force: bool = False,
    ) -> None:
        """Terminate all machines (or containers) and resources for a model.

        If the given model is this instance's model, also sets this instance's
        :attr:`model` to None.

        Args:
            model: Name of model to destroy.
            destroy_storage: If true, destroy all storage instances in the model.
            force: If true, force model destruction and ignore any errors.
        """
        args = ['destroy-model', model, '--no-prompt']
        if destroy_storage:
            args.append('--destroy-storage')
        if force:
            args.append('--force')
        self.cli(*args, include_model=False)
        if model == self.model:
            self.model = None

    @overload
    def exec(
        self, command: str, *args: str, machine: int | str, wait: float | None = None
    ) -> Task: ...

    @overload
    def exec(self, command: str, *args: str, unit: str, wait: float | None = None) -> Task: ...

    def exec(
        self,
        command: str,
        *args: str,
        machine: int | str | None = None,
        unit: str | None = None,
        wait: float | None = None,
    ) -> Task:
        """Run the command on the remote target specified.

        You must specify either *machine* or *unit*, but not both.

        Note: this method does not support running a command on multiple units
        at once. If you need that, let us know, and we'll consider adding it
        with a new ``exec_multiple`` method or similar.

        Args:
            command: Command to run. Because the command is executed using the shell,
                arguments may also be included here as a single string, for example
                ``juju.exec('echo foo', ...)``.
            args: Arguments of the command.
            machine: ID of machine to run the command on, for example ``0``, ``"0"``,
                or ``"0/lxd/0"``.
            unit: Name of unit to run the command on, for example ``mysql/0`` or ``mysql/leader``.
            wait: Maximum time to wait for command to finish; :class:`TimeoutError` is raised if
                this is reached. Juju's default is to wait 5 minutes.

        Returns:
            The task created to run the command, including logs, failure message, and so on.

        Raises:
            ValueError: if the machine or unit doesn't exist.
            TaskError: if the command failed.
            TimeoutError: if *wait* was specified and the wait time was reached.
        """
        if (machine is not None and unit is not None) or (machine is None and unit is None):
            raise TypeError('must specify "machine" or "unit", but not both')

        cli_args = ['exec', '--format', 'json']
        if machine is not None:
            cli_args.extend(['--machine', str(machine)])
        else:
            assert unit is not None
            cli_args.extend(['--unit', unit])
        if wait is not None:
            cli_args.extend(['--wait', f'{wait}s'])
        cli_args.append('--')
        cli_args.append(command)
        cli_args.extend(args)

        try:
            stdout, stderr = self._cli(*cli_args)
        except CLIError as exc:
            if 'timed out' in exc.stderr:
                msg = f'timed out waiting for command, stderr:\n{exc.stderr}'
                raise TimeoutError(msg) from None
            # The "juju exec" CLI command itself fails if the exec'd command fails.
            if 'task failed' not in exc.stderr:
                raise
            stdout = exc.stdout
            stderr = exc.stderr

        # Command doesn't return any stdout if no units exist.
        results: dict[str, Any] = json.loads(stdout) if stdout.strip() else {}
        if not results:
            raise ValueError(f'error running command, stderr:\n{stderr}')
        # Don't look up results[unit] directly, because if the caller specifies
        # app/leader it is returned as app/N, for example app/0.
        task_dict = next(iter(results.values()))
        task = Task._from_dict(task_dict)
        task.raise_on_failure()
        return task

    def grant_secret(self, identifier: str | SecretURI, app: str | Iterable[str]) -> None:
        """Grant access to a secret for one or more applications.

        Args:
            identifier: The name or URI of the secret to grant access to.
            app: Name or names of applications to grant access to.
        """
        if not isinstance(app, str):
            app = ','.join(app)
        args = ['grant-secret', identifier, app]
        self.cli(*args)

    def integrate(self, app1: str, app2: str, *, via: str | Iterable[str] | None = None) -> None:
        """Integrate two applications, creating a relation between them.

        The order of *app1* and *app2* is not significant. Each of them should
        be in the format ``<application>[:<endpoint>]``. The endpoint is only
        required if there's more than one possible integration between the two
        applications.

        To integrate an application in the current model with an application in
        another model (cross-model), prefix *app1* or *app2* with ``<model>.``.
        To integrate with an application on another controller, *app1* or *app2* must
        be an offer endpoint. See ``juju integrate --help`` for details.

        Args:
            app1: One of the applications (and endpoints) to integrate.
            app2: The other of the applications (and endpoints) to integrate.
            via: Inform the offering side (the remote application) of the
                source of traffic, to enable network ports to be opened. This
                is in CIDR notation, for example ``192.0.2.0/24``.
        """
        args = ['integrate', app1, app2]
        if via:
            if isinstance(via, str):
                args.extend(['--via', via])
            else:
                args.extend(['--via', ','.join(via)])
        self.cli(*args)

    @overload
    def model_config(self) -> Mapping[str, ConfigValue]: ...

    @overload
    def model_config(
        self, values: Mapping[str, ConfigValue], *, reset: str | Iterable[str] = ()
    ) -> None: ...

    @overload
    def model_config(self, *, reset: str | Iterable[str]) -> None: ...

    def model_config(
        self, values: Mapping[str, ConfigValue] | None = None, reset: str | Iterable[str] = ()
    ) -> Mapping[str, ConfigValue] | None:
        """Get or set the configuration of the model.

        If called with no arguments, get the model config and return it.

        If called with the *values* or *reset* arguments, set the model config values and return
        None, and reset any keys in *reset* to their defaults.

        Args:
            values: Mapping of model config names to values to set, for example
                ``{'update-status-hook-interval': '10s'}``.
            reset: Key or list of keys to reset to their defaults.
        """
        if values is None and not reset:
            stdout = self.cli('model-config', '--format', 'json')
            result = json.loads(stdout)
            return {k: v['Value'] for k, v in result.items() if 'Value' in v}

        args = ['model-config']
        if values:
            args.extend(_format_config(k, v) for k, v in values.items())
        if reset:
            if not isinstance(reset, str):
                reset = ','.join(reset)
            args.extend(['--reset', reset])

        self.cli(*args)

    def offer(
        self,
        app: str,
        *,
        controller: str | None = None,
        endpoint: str | Iterable[str],
        name: str | None = None,
    ) -> None:
        """Offer application endpoints for use in other models.

        Examples::

            juju.offer('mysql', endpoint='db')
            juju.offer('mymodel.mysql', endpoint=['db', 'log'], name='altname')

        Args:
            app: Application name to offer endpoints for. May include a dotted model name, for
                example ``mymodel.mysql``.
            controller: Name of controller to operate in. If not specified, use the current
                controller.
            endpoint: Endpoint or endpoints to offer.
            name: Name of the offer. By default, the offer is named after the application.
        """
        if not isinstance(endpoint, str):
            endpoint = ','.join(endpoint)
        app_endpoint = f'{app}:{endpoint}'
        args = ['offer', app_endpoint]
        if controller:
            args.extend(['--controller', controller])
        if name is not None:
            args.append(name)

        self.cli(*args, include_model=False)

    def refresh(
        self,
        app: str,
        *,
        base: str | None = None,
        channel: str | None = None,
        config: Mapping[str, ConfigValue] | None = None,
        force: bool = False,
        path: str | pathlib.Path | None = None,
        resources: Mapping[str, str] | None = None,
        revision: int | None = None,
        storage: Mapping[str, str] | None = None,
        trust: bool = False,
    ):
        """Refresh (upgrade) an application's charm.

        Args:
            app: Name of application to refresh.
            base: Select a different base than is currently running.
            channel: Channel to use when deploying from Charmhub, for example, ``latest/edge``.
            config: Application configuration as key-value pairs.
            force: If true, bypass checks such as supported bases.
            path: Refresh to a charm located at this path.
            resources: Specify named resources to use for deployment, for example:
                ``{'bin': '/path/to/some/binary'}``.
            revision: Charmhub revision number to deploy.
            storage: Constraints for named storage(s), for example, ``{'data': 'tmpfs,1G'}``.
            trust: If true, allows charm to run hooks that require access to cloud credentials.
        """
        args = ['refresh', app]

        with self._deploy_tempdir(path, resources) as (path, resources):
            if base is not None:
                args.extend(['--base', base])
            if channel is not None:
                args.extend(['--channel', channel])
            if config is not None:
                for k, v in config.items():
                    args.extend(['--config', _format_config(k, v)])
            if force:
                args.extend(['--force', '--force-base', '--force-units'])
            if path is not None:
                args.extend(['--path', path])
            if resources is not None:
                for k, v in resources.items():
                    args.extend(['--resource', f'{k}={v}'])
            if revision is not None:
                args.extend(['--revision', str(revision)])
            if storage is not None:
                for k, v in storage.items():
                    args.extend(['--storage', f'{k}={v}'])
            if trust:
                args.append('--trust')

            self.cli(*args)

    def remove_application(
        self,
        *app: str,
        destroy_storage: bool = False,
        force: bool = False,
    ) -> None:
        """Remove applications from the model.

        Args:
            app: Name of the application or applications to remove.
            destroy_storage: If True, also destroy storage attached to application units.
            force: Force removal even if an application is in an error state.
        """
        args = ['remove-application', '--no-prompt', *app]

        if destroy_storage:
            args.append('--destroy-storage')
        if force:
            args.append('--force')

        self.cli(*args)

    def remove_relation(self, app1: str, app2: str, *, force: bool = False) -> None:
        """Remove an existing relation between two applications (opposite of :meth:`integrate`).

        The order of *app1* and *app2* is not significant. Each of them should
        be in the format ``<application>[:<endpoint>]``. The endpoint is only
        required if there's more than one possible integration between the two
        applications.

        Args:
            app1: One of the applications (and endpoints) to integrate.
            app2: The other of the applications (and endpoints) to integrate.
            force: Force removal, ignoring operational errors.
        """
        args = ['remove-relation', app1, app2]
        if force:
            args.append('--force')
        self.cli(*args)

    def remove_secret(self, identifier: str | SecretURI, *, revision: int | None = None) -> None:
        """Remove a secret from the model.

        Args:
            identifier: The name or URI of the secret to remove.
            revision: The revision of the secret to remove. If not specified, remove all revisions.
        """
        args = ['remove-secret', identifier]
        if revision is not None:
            args.extend(['--revision', str(revision)])
        self.cli(*args)

    def remove_ssh_key(self, *ids: str) -> None:
        """Remove one or more SSH keys from the model.

        The SSH keys are removed from all machines in the model.

        Args:
            ids: SSH key identifier or identifiers to remove. Each identifier can be
                a key fingerprint (for example,
                ``45:7f:33:2c:10:4e:6c:14:e3:a1:a4:c8:b2:e1:34:b4``), a key comment
                (for example, ``user@host``), or the full SSH public key string.
        """
        self.cli('remove-ssh-key', *ids)

    def remove_unit(
        self,
        *app_or_unit: str,
        destroy_storage: bool = False,
        force: bool = False,
        num_units: int = 0,
    ) -> None:
        """Remove application units from the model.

        Examples::

            # Kubernetes model:
            juju.remove_unit('wordpress', num_units=2)

            # Machine model:
            juju.remove_unit('wordpress/1')
            juju.remove_unit('wordpress/2', 'wordpress/3')

        Args:
            app_or_unit: On machine models, this is the name of the unit or units to remove.
                On Kubernetes models, this is actually the application name (a single string),
                as individual units are not named; you must use *num_units* to remove more than
                one unit on a Kubernetes model.
            destroy_storage: If True, also destroy storage attached to units.
            force: Force removal even if a unit is in an error state.
            num_units: Number of units to remove (Kubernetes models only).
        """
        args = ['remove-unit', '--no-prompt', *app_or_unit]

        if destroy_storage:
            args.append('--destroy-storage')
        if force:
            args.append('--force')
        if num_units:
            if len(app_or_unit) > 1:
                raise TypeError('"app_or_unit" must be a single app name if num_units specified')
            args.extend(['--num-units', str(num_units)])

        self.cli(*args)

    def run(
        self,
        unit: str,
        action: str,
        params: Mapping[str, Any] | None = None,
        *,
        wait: float | None = None,
    ) -> Task:
        """Run an action on the given unit and wait for the result.

        Note: this method does not support running an action on multiple units
        at once. If you need that, let us know, and we'll consider adding it
        with a new ``run_multiple`` method or similar.

        Example::

            juju = jubilant.Juju()
            result = juju.run('mysql/0', 'get-password')
            assert result.results['username'] == 'USER0'

        Args:
            unit: Name of unit to run the action on, for example ``mysql/0`` or
                ``mysql/leader``.
            action: Name of action to run.
            params: Named parameters to pass to the action.
            wait: Maximum time to wait for action to finish; :class:`TimeoutError` is raised if
                this is reached. Juju's default is to wait 60 seconds.

        Returns:
            The task created to run the action, including logs, failure message, and so on.

        Raises:
            ValueError: if the action or the unit doesn't exist.
            TaskError: if the action failed.
            TimeoutError: if *wait* was specified and the wait time was reached.
        """
        args = ['run', '--format', 'json', unit, action]
        if wait is not None:
            args.extend(['--wait', f'{wait}s'])

        with (
            tempfile.NamedTemporaryFile('w+', dir=self._temp_dir)
            if params is not None
            else contextlib.nullcontext()
        ) as params_file:
            # params_file is defined when params is not None
            if params_file is not None:
                _yaml.safe_dump(params, params_file)
                params_file.flush()
                args.extend(['--params', params_file.name])
            try:
                stdout, stderr = self._cli(*args)
            except CLIError as exc:
                if 'timed out' in exc.stderr:
                    msg = f'timed out waiting for action, stderr:\n{exc.stderr}'
                    raise TimeoutError(msg) from None
                # The "juju run" CLI command fails if the action has an uncaught exception.
                if 'task failed' not in exc.stderr:
                    raise
                stdout = exc.stdout
                stderr = exc.stderr

            # Command doesn't return any stdout if no units exist.
            results: dict[str, Any] = json.loads(stdout) if stdout.strip() else {}
            if not results:
                raise ValueError(f'error running action {action!r}, stderr:\n{stderr}')
            # Don't look up results[unit] directly, because if the caller specifies
            # app/leader it is returned as app/N, for example app/0.
            task_dict = next(iter(results.values()))
            task = Task._from_dict(task_dict)
            task.raise_on_failure()
            return task

    def scp(
        self,
        source: str | pathlib.Path,
        destination: str | pathlib.Path,
        *,
        container: str | None = None,
        host_key_checks: bool = True,
        scp_options: Iterable[str] = (),
    ) -> None:
        """Securely transfer files within a model.

        Args:
            source: Source of file, in format ``[[<user>@]<target>:]<path>``.
            destination: Destination for file, in format ``[<user>@]<target>[:<path>]``.
            container: Name of container for Kubernetes charms. Defaults to the charm container.
            host_key_checks: Set to False to disable host key checking (insecure).
            scp_options: ``scp`` client options, for example ``['-r', '-C']``.
        """
        # Need this check because str is also an iterable of str.
        if isinstance(scp_options, str):
            raise TypeError('scp_options must be an iterable of str, not str')

        args = ['scp']
        if container is not None:
            args.extend(['--container', container])
        if not host_key_checks:
            args.append('--no-host-key-checks')
        args.append('--')
        args.extend(scp_options)

        source = str(source)
        destination = str(destination)
        temp_needed = (':' not in source) != (':' not in destination) and self._juju_is_snap
        if not temp_needed:
            # Simple cases: juju not snap, or local->local, or remote->remote
            args.append(source)
            args.append(destination)
            self.cli(*args)
            return

        with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as file_temp:
            if ':' not in source:
                # Local source, remote destination
                shutil.copy(source, file_temp.name)
                args.append(file_temp.name)
                args.append(destination)
                self.cli(*args)
            else:
                # Remote source, local destination
                args.append(source)
                args.append(file_temp.name)
                self.cli(*args)
                shutil.copy(file_temp.name, destination)

    def secrets(self, *, owner: str | None = None) -> list[Secret]:
        """Get all secrets in the model.

        Args:
            owner: The owner of the secrets to retrieve.

        Returns:
            A list of all secrets in the model.
        """
        args = ['secrets']
        if owner is not None:
            args.extend(['--owner', owner])
        stdout = self.cli(*args, '--format', 'json')
        output = json.loads(stdout)
        return [
            Secret._from_dict({'uri': uri_from_juju, **obj})
            for uri_from_juju, obj in output.items()
        ]

    def show_model(self, model: str | None = None) -> ModelInfo:
        """Get information about the current model (or another model).

        Args:
            model: Name of the model or ``controller:model``. If omitted,
                return details about the current model.
        """
        args = ['show-model', '--format', 'json']
        if model is not None:
            args.append(model)
        elif self.model is not None:
            # Use this instance's model if set.
            args.append(self.model)
        stdout = self.cli(*args, include_model=False)
        results = json.loads(stdout)
        info_dict = next(iter(results.values()))
        return ModelInfo._from_dict(info_dict)

    @overload
    def show_secret(
        self,
        identifier: str | SecretURI,
        *,
        reveal: Literal[True],
        revision: int | None = None,
        revisions: Literal[False] = False,
    ) -> RevealedSecret: ...

    @overload
    def show_secret(
        self,
        identifier: str | SecretURI,
        *,
        reveal: Literal[False] = False,
        revision: int | None = None,
        revisions: Literal[False] = False,
    ) -> Secret: ...

    @overload
    def show_secret(
        self,
        identifier: str | SecretURI,
        *,
        reveal: Literal[False] = False,
        revision: None = None,
        revisions: Literal[True],
    ) -> Secret: ...

    def show_secret(
        self,
        identifier: str | SecretURI,
        *,
        reveal: bool = False,
        revision: int | None = None,
        revisions: bool = False,
    ) -> Secret | RevealedSecret:
        """Get the content of a secret.

        Args:
            identifier: Name or URI of the secret to return.
            reveal: Whether to reveal the secret content.
            revision: Revision number of the secret to reveal. If not specified,
                the latest revision is revealed.
            revisions: Whether to include all revisions of the secret. Mutually
                exclusive with *reveal* and *revision*.
        """
        args = ['show-secret', identifier, '--format', 'json']
        if reveal:
            args.append('--reveal')
        if revisions:
            args.append('--revisions')
        if revision is not None:
            args.extend(['--revision', str(revision)])
        stdout = self.cli(*args)
        output = json.loads(stdout)
        uri_from_juju, obj = next(iter(output.items()))
        secret = {'uri': uri_from_juju, **obj}
        if reveal:
            return RevealedSecret._from_dict(secret)
        return Secret._from_dict(secret)

    def ssh(
        self,
        target: str | int,
        command: str,
        *args: str,
        container: str | None = None,
        host_key_checks: bool = True,
        ssh_options: Iterable[str] = (),
        user: str | None = None,
    ) -> str:
        """Executes a command using SSH on a machine or container and returns its standard output.

        Args:
            target: Where to run the command; this is a unit name such as ``mysql/0`` or a machine
                ID such as ``0``.
            command: Command to run. Because the command is executed using the shell,
                arguments may also be included here as a single string, for example
                ``juju.ssh('mysql/0', 'echo foo', ...)``.
            args: Arguments of the command.
            container: Name of container for Kubernetes charms. Defaults to the charm container.
            host_key_checks: Set to False to disable host key checking (insecure).
            ssh_options: OpenSSH client options, for example ``['-i', '/path/to/private.key']``.
            user: User account to make connection with. Defaults to ``ubuntu`` account.
        """
        # Need this check because str is also an iterable of str.
        if isinstance(ssh_options, str):
            raise TypeError('ssh_options must be an iterable of str, not str')

        cli_args = ['ssh']
        if container is not None:
            cli_args.extend(['--container', container])
        if not host_key_checks:
            cli_args.append('--no-host-key-checks')
        if user is not None:
            cli_args.append(f'{user}@{target}')
        else:
            cli_args.append(str(target))
        cli_args.extend(ssh_options)
        cli_args.append(command)
        cli_args.extend(args)

        return self.cli(*cli_args)

    def status(self) -> Status:
        """Fetch the status of the current model, including its applications and units."""
        stdout = self.cli('status', '--format', 'json')
        result = json.loads(stdout)
        return Status._from_dict(result)

    def trust(
        self, app: str, *, remove: bool = False, scope: Literal['cluster'] | None = None
    ) -> None:
        """Set the trust status of a deployed application.

        Args:
            app: Application name to set trust status for.
            remove: Set to True to remove trust status.
            scope: On Kubernetes models, this must be set to "cluster", as the trust operation
                grants the charm full access to the cluster.
        """
        args = ['trust', app]
        if remove:
            args.append('--remove')
        if scope is not None:
            args.extend(['--scope', scope])

        self.cli(*args)

    def update_secret(
        self,
        identifier: str | SecretURI,
        content: Mapping[str, str],
        *,
        info: str | None = None,
        name: str | None = None,
        auto_prune: bool = False,
    ) -> None:
        """Update the content of a secret.

        Args:
            identifier: The name or URI of the secret to update.
            content: Key-value pairs that represent the secret content, for example
                ``{'password': 'hunter2'}``.
            info: New description for the secret.
            name: New name for the secret.
            auto_prune: automatically remove revisions that are no longer tracked by any observers.
        """
        args = ['update-secret', identifier]
        if info is not None:
            args.extend(['--info', info])
        if name is not None:
            args.extend(['--name', name])
        if auto_prune:
            args.append('--auto-prune')

        with tempfile.NamedTemporaryFile('w+', dir=self._temp_dir) as file:
            _yaml.safe_dump(content, file)
            file.flush()
            args.extend(['--file', file.name])
            self.cli(*args)

    def version(self) -> Version:
        """Return the parsed Juju CLI version."""
        stdout = self.cli('version', '--format', 'json', '--all', include_model=False)
        version_dict = json.loads(stdout)
        return Version._from_dict(version_dict)

    def wait(
        self,
        ready: Callable[[Status], bool],
        *,
        error: Callable[[Status], bool] | None = None,
        delay: float = 1.0,
        timeout: float | None = None,
        successes: int = 3,
    ) -> Status:
        """Wait until ``ready(status)`` returns true.

        This fetches the Juju status repeatedly (waiting *delay* seconds between each call),
        and returns the last status after the *ready* callable returns true for *successes*
        times in a row.

        Example::

            juju = jubilant.Juju()
            juju.deploy('snappass-test')
            juju.wait(jubilant.all_active)

            # Or something more complex: wait specifically for 'snappass-test' to be active,
            # and raise if any app or unit is seen in "error" status while waiting.
            juju.wait(
                lambda status: jubilant.all_active(status, 'snappass-test'),
                error=jubilant.any_error,
            )

        For more examples, see `Tutorial | Use a custom wait condition <https://documentation.ubuntu.com/jubilant/tutorial/getting-started/#use-a-custom-wait-condition>`_.

        This function logs the status object after the first status call, and after subsequent
        calls if the status object has changed. Logs are sent to the logger named
        ``jubilant.wait`` at INFO level, so to disable these logs, set the level to WARNING or
        above::

            logging.getLogger('jubilant.wait').setLevel('WARNING')

        Args:
            ready: Callable that takes a :class:`Status` object and returns true when the wait
                should be considered ready. It needs to return true *successes* times in a row
                before ``wait`` returns.
            error: Callable that takes a :class:`Status` object and returns true when ``wait``
                should raise an error (:class:`WaitError`).
            delay: Delay in seconds between status calls.
            timeout: Overall timeout in seconds; :class:`TimeoutError` is raised if this
                is reached. If not specified, uses the *wait_timeout* specified when the
                instance was created.
            successes: Number of times *ready* must return true for the wait to succeed.

        Raises:
            TimeoutError: If the *timeout* is reached. A string representation
                of the last status, if any, is added as an exception note.
            WaitError: If the *error* callable returns True. A string representation
                of the last status is added as an exception note.
        """
        if timeout is None:
            timeout = self.wait_timeout

        status = None
        success_count = 0
        start = time.monotonic()

        while time.monotonic() - start < timeout:
            prev_status = status

            stdout, _ = self._cli('status', '--format', 'json', log=False)
            result = json.loads(stdout)
            status = Status._from_dict(result)

            if status != prev_status:
                diff = _status_diff(prev_status, status)
                if diff:
                    logger_wait.info('wait: status changed:\n%s', diff)

            if error is not None and error(status):
                name = getattr(error, '__qualname__', repr(error))
                raise WaitError(f'error function {name} returned true\n{status}')

            if ready(status):
                success_count += 1
                if success_count >= successes:
                    return status
            else:
                success_count = 0

            time.sleep(delay)

        if status is None:
            raise TimeoutError(f'wait timed out after {timeout}s')
        raise TimeoutError(f'wait timed out after {timeout}s\n{status}')

    @functools.cached_property
    def _juju_is_snap(self) -> bool:
        which = shutil.which(self.cli_binary)
        return which is not None and '/snap/' in which

    @functools.cached_property
    def _temp_dir(self) -> str:
        if self._juju_is_snap:
            # If Juju is running as a snap, we can't use /tmp, so put temp files here instead.
            temp_dir = os.path.expanduser('~/snap/juju/common')
            os.makedirs(temp_dir, exist_ok=True)
            return temp_dir
        else:
            return tempfile.gettempdir()

    # This context manager is for deploy() and refresh(), and automatically copies
    # a local charm file and local resource files into a temporary directory if Juju
    # is running as a snap (in which case /tmp is not accessible).
    @contextlib.contextmanager
    def _deploy_tempdir(
        self,
        charm: str | pathlib.Path | None,
        resources: Mapping[str, str] | None,
    ) -> Generator[tuple[str | None, Mapping[str, str] | None]]:
        if charm is not None:
            charm = str(charm)
        charm_needs_temp = charm is not None and charm.startswith(('.', '/'))
        resources_needs_temp = resources is not None and any(
            v.startswith(('.', '/')) for v in resources.values()
        )
        needs_temp = self._juju_is_snap and (charm_needs_temp or resources_needs_temp)
        if not needs_temp:
            yield charm, resources
            return

        with tempfile.TemporaryDirectory(dir=self._temp_dir) as temp_dir:
            if charm_needs_temp:
                assert charm is not None
                temp = os.path.join(temp_dir, '_temp.charm')
                shutil.copy(charm, temp)
                charm = temp

            if resources_needs_temp:
                assert resources is not None
                resources = dict(resources)
                for k, v in resources.items():
                    if v.startswith(('.', '/')):
                        resources[k] = os.path.join(temp_dir, k)
                        shutil.copy(v, resources[k])

            yield charm, resources


def _format_config(k: str, v: ConfigValue) -> str:
    if v is None:  # type: ignore
        raise TypeError(f'unexpected None value for config key {k!r}')
    if isinstance(v, bool):
        v = 'true' if v else 'false'
    return f'{k}={v}'


def _status_diff(old: Status | None, new: Status) -> str:
    """Return a line-based diff of two status objects."""
    if old is None:
        old_lines = []
    else:
        old_lines = [line for line in _pretty.gron(old) if _status_line_ok(line)]
    new_lines = [line for line in _pretty.gron(new) if _status_line_ok(line)]
    return '\n'.join(_pretty.diff(old_lines, new_lines))


def _status_line_ok(line: str) -> bool:
    """Return whether the status line should be included in the diff."""
    # Exclude controller timestamp as it changes every update and is just noise.
    field, _, _ = line.partition(' = ')
    if field == '.controller.timestamp':
        return False
    # Exclude status-updated-since timestamps as they just add noise (and log lines already
    # include timestamps).
    if field.endswith('.since'):
        return False
    return True
