# Copyright 2020-2023 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# For further info, check https://github.com/canonical/charmcraft

"""Tests for the Store commands (code in store/__init__.py)."""

import base64
import datetime
import platform
import sys
import zipfile
from argparse import ArgumentParser, Namespace
from unittest.mock import ANY, MagicMock, Mock, call, patch

import dateutil.parser
import pydantic
import pytest
import yaml
from craft_cli import CraftError
from craft_store.errors import CredentialsUnavailable, StoreServerError

from charmcraft import const
from charmcraft.cmdbase import JSON_FORMAT
from charmcraft.commands.store import (
    CloseCommand,
    CreateLibCommand,
    EntityType,
    ListLibCommand,
    ListNamesCommand,
    ListResourcesCommand,
    ListRevisionsCommand,
    LoginCommand,
    LogoutCommand,
    PublishLibCommand,
    RegisterBundleNameCommand,
    RegisterCharmNameCommand,
    ReleaseCommand,
    StatusCommand,
    UnregisterNameCommand,
    UploadCommand,
    UploadResourceCommand,
    WhoamiCommand,
    get_name_from_zip,
)
from charmcraft.main import ArgumentParsingError
from charmcraft.models.charmcraft import CharmhubConfig
from charmcraft.store.models import (
    Account,
    Base,
    Channel,
    Entity,
    Error,
    Library,
    MacaroonInfo,
    Package,
    RegistryCredentials,
    Release,
    Resource,
    Revision,
    Uploaded,
)
from charmcraft.utils import (
    ResourceOption,
    SingleOptionEnsurer,
    get_templates_environment,
    useful_filepath,
)
from tests import factory

# used a lot!
noargs = Namespace()

# used to flag defaults when None is a real option
DEFAULT = object()


def _fake_response(status_code, reason=None, json=None):
    response = Mock(spec="requests.Response")
    response.status_code = status_code
    response.ok = status_code == 200
    response.reason = reason
    if json is not None:
        response.json = Mock(return_value=json)
    return response


@pytest.fixture()
def store_mock():
    """The fixture to fake the store layer in all the tests."""
    store_mock = MagicMock()

    def validate_params(config, ephemeral=False, needs_auth=True):
        """Check that the store received the Charmhub configuration and ephemeral flag."""
        assert config == CharmhubConfig()
        assert isinstance(ephemeral, bool)
        assert isinstance(needs_auth, bool)
        return store_mock

    with patch("charmcraft.commands.store.Store", validate_params):
        yield store_mock


@pytest.fixture()
def add_cleanup():
    """Generic cleaning helper."""
    to_cleanup = []

    def f(func, *args, **kwargs):
        """Store the cleaning actions for later."""
        to_cleanup.append((func, args, kwargs))

    yield f

    for func, args, kwargs in to_cleanup:
        func(*args, **kwargs)


# -- tests for auth commands

LOGIN_OPTIONS = dict.fromkeys(["export", "charm", "bundle", "permission", "channel", "ttl"])


def test_login_simple(emitter, store_mock, config):
    """Simple login case."""
    store_mock.whoami.return_value = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=None,
        packages=None,
    )

    args = Namespace(**LOGIN_OPTIONS)
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(),
        call.whoami(),
    ]
    emitter.assert_message("Logged in as 'jdoe'.")


def test_login_exporting(emitter, store_mock, config, tmp_path):
    """Login with exported credentials."""
    acquired_credentials = "super secret stuff"
    store_mock.login.return_value = acquired_credentials

    credentials_file = tmp_path / "somefile.txt"
    args = Namespace(**LOGIN_OPTIONS)
    args.export = credentials_file
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(),
    ]
    emitter.assert_message(f"Login successful. Credentials exported to {str(credentials_file)!r}.")
    assert credentials_file.read_text() == acquired_credentials


@pytest.mark.parametrize("rest_option", ["charm", "bundle", "permission", "channel", "ttl"])
def test_login_restrictions_without_export(emitter, store_mock, config, tmp_path, rest_option):
    """Login restrictions are not allowed if export option is not used."""
    args = Namespace(**LOGIN_OPTIONS)
    setattr(args, rest_option, "used")
    with pytest.raises(ArgumentParsingError) as cm:
        LoginCommand(config).run(args)
    assert str(cm.value) == (
        "The restrictive options 'bundle', 'channel', 'charm', 'permission' or 'ttl' "
        "can only be used when credentials are exported."
    )


def test_login_restricting_ttl(emitter, store_mock, config, tmp_path):
    """Login with a TTL restriction."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.ttl = 1000
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(ttl=1000),
    ]


def test_login_restricting_channels(emitter, store_mock, config, tmp_path):
    """Login with channels restriction."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.channel = ["edge", "beta"]
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(channels=["edge", "beta"]),
    ]


def test_login_restricting_permissions(emitter, store_mock, config, tmp_path):
    """Login with permissions restriction."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.permission = ["package-view-metadata", "package-manage-metadata"]
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(permissions=["package-view-metadata", "package-manage-metadata"]),
    ]


def test_login_restricting_permission_invalid(emitter, store_mock, config, tmp_path):
    """Login with a permission restriction that is not valid."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.permission = ["absolute-power", "package-manage-metadata", "crazy-stuff"]
    with pytest.raises(CraftError) as cm:
        LoginCommand(config).run(args)

    assert str(cm.value) == "Invalid permission: 'absolute-power', 'crazy-stuff'."
    assert cm.value.details == (
        "Explore the documentation to learn about valid permissions: "
        "https://juju.is/docs/sdk/remote-env-auth"
    )


def test_login_restricting_charms(emitter, store_mock, config, tmp_path):
    """Login with charms restriction."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.charm = ["charm1", "charm2"]
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(charms=["charm1", "charm2"]),
    ]


def test_login_restricting_bundles(emitter, store_mock, config, tmp_path):
    """Login with bundles restriction."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(**LOGIN_OPTIONS)
    args.export = tmp_path / "somefile.txt"
    args.bundle = ["bundle1", "bundle2"]
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(bundles=["bundle1", "bundle2"]),
    ]


def test_login_restriction_mix(emitter, store_mock, config, tmp_path):
    """Valid case combining several restrictions."""
    store_mock.login.return_value = "super secret stuff"

    args = Namespace(
        export=tmp_path / "somefile.txt",
        charm=["mycharm"],
        bundle=None,
        permission=["package-view", "package-manage"],
        channel=["edge"],
        ttl=259200,
    )
    LoginCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.login(
            ttl=259200,
            channels=["edge"],
            charms=["mycharm"],
            permissions=["package-view", "package-manage"],
        ),
    ]


def test_logout(emitter, store_mock, config):
    """Simple logout case."""
    LogoutCommand(config).run(noargs)

    assert store_mock.mock_calls == [
        call.logout(),
    ]
    emitter.assert_message("Charmhub token cleared.")


def test_logout_but_not_logged_in(emitter, store_mock, config):
    """Simple logout case."""
    store_mock.logout.side_effect = CredentialsUnavailable(
        application="charmcraft", host="api.charmcraft.io"
    )

    LogoutCommand(config).run(noargs)

    assert store_mock.mock_calls == [
        call.logout(),
    ]
    emitter.assert_message("You are not logged in to Charmhub.")


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_whoami(emitter, store_mock, config, formatted):
    """Simple whoami case."""
    store_response = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=None,
        packages=None,
    )
    store_mock.whoami.return_value = store_response

    args = Namespace(format=formatted)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    if formatted:
        expected = {
            "logged": True,
            "name": "John Doe",
            "username": "jdoe",
            "id": "dlq8hl8hd8qhdl3lhl",
            "permissions": ["perm1", "perm2"],
        }
        emitter.assert_json_output(expected)
    else:
        expected = [
            "name: John Doe",
            "username: jdoe",
            "id: dlq8hl8hd8qhdl3lhl",
            "permissions:",
            "- perm1",
            "- perm2",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_whoami_but_not_logged_in(emitter, store_mock, config, formatted):
    """Whoami when not logged."""
    store_mock.whoami.side_effect = CredentialsUnavailable(
        application="charmcraft", host="api.charmcraft.io"
    )

    args = Namespace(format=formatted)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    if formatted:
        expected = {
            "logged": False,
        }
        emitter.assert_json_output(expected)
    else:
        emitter.assert_message("You are not logged in to Charmhub.")


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_whoami_with_channels(emitter, store_mock, config, formatted):
    """Whoami with channel attenuations."""
    store_response = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=["edge", "beta"],
        packages=None,
    )
    store_mock.whoami.return_value = store_response

    args = Namespace(format=formatted)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    if formatted:
        expected = {
            "logged": True,
            "name": "John Doe",
            "username": "jdoe",
            "id": "dlq8hl8hd8qhdl3lhl",
            "permissions": ["perm1", "perm2"],
            "channels": ["edge", "beta"],
        }
        emitter.assert_json_output(expected)
    else:
        expected = [
            "name: John Doe",
            "username: jdoe",
            "id: dlq8hl8hd8qhdl3lhl",
            "permissions:",
            "- perm1",
            "- perm2",
            "channels:",
            "- edge",
            "- beta",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_whoami_with_charms(emitter, store_mock, config, formatted):
    """Whoami with charms attenuations."""
    store_response = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=None,
        packages=[
            Package(type="charm", name="charmname1", id=None),
            Package(type="charm", name=None, id="charmid2"),
        ],
    )
    store_mock.whoami.return_value = store_response

    args = Namespace(format=formatted)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    if formatted:
        expected = {
            "logged": True,
            "name": "John Doe",
            "username": "jdoe",
            "id": "dlq8hl8hd8qhdl3lhl",
            "permissions": ["perm1", "perm2"],
            "charms": [{"name": "charmname1"}, {"id": "charmid2"}],
        }
        emitter.assert_json_output(expected)
    else:
        expected = [
            "name: John Doe",
            "username: jdoe",
            "id: dlq8hl8hd8qhdl3lhl",
            "permissions:",
            "- perm1",
            "- perm2",
            "charms:",
            "- name: charmname1",
            "- id: charmid2",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_whoami_with_bundles(emitter, store_mock, config, formatted):
    """Whoami with bundles attenuations."""
    store_response = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=None,
        packages=[
            Package(type="bundle", name="bundlename1", id=None),
            Package(type="bundle", name=None, id="bundleid2"),
        ],
    )
    store_mock.whoami.return_value = store_response

    args = Namespace(format=formatted)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    if formatted:
        expected = {
            "logged": True,
            "name": "John Doe",
            "username": "jdoe",
            "id": "dlq8hl8hd8qhdl3lhl",
            "permissions": ["perm1", "perm2"],
            "bundles": [{"name": "bundlename1"}, {"id": "bundleid2"}],
        }
        emitter.assert_json_output(expected)
    else:
        expected = [
            "name: John Doe",
            "username: jdoe",
            "id: dlq8hl8hd8qhdl3lhl",
            "permissions:",
            "- perm1",
            "- perm2",
            "bundles:",
            "- name: bundlename1",
            "- id: bundleid2",
        ]
        emitter.assert_messages(expected)


def test_whoami_comprehensive(emitter, store_mock, config):
    """Whoami with ALL attenuations."""
    store_response = MacaroonInfo(
        account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"),
        permissions=["perm1", "perm2"],
        channels=["edge", "beta"],
        packages=[
            Package(type="charm", name="charmname1", id=None),
            Package(type="charm", name=None, id="charmid2"),
            Package(type="bundle", name="bundlename", id=None),
        ],
    )
    store_mock.whoami.return_value = store_response

    args = Namespace(format=False)
    WhoamiCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.whoami(),
    ]
    expected = [
        "name: John Doe",
        "username: jdoe",
        "id: dlq8hl8hd8qhdl3lhl",
        "permissions:",
        "- perm1",
        "- perm2",
        "charms:",
        "- name: charmname1",
        "- id: charmid2",
        "bundles:",
        "- name: bundlename",
        "channels:",
        "- edge",
        "- beta",
    ]
    emitter.assert_messages(expected)


# -- tests for name-related commands


def test_register_charm_name(emitter, store_mock, config):
    """Simple register_name case for a charm."""
    args = Namespace(name="testname")
    RegisterCharmNameCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.register_name("testname", EntityType.charm),
    ]
    expected = "You are now the publisher of charm 'testname' in Charmhub."
    emitter.assert_message(expected)


def test_register_bundle_name(emitter, store_mock, config):
    """Simple register_name case for a bundl."""
    args = Namespace(name="testname")
    RegisterBundleNameCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.register_name("testname", EntityType.bundle),
    ]
    expected = "You are now the publisher of bundle 'testname' in Charmhub."
    emitter.assert_message(expected)


def test_unregister_name(emitter, store_mock, config):
    """Simple name unregsitration name."""
    args = Namespace(name="testname")
    UnregisterNameCommand(config).run(args)

    assert store_mock.mock_calls == [call.unregister_name("testname")]
    emitter.assert_message("Name 'testname' has been removed from Charmhub.")


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_list_registered_empty(emitter, store_mock, config, formatted):
    """List registered with empty response."""
    store_response = []
    store_mock.list_registered_names.return_value = store_response

    args = Namespace(format=formatted, include_collaborations=None)
    ListNamesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_registered_names(include_collaborations=None),
    ]
    if formatted:
        emitter.assert_json_output([])
    else:
        expected = "No charms or bundles registered."
        emitter.assert_message(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_list_registered_one_private(emitter, store_mock, config, formatted):
    """List registered with one private item in the response."""
    store_response = [
        Entity(
            entity_type="charm",
            name="charm",
            private=True,
            status="status",
            publisher_display_name="J. Doe",
        ),
    ]
    store_mock.list_registered_names.return_value = store_response

    args = Namespace(format=formatted, include_collaborations=None)
    ListNamesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_registered_names(include_collaborations=None),
    ]
    expected = [
        "Name    Type    Visibility    Status",
        "charm   charm   private       status",
    ]
    if formatted:
        expected = [
            {
                "name": "charm",
                "type": "charm",
                "visibility": "private",
                "status": "status",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_list_registered_one_public(emitter, store_mock, config, formatted):
    """List registered with one public item in the response."""
    store_response = [
        Entity(
            entity_type="charm",
            name="charm",
            private=False,
            status="status",
            publisher_display_name="J. Doe",
        ),
    ]
    store_mock.list_registered_names.return_value = store_response

    args = Namespace(format=formatted, include_collaborations=None)
    ListNamesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_registered_names(include_collaborations=None),
    ]
    expected = [
        "Name    Type    Visibility    Status",
        "charm   charm   public        status",
    ]
    if formatted:
        expected = [
            {
                "name": "charm",
                "type": "charm",
                "visibility": "public",
                "status": "status",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_list_registered_several(emitter, store_mock, config, formatted):
    """List registered with several itemsssssssss in the response."""
    store_response = [
        Entity(
            entity_type="charm",
            name="charm1",
            private=True,
            status="simple status",
            publisher_display_name="J. Doe",
        ),
        Entity(
            entity_type="charm",
            name="charm2-long-name",
            private=False,
            status="other",
            publisher_display_name="J. Doe",
        ),
        Entity(
            entity_type="charm",
            name="charm3",
            private=True,
            status="super long status",
            publisher_display_name="J. Doe",
        ),
        Entity(
            entity_type="bundle",
            name="somebundle",
            private=False,
            status="bundle status",
            publisher_display_name="J. Doe",
        ),
    ]
    store_mock.list_registered_names.return_value = store_response

    args = Namespace(format=formatted, include_collaborations=None)
    ListNamesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_registered_names(include_collaborations=None),
    ]
    if formatted:
        expected = [
            {
                "name": "charm1",
                "type": "charm",
                "visibility": "private",
                "status": "simple status",
            },
            {
                "name": "charm2-long-name",
                "type": "charm",
                "visibility": "public",
                "status": "other",
            },
            {
                "name": "charm3",
                "type": "charm",
                "visibility": "private",
                "status": "super long status",
            },
            {
                "name": "somebundle",
                "type": "bundle",
                "visibility": "public",
                "status": "bundle status",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Name              Type    Visibility    Status",
            "charm1            charm   private       simple status",
            "charm2-long-name  charm   public        other",
            "charm3            charm   private       super long status",
            "somebundle        bundle  public        bundle status",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_list_registered_with_collaborations(emitter, store_mock, config, formatted):
    """List registered with collaborations flag."""
    store_response = [
        Entity(
            entity_type="charm",
            name="charm1",
            private=True,
            status="simple status",
            publisher_display_name="J. Doe",
        ),
        Entity(
            entity_type="bundle",
            name="somebundle",
            private=False,
            status="bundle status",
            publisher_display_name="Ms. Bundle Publisher",
        ),
    ]
    store_mock.list_registered_names.return_value = store_response

    args = Namespace(format=formatted, include_collaborations=True)
    ListNamesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_registered_names(include_collaborations=True),
    ]
    if formatted:
        expected = [
            {
                "name": "charm1",
                "type": "charm",
                "visibility": "private",
                "status": "simple status",
                "publisher": "J. Doe",
            },
            {
                "name": "somebundle",
                "type": "bundle",
                "visibility": "public",
                "status": "bundle status",
                "publisher": "Ms. Bundle Publisher",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Name        Type    Visibility    Status         Publisher",
            "charm1      charm   private       simple status  J. Doe",
            "somebundle  bundle  public        bundle status  Ms. Bundle Publisher",
        ]
        emitter.assert_messages(expected)


# -- tests for upload command


def _build_zip_with_yaml(zippath, filename, *, content=None, raw_yaml=None):
    """Create a yaml named 'filename' with given content, inside a zip file in 'zippath'."""
    if raw_yaml is None:
        raw_yaml = yaml.dump(content).encode("ascii")
    with zipfile.ZipFile(str(zippath), "w") as zf:
        zf.writestr(filename, raw_yaml)


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_get_name_bad_zip(tmp_path):
    """Get the name from a bad zip file."""
    bad_zip = tmp_path / "badstuff.zip"
    bad_zip.write_text("I'm not really a zip file")

    with pytest.raises(CraftError) as cm:
        get_name_from_zip(bad_zip)
    assert str(cm.value) == f"Cannot open '{bad_zip}' (bad zip file)."


def test_get_name_charm_ok(tmp_path):
    """Get the name from a charm file, all ok."""
    test_zip = tmp_path / "some.zip"
    test_name = "whatever"
    _build_zip_with_yaml(test_zip, const.METADATA_FILENAME, content={"name": test_name})

    name = get_name_from_zip(test_zip)
    assert name == test_name


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@pytest.mark.parametrize(
    "yaml_content",
    [
        b"=",  # invalid yaml
        b"foo: bar",  # missing 'name'
    ],
)
def test_get_name_charm_bad_metadata(tmp_path, yaml_content):
    """Get the name from a charm file, but with a wrong metadata.yaml."""
    bad_zip = tmp_path / "badstuff.zip"
    _build_zip_with_yaml(bad_zip, const.METADATA_FILENAME, raw_yaml=yaml_content)

    with pytest.raises(CraftError) as cm:
        get_name_from_zip(bad_zip)
    assert str(cm.value) == (
        "Bad 'metadata.yaml' file inside charm zip "
        f"'{bad_zip}': must be a valid YAML with a 'name' key."
    )
    assert cm.value.__cause__ is not None


def test_get_name_bundle_ok(tmp_path):
    """Get the name from a bundle file, all ok."""
    test_zip = tmp_path / "some.zip"
    test_name = "whatever"
    _build_zip_with_yaml(test_zip, const.BUNDLE_FILENAME, content={"name": test_name})

    name = get_name_from_zip(test_zip)
    assert name == test_name


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@pytest.mark.parametrize(
    "yaml_content",
    [
        b"=",  # invalid yaml
        b"foo: bar",  # missing 'name'
    ],
)
def test_get_name_bundle_bad_data(tmp_path, yaml_content):
    """Get the name from a bundle file, but with a bad bundle.yaml."""
    bad_zip = tmp_path / "badstuff.zip"
    _build_zip_with_yaml(bad_zip, const.BUNDLE_FILENAME, raw_yaml=yaml_content)

    with pytest.raises(CraftError) as cm:
        get_name_from_zip(bad_zip)
    assert str(cm.value) == (
        f"Bad 'bundle.yaml' file inside bundle zip '{bad_zip}': "
        "must be a valid YAML with a 'name' key."
    )
    assert cm.value.__cause__ is not None


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_get_name_nor_charm_nor_bundle(tmp_path):
    """Get the name from a zip that has no metadata.yaml nor bundle.yaml."""
    bad_zip = tmp_path / "bad-stuff.zip"
    _build_zip_with_yaml(bad_zip, "whatever.yaml", content={})

    with pytest.raises(CraftError) as cm:
        get_name_from_zip(bad_zip)
    assert str(cm.value) == (
        f"The indicated zip file '{bad_zip}' is not a charm ('metadata.yaml' not found) nor a bundle "
        "('bundle.yaml' not found)."
    )


def test_upload_parameters_filepath_type(config):
    """The filepath parameter implies a set of validations."""
    cmd = UploadCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    (action,) = (action for action in parser._actions if action.dest == "filepath")
    assert action.type is useful_filepath


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_upload_call_ok(emitter, store_mock, config, tmp_path, formatted):
    """Simple upload, success result."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(filepath=test_charm, release=[], name=None, format=formatted)
    retcode = UploadCommand(config).run(args)
    assert retcode == 0

    assert store_mock.mock_calls == [call.upload("mycharm", test_charm)]
    if formatted:
        expected = {"revision": 7}
        emitter.assert_json_output(expected)
    else:
        expected = "Revision 7 of 'mycharm' created"
        emitter.assert_message(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_upload_call_error(emitter, store_mock, config, tmp_path, formatted):
    """Simple upload but with a response indicating an error."""
    errors = [
        Error(message="text 1", code="missing-stuff"),
        Error(message="other long error text", code="broken"),
    ]
    store_response = Uploaded(ok=False, status=400, revision=None, errors=errors)
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(filepath=test_charm, release=[], name=None, format=formatted)
    retcode = UploadCommand(config).run(args)
    assert retcode == 1

    assert store_mock.mock_calls == [call.upload("mycharm", test_charm)]
    if formatted:
        expected = {
            "errors": [
                {"code": "missing-stuff", "message": "text 1"},
                {"code": "broken", "message": "other long error text"},
            ]
        }
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Upload failed with status 400:",
            "- missing-stuff: text 1",
            "- broken: other long error text",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_upload_call_login_expired(mocker, monkeypatch, config, tmp_path, formatted):
    """Simple upload but login expired."""
    monkeypatch.setenv(const.ALTERNATE_AUTH_ENV_VAR, base64.b64encode(b"credentials").decode())
    mock_whoami = mocker.patch("craft_store.base_client.HTTPClient.request")
    push_file_mock = mocker.patch("charmcraft.store.Client.push_file")

    mock_whoami.side_effect = StoreServerError(_fake_response(401, json={}))

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(filepath=test_charm, release=[], name=None, format=formatted)

    with pytest.raises(CraftError) as cm:
        UploadCommand(config).run(args)
    assert str(cm.value) == (
        "Provided credentials are no longer valid for Charmhub. Regenerate them and try again."
    )
    assert not push_file_mock.called


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_upload_call_ok_including_release(emitter, store_mock, config, tmp_path, formatted):
    """Upload with a release included, success result."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(
        filepath=test_charm, release=["edge"], resource=[], name=None, format=formatted
    )
    UploadCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.upload("mycharm", test_charm),
        call.release("mycharm", 7, ["edge"], []),
    ]
    if formatted:
        expected = {"revision": 7}
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision 7 of 'mycharm' created",
            "Revision released to edge",
        ]
        emitter.assert_messages(expected)


def test_upload_call_ok_including_release_multiple(emitter, store_mock, config, tmp_path):
    """Upload with release to two channels included, success result."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(
        filepath=test_charm, release=["edge", "stable"], resource=[], name=None, format=False
    )
    UploadCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.upload("mycharm", test_charm),
        call.release("mycharm", 7, ["edge", "stable"], []),
    ]
    expected = [
        "Revision 7 of 'mycharm' created",
        "Revision released to edge, stable",
    ]
    emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_upload_including_release_with_resources(emitter, store_mock, config, tmp_path, formatted):
    """Releasing with resources."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    r1 = ResourceOption(name="foo", revision=3)
    r2 = ResourceOption(name="bar", revision=17)
    args = Namespace(
        filepath=test_charm, release=["edge"], resource=[r1, r2], name=None, format=formatted
    )
    UploadCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.upload("mycharm", test_charm),
        call.release("mycharm", 7, ["edge"], [r1, r2]),
    ]
    if formatted:
        expected = {"revision": 7}
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision 7 of 'mycharm' created",
            "Revision released to edge (attaching resources: 'foo' r3, 'bar' r17)",
        ]
        emitter.assert_messages(expected)


def test_upload_options_resource(config):
    """The --resource option implies a set of validations."""
    cmd = UploadCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    (action,) = (action for action in parser._actions if action.dest == "resource")
    assert isinstance(action.type, ResourceOption)


def test_upload_call_error_including_release(emitter, store_mock, config, tmp_path):
    """Upload with a realsea but the upload went wrong, so no release."""
    errors = [Error(message="text", code="problem")]
    store_response = Uploaded(ok=False, status=400, revision=None, errors=errors)
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(filepath=test_charm, release=["edge"], name=None, format=False)
    UploadCommand(config).run(args)

    # check the upload was attempted, but not the release!
    assert store_mock.mock_calls == [call.upload("mycharm", test_charm)]


def test_upload_with_different_name_than_in_metadata(emitter, store_mock, config, tmp_path):
    """Simple upload to a specific name different from metadata, success result."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload.return_value = store_response

    test_charm = tmp_path / "mystuff.charm"
    _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"})
    args = Namespace(filepath=test_charm, release=[], name="foo-mycharm", format=False)
    retcode = UploadCommand(config).run(args)
    assert retcode == 0

    assert store_mock.mock_calls == [call.upload("foo-mycharm", test_charm)]
    expected = "Revision 7 of 'foo-mycharm' created"
    emitter.assert_message(expected)


# -- tests for list revisions command


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_simple(emitter, store_mock, config, formatted):
    """Happy path of one result from the Store."""
    bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")]
    store_response = [
        Revision(
            revision=1,
            version="v1",
            created_at=datetime.datetime(2020, 7, 3, 20, 30, 40),
            status="accepted",
            errors=[],
            bases=bases,
        ),
    ]
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_revisions("testcharm"),
    ]
    if formatted:
        expected = [
            {
                "revision": 1,
                "version": "v1",
                "created_at": "2020-07-03T20:30:40Z",
                "status": "accepted",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision    Version    Created at            Status",
            "1           v1         2020-07-03T20:30:40Z  accepted",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_empty(emitter, store_mock, config, formatted):
    """No results from the store."""
    store_response = []
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    if formatted:
        emitter.assert_json_output([])
    else:
        expected = [
            "No revisions found.",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_ordered_by_revision(emitter, store_mock, config, formatted):
    """Results are presented ordered by revision in the table."""
    # three Revisions with all values weirdly similar, the only difference is revision, so
    # we really assert later that it was used for ordering
    tstamp = datetime.datetime(2020, 7, 3, 20, 30, 40)
    bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")]
    store_response = [
        Revision(
            revision=1,
            version="v1",
            created_at=tstamp,
            status="accepted",
            errors=[],
            bases=bases,
        ),
        Revision(
            revision=3,
            version="v1",
            created_at=tstamp,
            status="accepted",
            errors=[],
            bases=bases,
        ),
        Revision(
            revision=2,
            version="v1",
            created_at=tstamp,
            status="accepted",
            errors=[],
            bases=bases,
        ),
    ]
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    if formatted:
        expected = [
            {
                "revision": 3,
                "version": "v1",
                "created_at": "2020-07-03T20:30:40Z",
                "status": "accepted",
            },
            {
                "revision": 2,
                "version": "v1",
                "created_at": "2020-07-03T20:30:40Z",
                "status": "accepted",
            },
            {
                "revision": 1,
                "version": "v1",
                "created_at": "2020-07-03T20:30:40Z",
                "status": "accepted",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision    Version    Created at            Status",
            "3           v1         2020-07-03T20:30:40Z  accepted",
            "2           v1         2020-07-03T20:30:40Z  accepted",
            "1           v1         2020-07-03T20:30:40Z  accepted",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_version_null(emitter, store_mock, config, formatted):
    """Support the case of version being None."""
    bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")]
    store_response = [
        Revision(
            revision=1,
            version=None,
            created_at=datetime.datetime(2020, 7, 3, 20, 30, 40),
            status="accepted",
            errors=[],
            bases=bases,
        ),
    ]
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    if formatted:
        expected = [
            {
                "revision": 1,
                "version": None,
                "created_at": "2020-07-03T20:30:40Z",
                "status": "accepted",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision    Version    Created at            Status",
            "1                      2020-07-03T20:30:40Z  accepted",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_errors_simple(emitter, store_mock, config, formatted):
    """Support having one case with a simple error."""
    bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")]
    store_response = [
        Revision(
            revision=1,
            version=None,
            created_at=datetime.datetime(2020, 7, 3, 20, 30, 40),
            status="rejected",
            errors=[Error(message="error text", code="broken")],
            bases=bases,
        ),
    ]
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    if formatted:
        expected = [
            {
                "revision": 1,
                "version": None,
                "created_at": "2020-07-03T20:30:40Z",
                "status": "rejected",
                "errors": [{"message": "error text", "code": "broken"}],
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision    Version    Created at            Status",
            "1                      2020-07-03T20:30:40Z  rejected: error text [broken]",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_revisions_errors_multiple(emitter, store_mock, config, formatted):
    """Support having one case with multiple errors."""
    bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")]
    store_response = [
        Revision(
            revision=1,
            version=None,
            created_at=datetime.datetime(2020, 7, 3, 20, 30, 40),
            status="rejected",
            errors=[
                Error(message="text 1", code="missing-stuff"),
                Error(message="other long error text", code="broken"),
            ],
            bases=bases,
        ),
    ]
    store_mock.list_revisions.return_value = store_response

    args = Namespace(name="testcharm", format=formatted)
    ListRevisionsCommand(config).run(args)

    if formatted:
        expected = [
            {
                "revision": 1,
                "version": None,
                "created_at": "2020-07-03T20:30:40Z",
                "status": "rejected",
                "errors": [
                    {"message": "text 1", "code": "missing-stuff"},
                    {"message": "other long error text", "code": "broken"},
                ],
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Revision    Version    Created at            Status",
            "1                      2020-07-03T20:30:40Z  rejected: text 1 [missing-stuff]; other long error text [broken]",
        ]
        emitter.assert_messages(expected)


# -- tests for the release command


def test_release_simple_ok(emitter, store_mock, config):
    """Simple case of releasing a revision ok."""
    channels = ["somechannel"]
    args = Namespace(name="testcharm", revision=7, channel=channels, resource=[])
    ReleaseCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.release("testcharm", 7, channels, []),
    ]

    expected = "Revision 7 of 'testcharm' released to somechannel"
    emitter.assert_message(expected)


def test_release_simple_multiple_channels(emitter, store_mock, config):
    """Releasing to multiple channels."""
    args = Namespace(
        name="testcharm",
        revision=7,
        channel=["channel1", "channel2", "channel3"],
        resource=[],
    )
    ReleaseCommand(config).run(args)

    expected = "Revision 7 of 'testcharm' released to channel1, channel2, channel3"
    emitter.assert_message(expected)


def test_release_including_resources(emitter, store_mock, config):
    """Releasing with resources."""
    r1 = ResourceOption(name="foo", revision=3)
    r2 = ResourceOption(name="bar", revision=17)
    args = Namespace(name="testcharm", revision=7, channel=["testchannel"], resource=[r1, r2])
    ReleaseCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.release("testcharm", 7, ["testchannel"], [r1, r2]),
    ]

    expected = (
        "Revision 7 of 'testcharm' released to testchannel "
        "(attaching resources: 'foo' r3, 'bar' r17)"
    )
    emitter.assert_message(expected)


def test_release_options_resource(config):
    """The --resource-file option implies a set of validations."""
    cmd = ReleaseCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    (action,) = (action for action in parser._actions if action.dest == "resource")
    assert isinstance(action.type, ResourceOption)


@pytest.mark.parametrize(
    ("sysargs", "expected_parsed"),
    [
        (
            ["somename", "--channel=stable", "--revision=33"],
            ("somename", 33, ["stable"], []),
        ),
        (
            ["somename", "--channel=stable", "-r", "33"],
            ("somename", 33, ["stable"], []),
        ),
        (
            ["somename", "-c", "stable", "--revision=33"],
            ("somename", 33, ["stable"], []),
        ),
        (
            ["-c", "stable", "--revision=33", "somename"],
            ("somename", 33, ["stable"], []),
        ),
        (
            ["-c", "beta", "--revision=1", "--channel=edge", "name"],
            ("name", 1, ["beta", "edge"], []),
        ),
        (
            ["somename", "-c=beta", "-r=3", "--resource=foo:15"],
            ("somename", 3, ["beta"], [ResourceOption("foo", 15)]),
        ),
        (
            ["somename", "-c=beta", "-r=3", "--resource=foo:15", "--resource=bar:99"],
            (
                "somename",
                3,
                ["beta"],
                [ResourceOption("foo", 15), ResourceOption("bar", 99)],
            ),
        ),
    ],
)
def test_release_parameters_ok(config, sysargs, expected_parsed):
    """Control of different combination of valid parameters."""
    cmd = ReleaseCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    try:
        args = parser.parse_args(sysargs)
    except SystemExit:
        pytest.fail(f"Parsing of {sysargs} was not ok.")
    attribs = ["name", "revision", "channel", "resource"]
    assert args == Namespace(**dict(zip(attribs, expected_parsed)))


@pytest.mark.parametrize(
    "sysargs",
    [
        ["somename", "--channel=stable", "--revision=foo"],  # revision not an int
        ["somename", "--channel=stable"],  # missing the revision
        ["somename", "--revision=1"],  # missing a channel
        [
            "somename",
            "--channel=stable",
            "--revision=1",
            "--revision=2",
        ],  # too many revisions
        ["--channel=stable", "--revision=1"],  # missing the name
        ["somename", "-c=beta", "-r=3", "--resource=foo15"],  # bad resource format
    ],
)
def test_release_parameters_bad(config, sysargs):
    """Control of different option/parameters combinations that are not valid."""
    cmd = ReleaseCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    with pytest.raises(SystemExit):
        parser.parse_args(sysargs)


# -- tests for the close command


def test_close_simple_ok(emitter, store_mock, config):
    """Simple case of closing a channel."""
    args = Namespace(name="testcharm", channel="somechannel")
    CloseCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.release("testcharm", None, ["somechannel"], []),
    ]

    expected = "Closed 'somechannel' channel for 'testcharm'."
    emitter.assert_message(expected)


# -- tests for the status command


def _build_channels(track="latest"):
    """Helper to build simple channels structure."""
    channels = []
    risks = ["stable", "candidate", "beta", "edge"]
    for risk, fback in zip(risks, [None, *risks]):
        name = "/".join((track, risk))
        fallback = None if fback is None else "/".join((track, fback))
        channels.append(Channel(name=name, fallback=fallback, track=track, risk=risk, branch=None))
    return channels


def _build_revision(revno, version):
    """Helper to build a revision."""
    return Revision(
        revision=revno,
        version=version,
        created_at=datetime.datetime(2020, 7, 3, 20, 30, 40),
        status="accepted",
        errors=[],
        bases=[Base(architecture="amd64", channel="20.04", name="ubuntu")],
    )


def _build_release(revision, channel, expires_at=None, resources=None, base=DEFAULT):
    """Helper to build a release."""
    if resources is None:
        resources = []
    if base is DEFAULT:
        base = Base(architecture="amd64", channel="20.04", name="ubuntu")
    return Release(
        revision=revision,
        channel=channel,
        expires_at=expires_at,
        resources=resources,
        base=base,
    )


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_simple_ok(emitter, store_mock, config, formatted):
    """Simple happy case of getting a status."""
    channel_map = [
        _build_release(revision=7, channel="latest/stable"),
        _build_release(revision=7, channel="latest/candidate"),
        _build_release(revision=80, channel="latest/beta"),
        _build_release(revision=156, channel="latest/edge"),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=7, version="v7"),
        _build_revision(revno=80, version="2.0"),
        _build_revision(revno=156, version="git-0db35ea1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "open",
                                "channel": "latest/stable",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/candidate",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta",
                                "version": "2.0",
                                "revision": 80,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/edge",
                                "version": "git-0db35ea1",
                                "revision": 156,
                                "resources": [],
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version       Revision",
            "latest   ubuntu 20.04 (amd64)  stable     v7            7",
            "                               candidate  v7            7",
            "                               beta       2.0           80",
            "                               edge       git-0db35ea1  156",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_empty(emitter, store_mock, config, formatted):
    """Empty response from the store."""
    store_mock.list_releases.return_value = [], [], []
    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    if formatted:
        emitter.assert_json_output({})
    else:
        expected = "Nothing has been released yet."
        emitter.assert_message(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_channels_not_released_with_fallback(emitter, store_mock, config, formatted):
    """Support gaps in channel releases, having fallbacks."""
    channel_map = [
        _build_release(revision=7, channel="latest/stable"),
        _build_release(revision=80, channel="latest/edge"),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=7, version="v7"),
        _build_revision(revno=80, version="2.0"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "open",
                                "channel": "latest/stable",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/beta",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/edge",
                                "version": "2.0",
                                "revision": 80,
                                "resources": [],
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version    Revision",
            "latest   ubuntu 20.04 (amd64)  stable     v7         7",
            "                               candidate  ↑          ↑",
            "                               beta       ↑          ↑",
            "                               edge       2.0        80",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_channels_not_released_without_fallback(emitter, store_mock, config, formatted):
    """Support gaps in channel releases, nothing released in more stable ones."""
    channel_map = [
        _build_release(revision=5, channel="latest/beta"),
        _build_release(revision=12, channel="latest/edge"),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=5, version="5.1"),
        _build_revision(revno=12, version="almostready"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "closed",
                                "channel": "latest/stable",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "closed",
                                "channel": "latest/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta",
                                "version": "5.1",
                                "revision": 5,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/edge",
                                "version": "almostready",
                                "revision": 12,
                                "resources": [],
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version      Revision",
            "latest   ubuntu 20.04 (amd64)  stable     -            -",
            "                               candidate  -            -",
            "                               beta       5.1          5",
            "                               edge       almostready  12",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_multiple_tracks(emitter, store_mock, config, formatted):
    """Support multiple tracks."""
    channel_map = [
        _build_release(revision=503, channel="latest/stable"),
        _build_release(revision=1, channel="2.0/edge"),
    ]
    channels_latest = _build_channels()
    channels_track = _build_channels(track="2.0")
    channels = channels_latest + channels_track
    revisions = [
        _build_revision(revno=503, version="7.5.3"),
        _build_revision(revno=1, version="1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "open",
                                "channel": "latest/stable",
                                "version": "7.5.3",
                                "revision": 503,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/beta",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/edge",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            },
            {
                "track": "2.0",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "closed",
                                "channel": "2.0/stable",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "closed",
                                "channel": "2.0/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "closed",
                                "channel": "2.0/beta",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "2.0/edge",
                                "version": "1",
                                "revision": 1,
                                "resources": [],
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version    Revision",
            "latest   ubuntu 20.04 (amd64)  stable     7.5.3      503",
            "                               candidate  ↑          ↑",
            "                               beta       ↑          ↑",
            "                               edge       ↑          ↑",
            "2.0      ubuntu 20.04 (amd64)  stable     -          -",
            "                               candidate  -          -",
            "                               beta       -          -",
            "                               edge       1          1",
        ]
        emitter.assert_messages(expected)


def test_status_tracks_order(emitter, store_mock, config):
    """Respect the track ordering from the store."""
    channel_map = [
        _build_release(revision=1, channel="latest/edge"),
        _build_release(revision=2, channel="aaa/edge"),
        _build_release(revision=3, channel="2.0/edge"),
        _build_release(revision=4, channel="zzz/edge"),
    ]
    channels_latest = _build_channels()
    channels_track_1 = _build_channels(track="zzz")
    channels_track_2 = _build_channels(track="2.0")
    channels_track_3 = _build_channels(track="aaa")
    channels = channels_latest + channels_track_1 + channels_track_2 + channels_track_3
    revisions = [
        _build_revision(revno=1, version="v1"),
        _build_revision(revno=2, version="v2"),
        _build_revision(revno=3, version="v3"),
        _build_revision(revno=4, version="v4"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel    Version    Revision",
        "latest   ubuntu 20.04 (amd64)  stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v1         1",
        "zzz      ubuntu 20.04 (amd64)  stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v4         4",
        "2.0      ubuntu 20.04 (amd64)  stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v3         3",
        "aaa      ubuntu 20.04 (amd64)  stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v2         2",
    ]
    emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_with_one_branch(emitter, store_mock, config, formatted):
    """Support having one branch."""
    tstamp_with_timezone = dateutil.parser.parse("2020-07-03T20:30:40Z")
    channel_map = [
        _build_release(revision=5, channel="latest/beta"),
        _build_release(
            revision=12,
            channel="latest/beta/mybranch",
            expires_at=tstamp_with_timezone,
        ),
    ]
    channels = _build_channels()
    channels.append(
        Channel(
            name="latest/beta/mybranch",
            fallback="latest/beta",
            track="latest",
            risk="beta",
            branch="mybranch",
        )
    )
    revisions = [
        _build_revision(revno=5, version="5.1"),
        _build_revision(revno=12, version="ver.12"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "closed",
                                "channel": "latest/stable",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "closed",
                                "channel": "latest/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta",
                                "version": "5.1",
                                "revision": 5,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/edge",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta/mybranch",
                                "version": "ver.12",
                                "revision": 12,
                                "resources": [],
                                "expires_at": "2020-07-03T20:30:40Z",
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel        Version    Revision    Expires at",
            "latest   ubuntu 20.04 (amd64)  stable         -          -",
            "                               candidate      -          -",
            "                               beta           5.1        5",
            "                               edge           ↑          ↑",
            "                               beta/mybranch  ver.12     12          2020-07-03T20:30:40Z",
        ]
        emitter.assert_messages(expected)


def test_status_with_multiple_branches(emitter, store_mock, config):
    """Support having multiple branches."""
    tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z")
    channel_map = [
        _build_release(revision=5, channel="latest/beta"),
        _build_release(revision=12, channel="latest/beta/branch-1", expires_at=tstamp),
        _build_release(revision=15, channel="latest/beta/branch-2", expires_at=tstamp),
    ]
    channels = _build_channels()
    channels.extend(
        [
            Channel(
                name="latest/beta/branch-1",
                fallback="latest/beta",
                track="latest",
                risk="beta",
                branch="branch-1",
            ),
            Channel(
                name="latest/beta/branch-2",
                fallback="latest/beta",
                track="latest",
                risk="beta",
                branch="branch-2",
            ),
        ]
    )
    revisions = [
        _build_revision(revno=5, version="5.1"),
        _build_revision(revno=12, version="ver.12"),
        _build_revision(revno=15, version="15.0.0"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel        Version    Revision    Expires at",
        "latest   ubuntu 20.04 (amd64)  stable         -          -",
        "                               candidate      -          -",
        "                               beta           5.1        5",
        "                               edge           ↑          ↑",
        "                               beta/branch-1  ver.12     12          2020-07-03T20:30:40Z",
        "                               beta/branch-2  15.0.0     15          2020-07-03T20:30:40Z",
    ]
    emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_with_resources(emitter, store_mock, config, formatted):
    """Support having multiple branches."""
    res1 = Resource(name="resource1", optional=True, revision=1, resource_type="file")
    res2 = Resource(name="resource2", optional=True, revision=54, resource_type="file")
    channel_map = [
        _build_release(revision=5, channel="latest/candidate", resources=[res1, res2]),
        _build_release(revision=5, channel="latest/beta", resources=[res1]),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=5, version="5.1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "closed",
                                "channel": "latest/stable",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/candidate",
                                "version": "5.1",
                                "revision": 5,
                                "resources": [
                                    {"name": "resource1", "revision": 1},
                                    {"name": "resource2", "revision": 54},
                                ],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta",
                                "version": "5.1",
                                "revision": 5,
                                "resources": [{"name": "resource1", "revision": 1}],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/edge",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version    Revision    Resources",
            "latest   ubuntu 20.04 (amd64)  stable     -          -           -",
            "                               candidate  5.1        5           resource1 (r1), resource2 (r54)",
            "                               beta       5.1        5           resource1 (r1)",
            "                               edge       ↑          ↑           ↑",
        ]
        emitter.assert_messages(expected)


def test_status_with_resources_missing_after_closed_channel(emitter, store_mock, config):
    """Specific glitch for a channel without resources after a closed one."""
    resource = Resource(name="resource", optional=True, revision=1, resource_type="file")
    channel_map = [
        _build_release(revision=5, channel="latest/stable", resources=[resource]),
        _build_release(revision=5, channel="latest/beta", resources=[]),
        _build_release(revision=5, channel="latest/edge", resources=[resource]),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=5, version="5.1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    expected = [
        "Track    Base                  Channel    Version    Revision    Resources",
        "latest   ubuntu 20.04 (amd64)  stable     5.1        5           resource (r1)",
        "                               candidate  ↑          ↑           ↑",
        "                               beta       5.1        5           -",
        "                               edge       5.1        5           resource (r1)",
    ]
    emitter.assert_messages(expected)


def test_status_with_resources_and_branches(emitter, store_mock, config):
    """Support having multiple branches."""
    tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z")
    res1 = Resource(name="testres", optional=True, revision=1, resource_type="file")
    res2 = Resource(name="testres", optional=True, revision=14, resource_type="file")
    channel_map = [
        _build_release(revision=23, channel="latest/beta", resources=[res2]),
        _build_release(
            revision=5,
            channel="latest/edge/mybranch",
            expires_at=tstamp,
            resources=[res1],
        ),
    ]
    channels = _build_channels()
    channels.append(
        Channel(
            name="latest/edge/mybranch",
            fallback="latest/edge",
            track="latest",
            risk="edge",
            branch="mybranch",
        )
    )
    revisions = [
        _build_revision(revno=5, version="5.1"),
        _build_revision(revno=23, version="7.0.0"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    expected = [
        "Track    Base                  Channel        Version    Revision    Resources      Expires at",
        "latest   ubuntu 20.04 (amd64)  stable         -          -           -",
        "                               candidate      -          -           -",
        "                               beta           7.0.0      23          testres (r14)",
        "                               edge           ↑          ↑           ↑",
        "                               edge/mybranch  5.1        5           testres (r1)   2020-07-03T20:30:40Z",
    ]
    emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_multiplebases_single_track(emitter, store_mock, config, formatted):
    """Multiple bases with one track."""
    other_base = Base(architecture="16b", channel="1", name="xz")
    channel_map = [
        _build_release(revision=7, channel="latest/stable", base=other_base),
        _build_release(revision=7, channel="latest/candidate"),
        _build_release(revision=80, channel="latest/beta", base=other_base),
        _build_release(revision=156, channel="latest/edge"),
    ]
    channels = _build_channels()
    revisions = [
        _build_revision(revno=7, version="v7"),
        _build_revision(revno=80, version="2.0"),
        _build_revision(revno=156, version="git-0db35ea1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": {
                            "name": "ubuntu",
                            "channel": "20.04",
                            "architecture": "amd64",
                        },
                        "releases": [
                            {
                                "status": "closed",
                                "channel": "latest/stable",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/candidate",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/beta",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/edge",
                                "version": "git-0db35ea1",
                                "revision": 156,
                                "resources": [],
                                "expires_at": None,
                            },
                        ],
                    },
                    {
                        "base": {
                            "name": "xz",
                            "channel": "1",
                            "architecture": "16b",
                        },
                        "releases": [
                            {
                                "status": "open",
                                "channel": "latest/stable",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/candidate",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/beta",
                                "version": "2.0",
                                "revision": 80,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/edge",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base                  Channel    Version       Revision",
            "latest   ubuntu 20.04 (amd64)  stable     -             -",
            "                               candidate  v7            7",
            "                               beta       ↑             ↑",
            "                               edge       git-0db35ea1  156",
            "         xz 1 (16b)            stable     v7            7",
            "                               candidate  ↑             ↑",
            "                               beta       2.0           80",
            "                               edge       ↑             ↑",
        ]
        emitter.assert_messages(expected)


def test_status_multiplebases_multiple_tracks(emitter, store_mock, config):
    """Multiple bases with several tracks."""
    other_base = Base(architecture="16b", channel="1", name="xz")
    channel_map = [
        _build_release(revision=7, channel="latest/stable", base=other_base),
        _build_release(revision=7, channel="latest/candidate"),
        _build_release(revision=80, channel="latest/beta", base=other_base),
        _build_release(revision=156, channel="latest/edge"),
        _build_release(revision=7, channel="2.0/stable", base=other_base),
        _build_release(revision=7, channel="2.0/candidate"),
        _build_release(revision=80, channel="2.0/beta", base=other_base),
        _build_release(revision=156, channel="2.0/edge"),
        _build_release(revision=156, channel="3.0/edge"),
    ]
    channels = _build_channels() + _build_channels(track="2.0") + _build_channels(track="3.0")
    revisions = [
        _build_revision(revno=7, version="v7"),
        _build_revision(revno=80, version="2.0"),
        _build_revision(revno=156, version="git-0db35ea1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel    Version       Revision",
        "latest   ubuntu 20.04 (amd64)  stable     -             -",
        "                               candidate  v7            7",
        "                               beta       ↑             ↑",
        "                               edge       git-0db35ea1  156",
        "         xz 1 (16b)            stable     v7            7",
        "                               candidate  ↑             ↑",
        "                               beta       2.0           80",
        "                               edge       ↑             ↑",
        "2.0      ubuntu 20.04 (amd64)  stable     -             -",
        "                               candidate  v7            7",
        "                               beta       ↑             ↑",
        "                               edge       git-0db35ea1  156",
        "         xz 1 (16b)            stable     v7            7",
        "                               candidate  ↑             ↑",
        "                               beta       2.0           80",
        "                               edge       ↑             ↑",
        "3.0      ubuntu 20.04 (amd64)  stable     -             -",
        "                               candidate  -             -",
        "                               beta       -             -",
        "                               edge       git-0db35ea1  156",
    ]
    emitter.assert_messages(expected)


def test_status_multiplebases_everything_combined(emitter, store_mock, config):
    """Validate multiple bases with several other modifiers."""
    other_base = Base(architecture="16b", channel="1", name="xz")
    tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z")
    resource = Resource(name="testres", optional=True, revision=1, resource_type="file")
    channel_map = [
        _build_release(revision=7, channel="latest/candidate"),
        _build_release(revision=156, channel="latest/edge"),
        _build_release(revision=7, channel="latest/candidate/br1", expires_at=tstamp),
        _build_release(revision=7, channel="latest/stable", base=other_base),
        _build_release(revision=80, channel="latest/beta", base=other_base),
        _build_release(
            revision=99,
            channel="latest/beta/br2",
            base=other_base,
            expires_at=tstamp,
            resources=[resource],
        ),
        _build_release(revision=7, channel="2.0/candidate"),
        _build_release(revision=80, channel="2.0/beta"),
        _build_release(revision=7, channel="2.0/stable", base=other_base),
        _build_release(revision=80, channel="2.0/edge", base=other_base),
        _build_release(revision=80, channel="2.0/edge/foobar", base=other_base, expires_at=tstamp),
    ]
    channels = _build_channels() + _build_channels(track="2.0")
    channels.extend(
        [
            Channel(
                name="latest/candidate/br1",
                fallback="latest/candidate",
                track="latest",
                risk="candidate",
                branch="br1",
            ),
            Channel(
                name="latest/beta/br2",
                fallback="latest/beta",
                track="latest",
                risk="beta",
                branch="br2",
            ),
            Channel(
                name="2.0/edge/foobar",
                fallback="2.0/edge",
                track="2.0",
                risk="edge",
                branch="foobar",
            ),
        ]
    )
    revisions = [
        _build_revision(revno=7, version="v7"),
        _build_revision(revno=80, version="2.0"),
        _build_revision(revno=156, version="git-0db35ea1"),
        _build_revision(revno=99, version="weird"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel        Version       Revision    Resources     Expires at",
        "latest   ubuntu 20.04 (amd64)  stable         -             -           -",
        "                               candidate      v7            7           -",
        "                               beta           ↑             ↑           ↑",
        "                               edge           git-0db35ea1  156         -",
        "                               candidate/br1  v7            7           -             2020-07-03T20:30:40Z",
        "         xz 1 (16b)            stable         v7            7           -",
        "                               candidate      ↑             ↑           ↑",
        "                               beta           2.0           80          -",
        "                               edge           ↑             ↑           ↑",
        "                               beta/br2       weird         99          testres (r1)  2020-07-03T20:30:40Z",
        "2.0      ubuntu 20.04 (amd64)  stable         -             -           -",
        "                               candidate      v7            7           -",
        "                               beta           2.0           80          -",
        "                               edge           ↑             ↑           ↑",
        "         xz 1 (16b)            stable         v7            7           -",
        "                               candidate      ↑             ↑           ↑",
        "                               beta           ↑             ↑           ↑",
        "                               edge           2.0           80          -",
        "                               edge/foobar    2.0           80          -             2020-07-03T20:30:40Z",
    ]
    emitter.assert_messages(expected)


def test_status_multiplebases_multiplebranches(emitter, store_mock, config):
    """Validate specific mix between bases and branches.

    This exposes a bug in Charmhub: https://bugs.launchpad.net/snapstore-server/+bug/1994613
    """
    other_base = Base(architecture="i386", channel="20.04", name="ubuntu")
    tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z")
    channel_map = [
        _build_release(revision=1, channel="latest/edge"),
        _build_release(revision=1, channel="latest/edge/fix", expires_at=tstamp),
        _build_release(revision=1, channel="latest/edge", base=other_base),
        _build_release(revision=1, channel="latest/edge/fix", expires_at=tstamp, base=other_base),
    ]
    channels = _build_channels()
    extra_channel = Channel(
        name="latest/edge/fix",
        fallback="latest/edge",
        track="latest",
        risk="edge",
        branch="fix",
    )
    channels.extend([extra_channel, extra_channel])  # twice!! this is the reported bug in Charmhub
    revisions = [
        _build_revision(revno=1, version="v1"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel    Version    Revision    Expires at",
        "latest   ubuntu 20.04 (amd64)  stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v1         1",
        "                               edge/fix   v1         1           2020-07-03T20:30:40Z",
        "         ubuntu 20.04 (i386)   stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       v1         1",
        "                               edge/fix   v1         1           2020-07-03T20:30:40Z",
    ]
    emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_status_with_base_in_none(emitter, store_mock, config, formatted):
    """Support the case of base being None."""
    channel_map = [
        _build_release(revision=7, channel="latest/stable", base=None),
        _build_release(revision=7, channel="latest/candidate", base=None),
    ]
    channels = _build_channels()
    revisions = [_build_revision(revno=7, version="v7")]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=formatted)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    if formatted:
        expected = [
            {
                "track": "latest",
                "mappings": [
                    {
                        "base": None,
                        "releases": [
                            {
                                "status": "open",
                                "channel": "latest/stable",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "open",
                                "channel": "latest/candidate",
                                "version": "v7",
                                "revision": 7,
                                "resources": [],
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/beta",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                            {
                                "status": "tracking",
                                "channel": "latest/edge",
                                "version": None,
                                "revision": None,
                                "resources": None,
                                "expires_at": None,
                            },
                        ],
                    },
                ],
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Track    Base    Channel    Version    Revision",
            "latest   -       stable     v7         7",
            "                 candidate  v7         7",
            "                 beta       ↑          ↑",
            "                 edge       ↑          ↑",
        ]
        emitter.assert_messages(expected)


def test_status_unreleased_track(emitter, store_mock, config):
    """The package has a track, but nothing is released to it."""
    channel_map = [
        _build_release(revision=5, channel="latest/stable"),
    ]
    channels_latest = _build_channels()
    channels_track = _build_channels(track="2.0")
    channels = channels_latest + channels_track
    revisions = [
        _build_revision(revno=5, version="7.5.3"),
    ]
    store_mock.list_releases.return_value = (channel_map, channels, revisions)

    args = Namespace(name="testcharm", format=False)
    StatusCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_releases("testcharm"),
    ]

    expected = [
        "Track    Base                  Channel    Version    Revision",
        "latest   ubuntu 20.04 (amd64)  stable     7.5.3      5",
        "                               candidate  ↑          ↑",
        "                               beta       ↑          ↑",
        "                               edge       ↑          ↑",
        "2.0      -                     stable     -          -",
        "                               candidate  -          -",
        "                               beta       -          -",
        "                               edge       -          -",
    ]
    emitter.assert_messages(expected)


# -- tests for create library command


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
@pytest.mark.parametrize("charmcraft_yaml_name", [None, "test-charm"])
def test_createlib_simple(
    emitter, store_mock, tmp_path, monkeypatch, config, formatted, charmcraft_yaml_name
):
    """Happy path with result from the Store."""
    if not charmcraft_yaml_name:
        pytest.xfail("Store commands need refactoring to not need a project.")
    monkeypatch.chdir(tmp_path)

    config.name = charmcraft_yaml_name

    lib_id = "test-example-lib-id"
    store_mock.create_library_id.return_value = lib_id

    args = Namespace(name="testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        CreateLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.create_library_id("test-charm", "testlib"),
    ]
    if formatted:
        expected = {"library_id": lib_id}
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Library charms.test_charm.v0.testlib created with id test-example-lib-id.",
            "Consider 'git add lib/charms/test_charm/v0/testlib.py'.",
        ]
        emitter.assert_messages(expected)
    created_lib_file = tmp_path / "lib" / "charms" / "test_charm" / "v0" / "testlib.py"

    env = get_templates_environment("charmlibs")
    expected_newlib_content = env.get_template("new_library.py.j2").render(lib_id=lib_id)
    assert created_lib_file.read_text() == expected_newlib_content


@pytest.mark.xfail(
    strict=True, raises=pydantic.ValidationError, reason="Store commands need refactor."
)
def test_createlib_name_from_metadata_problem(store_mock, config):
    """The metadata wasn't there to get the name."""
    args = Namespace(name="testlib", format=None)
    config.name = None
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = None
        with pytest.raises(CraftError) as cm:
            CreateLibCommand(config).run(args)
        assert str(cm.value) == (
            "Cannot find a valid charm name in charm definition. "
            "Check that you are using the correct project directory."
        )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_createlib_name_contains_dash(emitter, store_mock, tmp_path, monkeypatch, config):
    """'-' is valid in charm names but can't be imported"""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    store_mock.create_library_id.return_value = lib_id

    args = Namespace(name="testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        CreateLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.create_library_id("test-charm", "testlib"),
    ]
    expected = [
        "Library charms.test_charm.v0.testlib created with id test-example-lib-id.",
        "Consider 'git add lib/charms/test_charm/v0/testlib.py'.",
    ]
    emitter.assert_messages(expected)
    created_lib_file = tmp_path / "lib" / "charms" / "test_charm" / "v0" / "testlib.py"

    env = get_templates_environment("charmlibs")
    expected_newlib_content = env.get_template("new_library.py.j2").render(lib_id=lib_id)
    assert created_lib_file.read_text() == expected_newlib_content


@pytest.mark.parametrize(
    "lib_name",
    [
        "foo.bar",
        "foo/bar",
        "Foo",
        "123foo",
        "_foo",
        "",
    ],
)
def test_createlib_invalid_name(lib_name, config):
    """Verify that it cannot be used with an invalid name."""
    args = Namespace(name=lib_name, format=None)
    with pytest.raises(CraftError) as err:
        CreateLibCommand(config).run(args)
    assert str(err.value) == (
        "Invalid library name. Must only use lowercase alphanumeric "
        "characters and underscore, starting with alpha."
    )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_createlib_path_already_there(tmp_path, monkeypatch, config):
    """The intended-to-be-created library is already there."""
    monkeypatch.chdir(tmp_path)

    factory.create_lib_filepath("test-charm", "testlib", api=0)
    args = Namespace(name="testlib", format=None)
    with pytest.raises(CraftError) as err:
        CreateLibCommand(config).run(args)

    assert str(err.value) == (
        "This library already exists: 'lib/charms/test_charm/v0/testlib.py'."
    )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_createlib_path_can_not_write(tmp_path, monkeypatch, store_mock, add_cleanup, config):
    """Disk error when trying to write the new lib (bad permissions, name too long, whatever)."""
    lib_dir = tmp_path / "lib" / "charms" / "test_charm" / "v0"
    lib_dir.mkdir(parents=True)
    lib_dir.chmod(0o111)
    add_cleanup(lib_dir.chmod, 0o777)
    monkeypatch.chdir(tmp_path)

    args = Namespace(name="testlib", format=None)
    store_mock.create_library_id.return_value = "lib_id"
    expected_error = "Error writing the library in .*: PermissionError.*"
    with pytest.raises(CraftError, match=expected_error):
        CreateLibCommand(config).run(args)


def test_createlib_library_template_is_python(emitter, store_mock, tmp_path, monkeypatch):
    """Verify that the template used to create a library is valid Python code."""
    env = get_templates_environment("charmlibs")
    newlib_content = env.get_template("new_library.py.j2").render(lib_id="test-lib-id")
    compile(newlib_content, "test.py", "exec")


# -- tests for publish libraries command


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_simple(emitter, store_mock, tmp_path, monkeypatch, config, formatted):
    """Happy path publishing because no revision at all in the Store."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    content, content_hash = factory.create_lib_filepath(
        "test-charm", "testlib", api=0, patch=1, lib_id=lib_id
    )

    store_mock.get_libraries_tips.return_value = {}
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "testcharm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
        call.create_library_revision("test-charm", lib_id, 0, 1, content, content_hash),
    ]
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "published": {
                    "patch": 1,
                    "content_hash": content_hash,
                },
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.1"
        emitter.assert_message(expected)


def test_publishlib_contains_dash(emitter, store_mock, tmp_path, monkeypatch, config):
    """Happy path publishing because no revision at all in the Store."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    content, content_hash = factory.create_lib_filepath(
        "test-charm", "testlib", api=0, patch=1, lib_id=lib_id
    )

    store_mock.get_libraries_tips.return_value = {}
    args = Namespace(library="charms.test_charm.v0.testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
        call.create_library_revision("test-charm", lib_id, 0, 1, content, content_hash),
    ]
    expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.1"
    emitter.assert_message(expected)


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_all(emitter, store_mock, tmp_path, monkeypatch, config, formatted):
    """Publish all the libraries found in disk."""
    monkeypatch.chdir(tmp_path)
    config.name = "testcharm-1"

    c1, h1 = factory.create_lib_filepath(
        "testcharm-1", "testlib-a", api=0, patch=1, lib_id="lib_id_1"
    )
    c2, h2 = factory.create_lib_filepath(
        "testcharm-1", "testlib-b", api=0, patch=1, lib_id="lib_id_2"
    )
    c3, h3 = factory.create_lib_filepath(
        "testcharm-1", "testlib-b", api=1, patch=3, lib_id="lib_id_2"
    )
    factory.create_lib_filepath("testcharm-2", "testlib", api=0, patch=1, lib_id="lib_id_4")

    store_mock.get_libraries_tips.return_value = {}
    args = Namespace(library=None, format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "testcharm-1"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips(
            [
                {"lib_id": "lib_id_1", "api": 0},
                {"lib_id": "lib_id_2", "api": 0},
                {"lib_id": "lib_id_2", "api": 1},
            ]
        ),
        call.create_library_revision("testcharm-1", "lib_id_1", 0, 1, c1, h1),
        call.create_library_revision("testcharm-1", "lib_id_2", 0, 1, c2, h2),
        call.create_library_revision("testcharm-1", "lib_id_2", 1, 3, c3, h3),
    ]
    names = [
        "charms.testcharm_1.v0.testlib-a",
        "charms.testcharm_1.v0.testlib-b",
        "charms.testcharm_1.v1.testlib-b",
    ]
    emitter.assert_debug("Libraries found under 'lib/charms/testcharm_1': " + str(names))
    if formatted:
        expected = [
            {
                "charm_name": "testcharm-1",
                "library_name": "testlib-a",
                "library_id": "lib_id_1",
                "api": 0,
                "published": {
                    "patch": 1,
                    "content_hash": h1,
                },
            },
            {
                "charm_name": "testcharm-1",
                "library_name": "testlib-b",
                "library_id": "lib_id_2",
                "api": 0,
                "published": {
                    "patch": 1,
                    "content_hash": h2,
                },
            },
            {
                "charm_name": "testcharm-1",
                "library_name": "testlib-b",
                "library_id": "lib_id_2",
                "api": 1,
                "published": {
                    "patch": 3,
                    "content_hash": h3,
                },
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages(
            [
                "Library charms.testcharm_1.v0.testlib-a sent to the store with version 0.1",
                "Library charms.testcharm_1.v0.testlib-b sent to the store with version 0.1",
                "Library charms.testcharm_1.v1.testlib-b sent to the store with version 1.3",
            ]
        )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_publishlib_not_found(emitter, store_mock, tmp_path, monkeypatch, config):
    """The indicated library is not found."""
    monkeypatch.chdir(tmp_path)

    args = Namespace(library="charms.testcharm.v0.testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "testcharm"
        with pytest.raises(CraftError) as cm:
            PublishLibCommand(config).run(args)

        assert str(cm.value) == (
            "The specified library was not found at path 'lib/charms/testcharm/v0/testlib.py'."
        )


def test_publishlib_not_from_current_charm(emitter, store_mock, tmp_path, monkeypatch, config):
    """The indicated library to publish does not belong to this charm."""
    monkeypatch.chdir(tmp_path)
    config.name = "charm2"
    factory.create_lib_filepath("testcharm", "testlib", api=0)

    args = Namespace(library="charms.testcharm.v0.testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "charm2"
        with pytest.raises(CraftError) as cm:
            PublishLibCommand(config).run(args)

        assert str(cm.value) == (
            "The library charms.testcharm.v0.testlib does not belong to this charm 'charm2'."
        )


@pytest.mark.xfail(
    strict=True,
    raises=pydantic.ValidationError,
    reason="Store commands need refactoring to not need a project.",
)
def test_publishlib_name_from_metadata_problem(store_mock, config):
    """The metadata wasn't there to get the name."""
    config.name = None
    args = Namespace(library="charms.testcharm.v0.testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = None
        with pytest.raises(CraftError) as cm:
            PublishLibCommand(config).run(args)

        assert str(cm.value) == (
            "Cannot find a valid charm name in charm definition. "
            "Check that you are using the correct project directory."
        )


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_store_is_advanced(
    emitter, store_mock, tmp_path, monkeypatch, config, formatted
):
    """The store has a higher revision number than what we expect."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    factory.create_lib_filepath("test-charm", "testlib", api=0, patch=1, lib_id=lib_id)

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash="abc",
            api=0,
            patch=2,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
    ]
    error_message = (
        "Library charms.test_charm.v0.testlib is out-of-date locally, Charmhub has version 0.2, "
        "please fetch the updates before publishing."
    )
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "error_message": error_message,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages([error_message])


def test_publishlib_store_is_exactly_behind_ok(emitter, store_mock, tmp_path, monkeypatch, config):
    """The store is exactly one revision less than local lib, ok."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    content, content_hash = factory.create_lib_filepath(
        "test-charm", "testlib", api=0, patch=7, lib_id=lib_id
    )

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash="abc",
            api=0,
            patch=6,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
        call.create_library_revision("test-charm", lib_id, 0, 7, content, content_hash),
    ]
    expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.7"
    emitter.assert_message(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_store_is_exactly_behind_same_hash(
    emitter, store_mock, tmp_path, monkeypatch, config, formatted
):
    """The store is exactly one revision less than local lib, same hash."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    content, content_hash = factory.create_lib_filepath(
        "test-charm", "testlib", api=0, patch=7, lib_id=lib_id
    )

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash=content_hash,
            api=0,
            patch=6,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
    ]
    error_message = (
        "Library charms.test_charm.v0.testlib LIBPATCH number was incorrectly incremented, "
        "Charmhub has the same content in version 0.6."
    )
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "error_message": error_message,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages([error_message])


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_store_is_too_behind(
    emitter, store_mock, tmp_path, monkeypatch, config, formatted
):
    """The store is way more behind than what we expected (local lib too high!)."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    factory.create_lib_filepath("test-charm", "testlib", api=0, patch=4, lib_id=lib_id)

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash="abc",
            api=0,
            patch=2,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
    ]
    error_message = (
        "Library charms.test_charm.v0.testlib has a wrong LIBPATCH number, it's too high and needs "
        "to be consecutive, Charmhub highest version is 0.2."
    )
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "error_message": error_message,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages([error_message])


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_store_has_same_revision_same_hash(
    emitter,
    store_mock,
    tmp_path,
    monkeypatch,
    config,
    formatted,
):
    """The store has the same revision we want to publish, with the same hash."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    content, content_hash = factory.create_lib_filepath(
        "test-charm", "testlib", api=0, patch=7, lib_id=lib_id
    )

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash=content_hash,
            api=0,
            patch=7,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
    ]
    error_message = "Library charms.test_charm.v0.testlib is already updated in Charmhub."
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "error_message": error_message,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages([error_message])


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_publishlib_store_has_same_revision_other_hash(
    emitter, store_mock, tmp_path, monkeypatch, config, formatted
):
    """The store has the same revision we want to publish, but with a different hash."""
    monkeypatch.chdir(tmp_path)

    lib_id = "test-example-lib-id"
    factory.create_lib_filepath("test-charm", "testlib", api=0, patch=7, lib_id=lib_id)

    store_mock.get_libraries_tips.return_value = {
        (lib_id, 0): Library(
            lib_id=lib_id,
            content=None,
            content_hash="abc",
            api=0,
            patch=7,
            lib_name="testlib",
            charm_name="test-charm",
        ),
    }
    args = Namespace(library="charms.test_charm.v0.testlib", format=formatted)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "test-charm"
        PublishLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]),
    ]
    error_message = (
        "Library charms.test_charm.v0.testlib version 0.7 is the same than in Charmhub but "
        "content is different"
    )
    if formatted:
        expected = [
            {
                "charm_name": "test-charm",
                "library_name": "testlib",
                "library_id": lib_id,
                "api": 0,
                "error_message": error_message,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages([error_message])


# -- tests for list libraries command


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_listlib_simple(emitter, store_mock, config, formatted):
    """Happy path listing simple case."""
    store_mock.get_libraries_tips.return_value = {
        ("some-lib-id", 3): Library(
            lib_id="some-lib-id",
            content=None,
            content_hash="abc",
            api=3,
            patch=7,
            lib_name="testlib",
            charm_name="testcharm",
        ),
    }
    args = Namespace(name="testcharm", format=formatted)
    ListLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"charm_name": "testcharm"}]),
    ]
    if formatted:
        expected = [
            {
                "charm_name": "testcharm",
                "library_name": "testlib",
                "library_id": "some-lib-id",
                "api": 3,
                "patch": 7,
                "content_hash": "abc",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Library name    API    Patch",
            "testlib         3      7",
        ]
        emitter.assert_messages(expected)


def test_listlib_charm_from_metadata(emitter, store_mock, config):
    """Happy path listing simple case."""
    store_mock.get_libraries_tips.return_value = {}
    args = Namespace(name=None, format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = "testcharm"
        ListLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"charm_name": "testcharm"}]),
    ]


def test_listlib_name_from_metadata_problem(store_mock, config):
    """The metadata wasn't there to get the name."""
    args = Namespace(name=None, format=None)
    with patch("charmcraft.utils.get_name_from_metadata") as mock:
        mock.return_value = None
        with pytest.raises(CraftError) as cm:
            ListLibCommand(config).run(args)

        assert str(cm.value) == (
            "Can't access name in 'metadata.yaml' file. The 'list-lib' command must either be "
            "executed from a valid project directory, or specify a charm name using "
            "the --charm-name option."
        )


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_listlib_empty(emitter, store_mock, config, formatted):
    """Nothing found in the store for the specified charm."""
    store_mock.get_libraries_tips.return_value = {}
    args = Namespace(name="testcharm", format=formatted)
    ListLibCommand(config).run(args)

    if formatted:
        emitter.assert_json_output([])
    else:
        expected = "No libraries found for charm testcharm."
        emitter.assert_message(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_listlib_properly_sorted(emitter, store_mock, config, formatted):
    """Check the sorting of the list."""
    store_mock.get_libraries_tips.return_value = {
        ("lib-id-2", 3): Library(
            lib_id="lib-id-2",
            content=None,
            content_hash="abc",
            api=3,
            patch=7,
            lib_name="testlib-2",
            charm_name="testcharm",
        ),
        ("lib-id-2", 2): Library(
            lib_id="lib-id-2",
            content=None,
            content_hash="abc",
            api=2,
            patch=8,
            lib_name="testlib-2",
            charm_name="testcharm",
        ),
        ("lib-id-1", 5): Library(
            lib_id="lib-id-1",
            content=None,
            content_hash="abc",
            api=5,
            patch=124,
            lib_name="testlib-1",
            charm_name="testcharm",
        ),
    }
    args = Namespace(name="testcharm", format=formatted)
    ListLibCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.get_libraries_tips([{"charm_name": "testcharm"}]),
    ]
    if formatted:
        expected = [
            {
                "charm_name": "testcharm",
                "library_name": "testlib-1",
                "library_id": "lib-id-1",
                "api": 5,
                "patch": 124,
                "content_hash": "abc",
            },
            {
                "charm_name": "testcharm",
                "library_name": "testlib-2",
                "library_id": "lib-id-2",
                "api": 2,
                "patch": 8,
                "content_hash": "abc",
            },
            {
                "charm_name": "testcharm",
                "library_name": "testlib-2",
                "library_id": "lib-id-2",
                "api": 3,
                "patch": 7,
                "content_hash": "abc",
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Library name    API    Patch",
            "testlib-1       5      124",
            "testlib-2       2      8",
            "testlib-2       3      7",
        ]
        emitter.assert_messages(expected)


# -- tests for list resources command


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_resources_simple(emitter, store_mock, config, formatted):
    """Happy path of one result from the Store."""
    store_response = [
        Resource(name="testresource", optional=True, revision=1, resource_type="file"),
    ]
    store_mock.list_resources.return_value = store_response

    args = Namespace(charm_name="testcharm", format=formatted)
    ListResourcesCommand(config).run(args)

    assert store_mock.mock_calls == [
        call.list_resources("testcharm"),
    ]
    if formatted:
        expected = [
            {
                "charm_revision": 1,
                "name": "testresource",
                "type": "file",
                "optional": True,
            }
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Charm Rev    Resource      Type    Optional",
            "1            testresource  file    True",
        ]
        emitter.assert_messages(expected)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_resources_empty(emitter, store_mock, config, formatted):
    """No results from the store."""
    store_response = []
    store_mock.list_resources.return_value = store_response

    args = Namespace(charm_name="testcharm", format=formatted)
    ListResourcesCommand(config).run(args)

    if formatted:
        emitter.assert_json_output([])
    else:
        emitter.assert_message("No resources associated to testcharm.")


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_resources_ordered_and_grouped(emitter, store_mock, config, formatted):
    """Results are presented ordered by name in the table."""
    store_response = [
        Resource(name="bbb-resource", optional=True, revision=2, resource_type="file"),
        Resource(name="ccc-resource", optional=True, revision=1, resource_type="file"),
        Resource(name="bbb-resource", optional=False, revision=3, resource_type="file"),
        Resource(name="aaa-resource", optional=True, revision=2, resource_type="oci-image"),
        Resource(name="bbb-resource", optional=True, revision=5, resource_type="file"),
    ]
    store_mock.list_resources.return_value = store_response

    args = Namespace(charm_name="testcharm", format=formatted)
    ListResourcesCommand(config).run(args)

    if formatted:
        expected = [
            {
                "charm_revision": 2,
                "name": "bbb-resource",
                "type": "file",
                "optional": True,
            },
            {
                "charm_revision": 1,
                "name": "ccc-resource",
                "type": "file",
                "optional": True,
            },
            {
                "charm_revision": 3,
                "name": "bbb-resource",
                "type": "file",
                "optional": False,
            },
            {
                "charm_revision": 2,
                "name": "aaa-resource",
                "type": "oci-image",
                "optional": True,
            },
            {
                "charm_revision": 5,
                "name": "bbb-resource",
                "type": "file",
                "optional": True,
            },
        ]
        emitter.assert_json_output(expected)
    else:
        expected = [
            "Charm Rev    Resource      Type       Optional",
            "5            bbb-resource  file       True",
            "3            bbb-resource  file       False",
            "2            aaa-resource  oci-image  True",
            "             bbb-resource  file       True",
            "1            ccc-resource  file       True",
        ]
        emitter.assert_messages(expected)


# -- tests for upload resources command


def test_uploadresource_options_filepath_type(config):
    """The --filepath option implies a set of validations."""
    cmd = UploadResourceCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    (action,) = (action for action in parser._actions if action.dest == "filepath")
    assert isinstance(action.type, SingleOptionEnsurer)
    assert action.type.converter is useful_filepath


def test_uploadresource_options_image_type(config):
    """The --image option implies a set of validations."""
    cmd = UploadResourceCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    (action,) = (action for action in parser._actions if action.dest == "image")
    assert isinstance(action.type, SingleOptionEnsurer)
    assert action.type.converter is str


@pytest.mark.parametrize(
    "sysargs",
    [
        ("c", "r", "--filepath=fpath"),
        ("c", "r", "--image=x"),
    ],
)
def test_uploadresource_options_good_combinations(tmp_path, config, sysargs, monkeypatch):
    """Check the specific rules for filepath and image/[registry] good combinations."""
    # fake the file for filepath
    (tmp_path / "fpath").touch()
    monkeypatch.chdir(tmp_path)

    cmd = UploadResourceCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    try:
        parser.parse_args(sysargs)
    except SystemExit:
        pytest.fail("Argument parsing expected to succeed but failed")


@pytest.mark.parametrize(
    "sysargs",
    [
        ("c", "r"),  # filepath XOR image needs to be specified
        ("c", "r", "--filepath=fpath", "--image=y"),  # can't specify both
    ],
)
def test_uploadresource_options_bad_combinations(config, sysargs, tmp_path, monkeypatch):
    """Check the specific rules for filepath and image/[registry] bad combinations."""
    # fake the file for filepath
    (tmp_path / "fpath").touch()
    monkeypatch.chdir(tmp_path)

    cmd = UploadResourceCommand(config)
    parser = ArgumentParser()
    cmd.fill_parser(parser)
    with pytest.raises(SystemExit):
        parsed_args = parser.parse_args(sysargs)
        cmd.parsed_args_post_verification(parser, parsed_args)


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path, formatted):
    """Simple upload, success result."""
    store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
    store_mock.upload_resource.return_value = store_response

    test_resource = tmp_path / "mystuff.bin"
    test_resource.write_text("sample stuff")
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=test_resource,
        image=None,
        format=formatted,
    )
    retcode = UploadResourceCommand(config).run(args)
    assert retcode == 0

    assert store_mock.mock_calls == [
        call.upload_resource("mycharm", "myresource", "file", test_resource)
    ]
    if formatted:
        expected = {"revision": 7}
        emitter.assert_json_output(expected)
    else:
        emitter.assert_interactions(
            [
                call("progress", f"Uploading resource directly from file {str(test_resource)!r}."),
                call(
                    "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."
                ),
            ]
        )
    assert test_resource.exists()  # provided by the user, don't touch it


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_uploadresource_image_digest_already_uploaded(emitter, store_mock, config, formatted):
    """Upload an oci-image resource, the image itself already being in the registry."""
    # fake credentials for the charm/resource, and the final json content
    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
        username="testusername",
        password="testpassword",
        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
    )

    test_json_content = "from charmhub we came, to charmhub we shall return"
    store_mock.get_oci_image_blob.return_value = test_json_content

    # hack into the store mock to save for later the uploaded resource bytes
    uploaded_resource_content = None
    uploaded_resource_filepath = None

    def interceptor(charm_name, resource_name, resource_type, resource_filepath):
        """Intercept the call to save real content (and validate params)."""
        nonlocal uploaded_resource_content, uploaded_resource_filepath

        uploaded_resource_filepath = resource_filepath
        uploaded_resource_content = resource_filepath.read_text()

        assert charm_name == "mycharm"
        assert resource_name == "myresource"
        assert resource_type == "oci-image"
        return Uploaded(ok=True, status=200, revision=7, errors=[])

    store_mock.upload_resource.side_effect = interceptor

    # test
    original_image_digest = "sha256:test-digest-given-by-user"
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=None,
        image=original_image_digest,
        format=formatted,
    )
    with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
        with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
            reg_class_mock.return_value = reg_mock = MagicMock()
            im_class_mock.return_value = im_mock = MagicMock()
            im_mock.check_in_registry.return_value = True
            UploadResourceCommand(config).run(args)

    # validate how OCIRegistry was instantiated
    assert reg_class_mock.mock_calls == [
        call(
            config.charmhub.registry_url,
            "charm/charm-id/test-image-name",
            username="testusername",
            password="testpassword",
        )
    ]

    # validate how ImageHandler was used
    assert im_class_mock.mock_calls == [
        call(reg_mock),
        call().check_in_registry(original_image_digest),
    ]

    # check that the uploaded file is fine and that was cleaned
    assert uploaded_resource_content == test_json_content
    assert not uploaded_resource_filepath.exists()  # temporary! shall be cleaned

    assert store_mock.mock_calls == [
        call.get_oci_registry_credentials("mycharm", "myresource"),
        call.get_oci_image_blob("mycharm", "myresource", original_image_digest),
        call.upload_resource("mycharm", "myresource", "oci-image", uploaded_resource_filepath),
    ]

    if formatted:
        expected = {"revision": 7}
        emitter.assert_json_output(expected)
    else:
        emitter.assert_interactions(
            [
                call(
                    "progress",
                    "Uploading resource from image "
                    "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
                ),
                call("progress", "Using OCI image from Canonical's registry.", permanent=True),
                call(
                    "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."
                ),
            ]
        )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_uploadresource_image_digest_upload_from_local(emitter, store_mock, config):
    """Upload an oci-image resource, from local to Canonical's registry, specified by digest."""
    # fake credentials for the charm/resource, the final json content, and the upload result
    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
        username="testusername",
        password="testpassword",
        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
    )

    test_json_content = "from charmhub we came, to charmhub we shall return"
    store_mock.get_oci_image_blob.return_value = test_json_content

    store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[])

    # test
    original_image_digest = "sha256:test-digest-given-by-user"
    local_image_info = "local image info"
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=None,
        image=original_image_digest,
        format=False,
    )
    with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
        with patch(
            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
        ) as dockerd_class_mock:
            im_class_mock.return_value = im_mock = MagicMock()
            dockerd_class_mock.return_value = dock_mock = MagicMock()

            # not in the remote registry, found locally, then uploaded ok
            im_mock.check_in_registry.return_value = False
            dock_mock.get_image_info_from_digest.return_value = local_image_info
            new_image_digest = "new-digest-after-upload"
            im_mock.upload_from_local.return_value = new_image_digest

            UploadResourceCommand(config).run(args)

    # validate how ImageHandler was used
    assert im_mock.mock_calls == [
        call.check_in_registry(original_image_digest),
        call.upload_from_local(local_image_info),
    ]

    assert store_mock.mock_calls == [
        call.get_oci_registry_credentials("mycharm", "myresource"),
        call.get_oci_image_blob("mycharm", "myresource", new_image_digest),
        call.upload_resource("mycharm", "myresource", "oci-image", ANY),
    ]

    emitter.assert_interactions(
        [
            call(
                "progress",
                "Uploading resource from image "
                "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
            ),
            call("progress", "Remote image not found, getting its info from local registry."),
            call("progress", "Uploading from local registry.", permanent=True),
            call(
                "progress",
                "Image uploaded, new remote digest: new-digest-after-upload.",
                permanent=True,
            ),
            call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
        ]
    )


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
def test_uploadresource_image_id_upload_from_local(emitter, store_mock, config):
    """Upload an oci-image resource, from local to Canonical's registry, specified by id."""
    # fake credentials for the charm/resource, the final json content, and the upload result
    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
        username="testusername",
        password="testpassword",
        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
    )

    test_json_content = "from charmhub we came, to charmhub we shall return"
    store_mock.get_oci_image_blob.return_value = test_json_content

    store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[])

    # test
    original_image_id = "test-id-given-by-user"
    local_image_info = "local image info"
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=None,
        image=original_image_id,
        format=False,
    )
    with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
        with patch(
            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
        ) as dockerd_class_mock:
            im_class_mock.return_value = im_mock = MagicMock()
            dockerd_class_mock.return_value = dock_mock = MagicMock()

            # found locally, then uploaded ok
            dock_mock.get_image_info_from_id.return_value = local_image_info
            new_image_digest = "new-digest-after-upload"
            im_mock.upload_from_local.return_value = new_image_digest

            UploadResourceCommand(config).run(args)

    # validate how ImageHandler was used
    assert im_mock.mock_calls == [
        call.upload_from_local(local_image_info),
    ]

    assert store_mock.mock_calls == [
        call.get_oci_registry_credentials("mycharm", "myresource"),
        call.get_oci_image_blob("mycharm", "myresource", new_image_digest),
        call.upload_resource("mycharm", "myresource", "oci-image", ANY),
    ]

    emitter.assert_interactions(
        [
            call(
                "progress",
                "Uploading resource from image "
                "charm/charm-id/test-image-name @ test-id-given-by-user.",
            ),
            call("progress", "Getting image info from local registry."),
            call("progress", "Uploading from local registry.", permanent=True),
            call(
                "progress",
                "Image uploaded, new remote digest: new-digest-after-upload.",
                permanent=True,
            ),
            call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
        ]
    )


@pytest.mark.skipif(platform.system() == "Windows", reason="No skopeo")
def test_uploadresource_image_digest_missing_everywhere(emitter, store_mock, config):
    """Upload an oci-image resource by digest, but the image is not found remote nor locally."""
    # fake credentials for the charm/resource, the final json content, and the upload result
    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
        username="testusername",
        password="testpassword",
        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
    )

    # test
    original_image_digest = "sha256:test-digest-given-by-user"
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=None,
        image=original_image_digest,
        format=False,
    )
    with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
        with patch(
            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
        ) as dockerd_class_mock:
            im_class_mock.return_value = im_mock = MagicMock()
            dockerd_class_mock.return_value = dock_mock = MagicMock()

            # not in the remote registry, not locally either
            im_mock.check_in_registry.return_value = False
            dock_mock.get_image_info_from_digest.return_value = None

            with pytest.raises(CraftError) as cm:
                UploadResourceCommand(config).run(args)

    assert str(cm.value) == "Image not found locally."

    # validate how local interfaces and store was used
    assert im_mock.mock_calls == [
        call.check_in_registry(original_image_digest),
    ]
    assert dock_mock.mock_calls == [
        call.get_image_info_from_digest(original_image_digest),
    ]
    assert store_mock.mock_calls == [
        call.get_oci_registry_credentials("mycharm", "myresource"),
    ]

    emitter.assert_interactions(
        [
            call(
                "progress",
                "Uploading resource from "
                "image charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
            ),
            call("progress", "Remote image not found, getting its info from local registry."),
        ]
    )


@pytest.mark.skipif(platform.system() == "Windows", reason="No skopeo")
def test_uploadresource_image_id_missing(emitter, store_mock, config):
    """Upload an oci-image resource by id, but the image is not found locally."""
    # fake credentials for the charm/resource, the final json content, and the upload result
    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
        username="testusername",
        password="testpassword",
        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
    )

    # test
    original_image_id = "test-id-given-by-user"
    args = Namespace(
        charm_name="mycharm",
        resource_name="myresource",
        filepath=None,
        image=original_image_id,
        format=False,
    )
    with patch(
        "charmcraft.commands.store.LocalDockerdInterface", autospec=True
    ) as dockerd_class_mock:
        dockerd_class_mock.return_value = dock_mock = MagicMock()

        # not present locally
        dock_mock.get_image_info_from_id.return_value = None

        with pytest.raises(CraftError) as cm:
            UploadResourceCommand(config).run(args)

    assert str(cm.value) == "Image not found locally."

    assert dock_mock.mock_calls == [
        call.get_image_info_from_id(original_image_id),
    ]
    assert store_mock.mock_calls == [
        call.get_oci_registry_credentials("mycharm", "myresource"),
    ]

    emitter.assert_interactions(
        [
            call(
                "progress",
                "Uploading resource from "
                "image charm/charm-id/test-image-name @ test-id-given-by-user.",
            ),
            call("progress", "Getting image info from local registry."),
        ]
    )


@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
def test_uploadresource_call_error(emitter, store_mock, config, tmp_path, formatted):
    """Simple upload but with a response indicating an error."""
    errors = [
        Error(message="text 1", code="missing-stuff"),
        Error(message="other long error text", code="broken"),
    ]
    store_response = Uploaded(ok=False, status=400, revision=None, errors=errors)
    store_mock.upload_resource.return_value = store_response

    test_resource = tmp_path / "mystuff.bin"
    test_resource.write_text("sample stuff")
    args = Namespace(
        charm_name="mycharm", resource_name="myresource", filepath=test_resource, format=formatted
    )
    retcode = UploadResourceCommand(config).run(args)
    assert retcode == 1

    if formatted:
        expected = {
            "errors": [
                {"code": "missing-stuff", "message": "text 1"},
                {"code": "broken", "message": "other long error text"},
            ]
        }
        emitter.assert_json_output(expected)
    else:
        emitter.assert_messages(
            [
                "Upload failed with status 400:",
                "- missing-stuff: text 1",
                "- broken: other long error text",
            ]
        )
