##############################################################################
# Institute for the Design of Advanced Energy Systems Process Systems
# Engineering Framework (IDAES PSE Framework) Copyright (c) 2018-2020, by the
# software owners: The Regents of the University of California, through
# Lawrence Berkeley National Laboratory,  National Technology & Engineering
# Solutions of Sandia, LLC, Carnegie Mellon University, West Virginia
# University Research Corporation, et al. All rights reserved.
#
# Please see the files COPYRIGHT.txt and LICENSE.txt for full copyright and
# license information, respectively. Both files are also available online
# at the URL "https://github.com/IDAES/idaes-pse".
##############################################################################
"""
Install IDAES example files locally.

By default, this will download the examples from "examples-pse/src" on Github,
with a release matching the version of the currently installed idaes package,
into a sub-directory of the current directory named "examples". It will
also, unless directed otherwise, install all Python modules from the downloaded
directory into a package called "idaes_examples".

Options let the user choose a different version, directory, and
whether to actually download or install.
"""
# stdlib
from collections import namedtuple
from datetime import datetime
from io import StringIO
import logging
from operator import attrgetter
import os
import re
from setuptools import setup, find_packages
from pathlib import Path
import shutil
import sys
from typing import List
from uuid import uuid4
from zipfile import ZipFile
import json

# third-party
import click
from nbconvert.exporters import NotebookExporter
from nbconvert.writers import FilesWriter
from traitlets.config import Config
import nbformat
import requests

# package
from idaes.commands import cb
from idaes.commands.base import how_to_report_an_error
from idaes.ver import package_version as V

__author__ = "Dan Gunter"


_log = logging.getLogger("idaes.commands.examples")

# Constants

GITHUB = "https://github.com"
GITHUB_API = "https://api.github.com"
REPO_ORG = "idaes"
REPO_NAME = "examples-pse"
REPO_DIR = "src"
PKG_VERSION = f"{V.major}.{V.minor}.{V.micro}"
INSTALL_PKG = "idaes_examples"
JUPYTER_NB_VERSION = 4  # for parsing

REMOVE_CELL_TAG = "remove_cell"  # for stripping Jupyter notebook cells
SOLUTION_CELL_TAG = "solution"  # ditto
EXERCISE_CELL_TAG = "exercise"  # ditto

STRIPPED_NOTEBOOK_SUFFIX = "_s"

# Global vars

g_tempdir, g_egg = None, None

# Exceptions


class DownloadError(Exception):
    """Used for errors downloading the release files.
    """

    pass


class CopyError(Exception):
    """Used for errors copying files.
    """
    pass


class InstallError(Exception):
    """Used for errors installing the source as a Python package.
    """

    pass


class GithubError(Exception):
    pass


Release = namedtuple("Release", ["date", "tag", "info"])


@cb.command(
    name="get-examples", help="Fetch example scripts and Jupyter Notebooks."
)
@click.option(
    "--dir", "-d", "directory", help="installation target directory", default="examples",
    type=str,
)
@click.option(
    "--local", "local_dir",
    help="For developers: instead of downloading, copy from an "
         "idaes-examples repository on local disk"
)
@click.option(
    "--no-install", "-I", "no_install", help="Do *not* install examples into 'idaes_examples' package",
    is_flag=True
)
@click.option(
    "--list",
    "-l",
    "list_releases",
    help="List all available released versions, and stop",
    is_flag=True,
)
@click.option(
    "--no-download",
    "-N",
    "no_download",
    help="Do not download anything",
    is_flag=True
)
@click.option(
    "--unstable",
    "-U",
    help="Allow and list unstable/pre-release versions",
    is_flag=True
)
@click.option(
    "--version",
    "-V",
    help=f"Version of examples to download",
    default=None,
    show_default=False,
)
def get_examples(directory, no_install, list_releases, no_download, version,
                 unstable, local_dir):
    """Get the examples from Github and put them in a local directory.
    """
    # list-releases mode
    if list_releases:
        try:
            releases = get_releases(unstable)
        except GithubError as err:
            click.echo(f"Error getting data from Github: {err}")
            sys.exit(-1)
        print_releases(releases, unstable)
        sys.exit(0)
    # otherwise..
    target_dir = Path(directory)
    # do nothing?
    if no_download and no_install:
        click.echo("Download and installation disabled. Done.")
        sys.exit(0)
    # download?
    if no_download:  # no download
        _log.info("skipping download step (use existing examples)")
        click.echo("Skip download")
    else:  # download
        if target_dir.exists():
            click.echo(f"Target directory '{target_dir}' already exists. Please "
                       f"remove it, or choose a different directory.")
            sys.exit(-1)
        if local_dir is not None:
            click.echo(f"Copying from local directory: {local_dir}")
            local_path = Path(local_dir)
            if not local_path.exists() or not local_path.is_dir():
                click.echo(f"Cannot copy from local directory '{local_dir}': "
                           f"directory does not exist, or not a directory")
                sys.exit(-1)
            try:
                copy_contents(target_dir, local_path)
            except CopyError as err:
                click.echo(f"Failed to copy from '{local_dir}' to '{target_dir}': "
                           f"{err}")
                sys.exit(-1)
            ex_version = version if version else PKG_VERSION
        else:
            try:
                releases = get_releases(unstable)
            except GithubError as err:
                click.echo(f"Error getting data from Github: {err}")
                sys.exit(-1)
            # check that unstable flag is given before downloading unstable version
            if version is not None:
                stable_ver = re.match(r".*-\w+$", version) is None
                if not stable_ver and not unstable:
                    click.echo(f"Cannot download unstable version {version} unless you add "
                               f"the -U/--unstable flag")
                    sys.exit(-1)
            # set version
            if version is None:
                ex_version = get_examples_version(PKG_VERSION)
            else:
                ex_version = version
            # give an error if selected version does not exist
            if ex_version not in [r.tag for r in releases]:
                if version is None:
                    click.echo(f"Internal Error: Could not find an examples release\n"
                               f"matching IDAES version {PKG_VERSION}\n"
                               f"You can manually pick  version with '-V/--version'\n"
                               f"or install from a local directory with '--local'.\n"
                               f"Use '-l/--list-releases' to see all versions.\n"
                               f"{how_to_report_an_error()}\n")
                else:
                    click.echo(f"Could not find an examples release matching IDAES version "
                               f"{version}.\n Use -l/--list-releases to see all "
                               f"available versions.")
                sys.exit(-1)
            click.echo("Downloading...")
            try:
                download(target_dir, ex_version)
            except DownloadError as err:
                _log.warning(f"abort due to failed download: {err}")
                clean_up_temporary_files()
                sys.exit(-1)
            full_dir = os.path.realpath(target_dir)
            _log.info(f"downloaded examples to: '{full_dir}'")
    # install
    if no_install:
        _log.info("skipping installation step")
        click.echo("Skip install")
    else:
        if not target_dir.exists():
            if no_download:
                click.echo(f"Target directory '{target_dir}' does not exist")
                sys.exit(-1)
            else:
                click.echo(f"Internal error: After download, directory '{target_dir}'\n"
                           f"does not exist.\n"
                           f"{how_to_report_an_error()}")
                sys.exit(-1)
        click.echo("Installing...")
        try:
            install_src(ex_version, target_dir)
        except InstallError as err:
            click.echo(f"Install error: {err}")
            clean_up_temporary_files()
            sys.exit(-1)
        _log.info(f"Installed examples as package {INSTALL_PKG}")

    click.echo("Cleaning up...")
    # strip notebooks
    _log.info("Stripping test/solution cells from notebooks")
    strip_special_cells(target_dir)
    # temporary files
    _log.info("Removing temporary files")
    clean_up_temporary_files()

    # Done
    print_summary( ex_version, target_dir, not no_install)


def print_summary(version, dirname, installed):
    sep1, sep2 = "-" * 40, "=" * 40
    print(f"{sep1}\nIDAES Examples {version}\n{sep2}")
    print(f"Path   : {dirname}")
    if installed:
        print(f"Package: {INSTALL_PKG}")
    else:
        print(f"Package: not installed")
    print(sep2)


def get_examples_version(idaes_version: str):
    """Given the specified 'idaes-pse' repository release version,
    identify the matching 'examples-pse' repository release version.

    Args:
        idaes_version: IDAES version, e.g. "1.5.0" or "1.5.0.dev0+e1bbb[...]"

    Returns:
        Examples version, or if there is no match, return None.
    """
    # Fetch the idaes:examples version mapping from Github
    compat_file = 'idaes-compatibility.json'
    url = f"{GITHUB_API}/repos/{REPO_ORG}/{REPO_NAME}/contents/{compat_file}"
    headers = {'Accept': 'application/vnd.github.v3.raw'}
    _log.debug(f'About to call requests.get({url}, {headers})')
    res = requests.get(url, headers=headers)
    if not res.ok:
        _log.debug(f'Problem getting mapping file: {res.json()}')
        raise DownloadError(res.json())

    try:
        compat_mapping = json.loads(res.text)['mapping']
    except KeyError:
        # return the latest version instead
        _log.warning('Ill-formed compatibility mapping file for examples repository:')
        _log.debug(f'compat_mapping: {res.text}')
        _log.info('Defaulting to latest released version of examples.')
        return None

    idaes_version_num = idaes_version
    version_numbers = idaes_version.split('.')
    if len(version_numbers) > 3:
        idaes_version_num = '.'.join(version_numbers[:3])
        click.echo(f"Warning: non-release version of IDAES detected. "
                   f"Using IDAES {idaes_version_num} as reference; "
                   f"examples version compatibility is not guaranteed.")

    try:
        examples_version = compat_mapping[idaes_version_num]
    except KeyError:
        # return the latest version instead, as above
        _log.warning('IDAES version not found in compatibility mapping file. \
                Defaulting to latest released version of examples.')
        return None

    _log.debug(f'get_examples_version({idaes_version}: {examples_version}')

    return examples_version


def download(target_dir: Path, version: str):
    """Download `version` into `target_dir`.
    Raises:
        DownloadError
    """
    # check target directory
    if target_dir.exists():
        click.echo(f"Directory '{target_dir}' exists. Please move or delete and "
                   f"try this command again")
        raise DownloadError("directory exists")
    # download
    try:
        download_contents(target_dir, version)
    except DownloadError as err:
        click.echo(f"Download failed: {err}")
        raise


def is_illegal_dir(d: Path):
    """Refuse to remove directories for some situations, for safety.
    """
    if (d / ".git").exists():
        return ".git file found"
    if d.absolute() == Path.home().absolute():
        return "cannot replace home directory"
    if d.absolute == Path("/").absolute():
        return "cannot replace root directory"
    return None


def download_contents(target_dir, version):
    """Download the given version from the Github releases and make
    its `REPO_DIR` subdirectory be the `target_dir`.

    Raises:
        DownloadError: if the GET on the release URL returns non-200 status
    """
    global g_tempdir
    url = archive_file_url(version)
    _log.info(f"get examples from: {url}")
    # stream out to a big .zip file
    req = requests.get(url, stream=True)
    if req.status_code != 200:
        if req.status_code in (400, 404):
            raise DownloadError(f"file not found")
        raise DownloadError(f"status={req.status_code}")
    # note: mkdtemp() creates a directory that seems un-removable on Windows.
    # So, instead, just create the directory yourself in the current directory
    random_name = str(uuid4())
    try:
        os.mkdir(random_name)
    except Exception as err:
        _log.fatal(f"making directory '{random_name}': {err}")
        click.echo("Cannot make temporary directory in current directory. Abort.")
        sys.exit(-1)
    g_tempdir = tempdir = Path(random_name)
    _log.debug(f"created temporary directory '{tempdir.name}'")
    tempfile = tempdir / "examples.zip"
    with tempfile.open("wb") as f:
        for chunk in req.iter_content(chunk_size=65536):
            f.write(chunk)
    _log.info(f"downloaded zipfile to {tempfile}")
    # open as a zip file, and extract all files into the temporary directory
    _log.debug(f"open zip file: {tempfile}")
    zipf = ZipFile(str(tempfile))
    zipf.extractall(path=tempdir.name)
    # move the REPO_DIR subdirectory into the target dir
    subdir = Path(tempdir.name) / f"{REPO_NAME}-{version}" / REPO_DIR
    _log.debug(f"move {subdir} -> {target_dir}")
    os.rename(str(subdir), str(target_dir))
    zipf.close()


def copy_contents(target_dir, repo_root):
    subdir = repo_root / REPO_DIR
    if not subdir.is_dir():
        raise CopyError(f"Could not copy from '{subdir}': not a directory")
    _log.info(f"copy.local.start from={subdir} to={target_dir}")
    try:
        shutil.copytree(subdir, target_dir)
    except shutil.Error as err:
        raise CopyError(err)
    except FileNotFoundError:
        raise CopyError(f"Could not find file '{subdir}'")
    except Exception as err:
        raise CopyError(f"Unknown problem copying: {err}")
    _log.info(f"copy.local.end from={subdir} to={target_dir}")


def clean_up_temporary_files():
    # temporary directory created for unzipping and renaming
    if g_tempdir:
        d = g_tempdir
        _log.debug(f"remove temporary directory.start name='{d}'")
        try:
            shutil.rmtree(d)
        except Exception as err:  # WTF
            _log.warning(f"remove temporary directory.error name='{d}' msg='{err}'")
        else:
            _log.debug(f"removed temporary directory.end name='{d}'")
    # egg file created by setuptools
    if g_egg and g_egg.exists() and g_egg.is_dir():
        _log.debug(f"remove setuptools egg path='{g_egg}'")
        try:
            shutil.rmtree(g_egg.name)
        except Exception as err:
            _log.warning(f"remove temporary directory.error name='{g_egg}' msg='{err}'")
    # dist directory created by setuptools
    d = Path("dist")
    if d.exists() and d.is_dir():
        for f in d.glob("idaes_examples-*.egg"):
            try:
                f.unlink()
            except Exception as err:
                _log.warning(f"could not remove distribution file {f}: {err}")
        # remove directory, if now empty
        num_files = len(list(d.glob("*")))
        if num_files == 0:
            _log.info(f"removing dist directory '{d.absolute()}'")
            try:
                d.rmdir()
            except Exception as err:
                _log.warning(f"could not remove distribution directory {d}: {err}")


def archive_file_url(version, org=REPO_ORG, repo=REPO_NAME):
    """Build & return URL for a given release version.
    """
    return f"{GITHUB}/{org}/{repo}/archive/{version}.zip"


def get_releases(unstable) -> List[Release]:
    """Returns a list of releases.

    The list is sorted in ascending order by date.
    """
    releases = []
    url = f"{GITHUB_API}/repos/{REPO_ORG}/{REPO_NAME}/releases"
    resp = requests.get(url)
    data, headers = resp.json(), resp.headers
    check_github_response(data, headers)
    for rel in data:
        if not unstable and rel["prerelease"]:
            continue
        releases.append(Release(rel["published_at"], rel["tag_name"], rel["name"]))
    releases.sort(key=attrgetter("date"))  # sort by publication date
    return releases


def check_github_response(data, headers):
    """Check whether GitHub gave an error message. If so, raise a GithubError
    with a hopefully informative and useful message.
    """
    if isinstance(data, list):
        return  # lists are assumed to be the releases
    if isinstance(data, dict) and "message" in data:
        if "rate limit exceeded" in data["message"]:
            reset_ts = int(headers["X-RateLimit-Reset"])
            now = datetime.now()
            now_ts, tzinfo = now.timestamp(), now.astimezone().tzinfo
            wait_min = int((reset_ts - now_ts) // 60) + 1
            reset_dt = datetime.fromtimestamp(reset_ts)
            datestr = reset_dt.astimezone(tzinfo).isoformat()
            raise GithubError(f"API rate limit exceeded.\n"
                              f"You will need to wait {wait_min} minutes,"
                              f" until {datestr}, to try again from "
                              f"this computer.")
        else:
            raise GithubError(f"Error connecting to Github: {data['message']}")
    else:
        raise GithubError(f"Invalid result from Github: data={data} headers={headers}")


def print_releases(releases: List[Release], unstable):
    """Print the releases, as returned by `get_releases()`, as a table
    to standard output.
    """
    if len(releases) == 0:
        if unstable:
            print("No releases found")
        else:
            print("No stable releases found. Add -U/--unstable to also look "
                  "for pre-releases.")
        return
    # determine column widths
    widths = [4, 7, 7]  # widths of column titles: date,version,details
    widths[0] = len(releases[0].date)  # dates are all the same
    # tags and names can have different widths
    for rel in releases:
        for i in range(1, 3):
            widths[i] = max(widths[i], len(rel[i]))
    # make row format
    pad = "  "
    fmt = f"{{date:{widths[0]}s}}{pad}{{tag:{widths[1]}s}}{pad}{{name:{widths[2]}s}}"
    # print header
    print("")
    print(fmt.format(date="Date", tag="Version", name="Details"))
    print(fmt.format(date="-" * widths[0], tag="-" * widths[1], name="-" * widths[2]))
    # print rows
    for rel in releases:
        print(fmt.format(date=rel.date, tag=rel.tag, name=rel.info))
    # print footer
    print("")


def install_src(version, target_dir):
    """Install the 'src' subdirectory as a package, given by `INSTALL_PKG`,
    by renaming the directory, adding '__init__.py' files,
    and then running `setuptools.setup()` on the directory tree.
    When done, name the directory back to 'src', and remove '__init__.py' files.
    Then clean up whatever cruft is left behind..
    """
    global g_egg
    orig_dir = Path(os.curdir).absolute()
    target_dir = Path(target_dir.absolute())
    root_dir = target_dir.parent
    examples_dir = root_dir.absolute() / INSTALL_PKG
    if examples_dir.exists():
        raise InstallError(f"package directory {examples_dir} already exists")
    _log.info(f"install into {INSTALL_PKG} package")
    # set the args to make it look like the 'install' command has been invoked
    saved_args = sys.argv[:]
    sys.argv = ["setup.py", "install"]
    # add some empty __init__.py files
    _log.debug("add temporary __init__.py files")
    pydirs = find_python_directories(target_dir)
    pydirs.append(target_dir)  # include top-level dir
    try:
        for d in pydirs:
            init_py = d / "__init__.py"
            init_py.open("w")
    except IOError as err:
        raise InstallError(f"error writing temporary __init__.py files: {err}")
    # temporarily rename target directory to the package name
    _log.info(f"rename {target_dir} -> {examples_dir}")
    os.rename(target_dir, examples_dir)
    # if there is a 'build' directory, move it aside
    build_dir = root_dir / 'build'
    if build_dir.exists():
        from uuid import uuid1
        random_letters = str(uuid1())
        moved_build_dir = f"{build_dir}.{random_letters}"
        _log.debug(f"move existing build dir to {moved_build_dir}")
        os.rename(str(build_dir), moved_build_dir)
    else:
        _log.debug("no existing build directory (nothing to do)")
        moved_build_dir = None
    # run setuptools' setup command (in root directory)
    _log.info(f"run setup command in directory {root_dir}")
    os.chdir(root_dir)
    packages = [d for d in find_packages() if d.startswith(INSTALL_PKG)]
    _log.debug(f"install packages: {packages}")
    # before running, grab stdout
    orig_stdout = sys.stdout
    sys.stdout = setup_out = StringIO()
    # run setup
    setup(
        name=INSTALL_PKG,
        version=version,
        # description='IDAES examples',
        packages=packages,
        python_requires=">=3.5,  <4",
        zip_safe=False
    )
    # restore stdout
    sys.stdout = orig_stdout
    # print/log output
    output_str = setup_out.getvalue()
    if _log.isEnabledFor(logging.DEBUG):
        for line in output_str.split("\n"):
            _log.debug(f"(setup) {line}")
    # name the target directory back to original
    _log.info(f"rename '{examples_dir}' to  '{target_dir}'")
    os.rename(examples_dir, target_dir)
    # remove the empty __init__.py files
    _log.info("remove temporary __init__.py files")
    for d in pydirs:
        init_py = d / "__init__.py"
        _log.debug(f"remove '{init_py}")
        init_py.unlink()
    # remove build dir, and restore any moved build dir
    _log.info(f"remove build directory '{build_dir}'")
    try:
        shutil.rmtree(build_dir)
    except Exception as err:
        _log.warning(f"failed to remove build directory {build_dir}: {err}")
    if moved_build_dir is not None:
        _log.info(f"restore build dir '{build_dir}' from '{moved_build_dir}'")
        os.rename(moved_build_dir, build_dir)
    # restore previous args
    sys.argv = saved_args
    # change back to previous directory
    os.chdir(orig_dir)
    # save name of egg file, for later cleanup
    f = root_dir / (INSTALL_PKG + ".egg-info")
    if f.exists():
        g_egg = f
    else:
        _log.warning(f"egg-info file not found path='{f}'")


def find_python_directories(target_dir: Path) -> List[Path]:
    """Find all directories from target_dir, on down, that contain a
    Python module or sub-package.
    """
    # get directories that contain python files -> pydirs
    pydirs = set((x.parent for x in target_dir.rglob("*.py")))
    # get all directories in the tree leading to the 'pydirs'
    alldirs = set()
    for d in pydirs:
        while d != target_dir:
            alldirs.add(d)
            d = d.parent
    return list(alldirs)


def find_notebook_files(target_dir: Path) -> List[Path]:
    """Find all files ending in ".ipynb", at or below target_dir.
    """
    return list(target_dir.rglob("*.ipynb"))


def strip_special_cells(target_dir: Path):
    """Strip 'special' cells from notebooks below `target_dir`.
    See `strip_tags()` for how that is done.
    """
    nb_files = find_notebook_files(target_dir)
    for nb_file in nb_files:
        if strip_tags(nb_file):
            _log.info(f"removing original file '{nb_file}'")
            nb_file.unlink()


def strip_tags(nb: Path) -> bool:
    """Strip tags from notebook, if there are any.

    Behavior depends on filename (case-insensitive).

    - if the notebook file name ends with "_solution_testing", create two files:

       1. chop off "_testing" from name, remove test and exercise cells
       2. chop off "_solution_testing" from name & add "_exercise", remove solution and test cells

    - if the file ends with "_testing" only, create one file:

        1. chop off "_testing" from name, remove test cells

    - otherwise: do nothing

    Returns:
        Was anything stripped (and new files created)?

    Raises:
        IOError: If the desired target for the stripped notebook already exists
    """
    _log.info(f"stripping tags from notebook {nb}")
    nb_name = nb.stem

    # Figure out which tags to strip:
    # - check for case (1), solution_testing.ipynb
    if nb_name.lower().endswith("_solution_testing"):
        pre = nb_name[:-17]
        names = [f"{pre}_solution.ipynb", f"{pre}_exercise.ipynb"]
        tags_to_strip = [(REMOVE_CELL_TAG, EXERCISE_CELL_TAG), (REMOVE_CELL_TAG, SOLUTION_CELL_TAG)]
    # - check for case (2), _testing.ipynb
    elif nb_name.lower().endswith("_testing"):
        pre = nb_name[:-8]
        names = [f"{pre}.ipynb"]
        tags_to_strip = [(REMOVE_CELL_TAG,)]
    # - if neither, we are done here
    else:
        _log.debug(f"notebook '{nb}' does not need to have tags stripped")
        return False

    # Create all desired tag-stripped copies
    for name, remove_tags in zip(names, tags_to_strip):
        target_nb = nb.parent / name
        _log.info(f"creating new notebook '{target_nb}'")
        # Don't overwrite an existing file
        if target_nb.exists():
            _log.warning(f"cannot create new notebook '{target_nb}': file exists")
            continue
        # Set up configuration for removing specially tagged cells
        conf = Config()
        conf.TagRemovePreprocessor.remove_cell_tags = remove_tags
        conf.NotebookExporter.preprocessors = [
            # this requires the full module path
            "nbconvert.preprocessors.TagRemovePreprocessor"
        ]
        # Convert from Notebook format to Notebook format, stripping tags
        (body, resources) = NotebookExporter(config=conf).from_filename(str(nb))
        # Set up output destination
        wrt = FilesWriter()
        wrt.build_directory = str(target_nb.parent)
        # Write stripped notebook to output file
        wrt.write(body, resources, notebook_name=target_nb.stem)

    return True

def has_tagged_cells(nb: Path):
    """Quickly check whether this notebook has any cells with the "special" tag.

    Returns:
        True = yes, it does; False = no specially tagged cells
    Raises:
        NotebookFormatError, if notebook at 'entry' is not parseable
    """
    # parse the notebook (assuming this is fast; otherwise should cache it)
    try:
        nb = nbformat.read(str(nb), as_version=JUPYTER_NB_VERSION)
    except nbformat.reader.NotJSONError:
        raise ValueError(f"Notebook '{nb}' is not valid JSON")
    # look for tagged cells; return immediately if one is found
    for i, c in enumerate(nb.cells):
        if "tags" in c.metadata and REMOVE_CELL_TAG in c.metadata.tags:
            _log.debug(f"Found {REMOVE_CELL_TAG} tag in cell {i}")
            return True  # can stop now, one is enough
    # no tagged cells
    return False
