from __future__ import annotations

from typing import Any, Dict, List, Sequence, Optional
from pathlib import Path
import sys
import re

from SYS.logger import log, debug
from SYS.item_accessors import extract_item_tags, get_string_list, set_field
from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table

from SYS import models
from SYS import pipeline as ctx
from .. import _shared as sh

normalize_result_input = sh.normalize_result_input
filter_results_by_temp = sh.filter_results_by_temp
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
normalize_hash = sh.normalize_hash
parse_tag_arguments = sh.parse_tag_arguments
expand_tag_groups = sh.expand_tag_groups
merge_sequences = sh.merge_sequences
render_tag_value_templates = sh.render_tag_value_templates
parse_cmdlet_args = sh.parse_cmdlet_args
collapse_namespace_tags = sh.collapse_namespace_tags
should_show_help = sh.should_show_help
get_field = sh.get_field

_FIELD_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
_DETAIL_PANEL_LIMIT = 9


def _normalize_title_for_extract(text: str) -> str:
    """Normalize common separators in titles for matching.

    Helps when sources use unicode dashes or odd whitespace.
    """

    s = str(text or "").strip()
    if not s:
        return s
    # Common unicode dash variants -> '-'
    s = s.replace("\u2013", "-")  # en dash
    s = s.replace("\u2014", "-")  # em dash
    s = s.replace("\u2212", "-")  # minus sign
    s = s.replace("\u2010", "-")  # hyphen
    s = s.replace("\u2011", "-")  # non-breaking hyphen
    s = s.replace("\u2012", "-")  # figure dash
    s = s.replace("\u2015", "-")  # horizontal bar

    # Collapse any whitespace runs (including newlines/tabs) to a single space.
    # Some sources wrap the artist name or title across lines.
    try:
        s = re.sub(r"\s+", " ", s).strip()
    except Exception:
        s = " ".join(s.split())
    return s


def _strip_title_prefix(text: str) -> str:
    s = str(text or "").strip()
    if s.lower().startswith("title:"):
        s = s.split(":", 1)[1].strip()
    return s


def _literal_to_title_pattern_regex(literal: str) -> str:
    """Convert a literal chunk of a template into a regex fragment.

    Keeps punctuation literal, but treats any whitespace run as \\s*.
    """

    out: List[str] = []
    i = 0
    while i < len(literal):
        ch = literal[i]
        if ch.isspace():
            while i < len(literal) and literal[i].isspace():
                i += 1
            out.append(r"\s*")
            continue
        out.append(re.escape(ch))
        i += 1
    return "".join(out)


def _compile_extract_template(template: str) -> tuple[re.Pattern[str], List[str]]:
    """Compile a simple (field) template into a regex.

    Example template:
      (artist) - (album) - (disk)-(track) (title)

    This is *not* user-facing regex: we only support named fields in parentheses.
    """

    tpl = str(template or "").strip()
    if not tpl:
        raise ValueError("empty extract template")

    matches = list(re.finditer(r"\(([^)]+)\)", tpl))
    if not matches:
        raise ValueError("extract template must contain at least one (field)")

    field_names: List[str] = []
    parts: List[str] = [r"^\s*"]
    last_end = 0

    for idx, m in enumerate(matches):
        literal = tpl[last_end:m.start()]
        if literal:
            parts.append(_literal_to_title_pattern_regex(literal))

        raw_name = (m.group(1) or "").strip()
        if not raw_name or not _FIELD_NAME_RE.fullmatch(raw_name):
            raise ValueError(
                f"invalid field name '{raw_name}' (use A-Z, 0-9, underscore)"
            )
        field_names.append(raw_name)

        name_lower = raw_name.lower()
        is_last = idx == (len(matches) - 1)
        if is_last:
            parts.append(rf"(?P<{raw_name}>.+)")
        else:
            # Heuristic: common numeric fields should capture full digit runs.
            # This avoids ambiguous splits like track='2', title='3 ...'.
            if name_lower in {
                    "disk",
                    "disc",
                    "cd",
                    "track",
                    "trk",
                    "episode",
                    "ep",
                    "season",
                    "year",
            }:
                parts.append(rf"(?P<{raw_name}>\d+)")
            else:
                parts.append(rf"(?P<{raw_name}>.+?)")

        last_end = m.end()

    tail = tpl[last_end:]
    if tail:
        parts.append(_literal_to_title_pattern_regex(tail))
    parts.append(r"\s*$")

    rx = "".join(parts)
    return re.compile(rx, flags=re.IGNORECASE), field_names


def _extract_tags_from_title(title_text: str, template: str) -> List[str]:
    """Extract (field)->value from title_text and return ['field:value', ...]."""

    title_clean = _normalize_title_for_extract(_strip_title_prefix(title_text))
    if not title_clean:
        return []

    pattern, field_names = _compile_extract_template(template)
    m = pattern.match(title_clean)
    if not m:
        return []

    out: List[str] = []
    for name in field_names:
        value = (m.group(name) or "").strip()
        if not value:
            continue
        out.append(f"{name}:{value}")
    return out


def _get_title_candidates_for_extraction(
    res: Any,
    existing_tags: Optional[List[str]] = None
) -> List[str]:
    """Return a list of possible title strings in priority order."""

    candidates: List[str] = []

    def add_candidate(val: Any) -> None:
        if val is None:
            return
        s = _normalize_title_for_extract(_strip_title_prefix(str(val)))
        if not s:
            return
        if s not in candidates:
            candidates.append(s)

    # 1) Item's title field (may be a display title, not the title: tag)
    try:
        add_candidate(get_field(res, "title"))
    except Exception:
        pass
    if isinstance(res, dict):
        add_candidate(res.get("title"))

    # 2) title: tag from either store tags or piped tags
    tags = existing_tags if isinstance(existing_tags, list) else _extract_item_tags(res)
    add_candidate(_extract_title_tag(tags) or "")

    # 3) Filename stem
    try:
        path_val = get_field(res, "path")
        if path_val:
            p = Path(str(path_val))
            add_candidate((p.stem or "").strip())
    except Exception:
        pass

    return candidates


def _extract_tags_from_title_candidates(candidates: List[str],
                                        template: str) -> tuple[List[str],
                                                                Optional[str]]:
    """Try candidates in order; return (tags, matched_candidate)."""

    for c in candidates:
        extracted = _extract_tags_from_title(c, template)
        if extracted:
            return extracted, c
    return [], None


def _try_compile_extract_template(
    template: Optional[str],
) -> tuple[Optional[re.Pattern[str]],
           Optional[str]]:
    """Compile template for debug; return (pattern, error_message)."""
    if template is None:
        return None, None
    try:
        pattern, _fields = _compile_extract_template(str(template))
        return pattern, None
    except Exception as exc:
        return None, str(exc)


def _extract_title_tag(tags: List[str]) -> Optional[str]:
    """Return the value of the first title: tag if present."""
    return extract_title_tag_value(tags)


def _extract_item_tags(res: Any) -> List[str]:
    return extract_item_tags(res)


def _set_item_tags(res: Any, tags: List[str]) -> None:
    set_field(res, "tag", tags)


def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
    """Update result object/dict title fields and columns in-place."""
    if not title_value:
        return
    if isinstance(res, models.PipeObject):
        res.title = title_value
        # Update columns if present (Title column assumed index 0)
        columns = getattr(res, "columns", None)
        if isinstance(columns, list) and columns:
            label, *_ = columns[0]
            if str(label).lower() == "title":
                columns[0] = (label, title_value)
    elif isinstance(res, dict):
        res["title"] = title_value
        cols = res.get("columns")
        if isinstance(cols, list):
            updated = []
            changed = False
            for col in cols:
                if isinstance(col, tuple) and len(col) == 2:
                    label, _val = col
                    if str(label).lower() == "title":
                        updated.append((label, title_value))
                        changed = True
                    else:
                        updated.append(col)
                else:
                    updated.append(col)
            if changed:
                res["columns"] = updated


def _matches_target(
    item: Any,
    target_hash: Optional[str],
    target_path: Optional[str],
    target_instance: Optional[str] = None,
) -> bool:
    """Determine whether a result item refers to the given target.

    Important: hashes can collide across backends in this app's UX (same media in
    multiple stores). When target_store is provided, it must match too.
    """

    def norm(val: Any) -> Optional[str]:
        return str(val).lower() if val is not None else None

    target_hash_l = target_hash.lower() if target_hash else None
    target_path_l = target_path.lower() if target_path else None
    target_store_l = target_store.lower() if target_store else None

    if isinstance(item, dict):
        hashes = [norm(item.get("hash"))]
        paths = [norm(item.get("path"))]
        stores = [norm(item.get("store"))]
    else:
        hashes = [norm(get_field(item, "hash"))]
        paths = [norm(get_field(item, "path"))]
        stores = [norm(get_field(item, "store"))]

    if target_store_l:
        if target_store_l not in stores:
            return False

    if target_hash_l and target_hash_l in hashes:
        return True
    if target_path_l and target_path_l in paths:
        return True
    return False


def _update_item_title_fields(item: Any, new_title: str) -> None:
    """Mutate an item to reflect a new title in plain fields and columns."""
    if isinstance(item, models.PipeObject):
        item.title = new_title
        columns = getattr(item, "columns", None)
        if isinstance(columns, list) and columns:
            label, *_ = columns[0]
            if str(label).lower() == "title":
                columns[0] = (label, new_title)
    elif isinstance(item, dict):
        item["title"] = new_title
        cols = item.get("columns")
        if isinstance(cols, list):
            updated_cols = []
            changed = False
            for col in cols:
                if isinstance(col, tuple) and len(col) == 2:
                    label, _val = col
                    if str(label).lower() == "title":
                        updated_cols.append((label, new_title))
                        changed = True
                    else:
                        updated_cols.append(col)
                else:
                    updated_cols.append(col)
            if changed:
                item["columns"] = updated_cols


def _refresh_result_table_title(
    new_title: str,
    target_hash: Optional[str],
    target_instance: Optional[str],
    target_path: Optional[str],
) -> None:
    """Refresh the cached result table with an updated title and redisplay it."""
    try:
        last_table = ctx.get_last_result_table()
        items = ctx.get_last_result_items()
        if not last_table or not items:
            return

        updated_items = []
        match_found = False
        for item in items:
            try:
                if _matches_target(item, target_hash, target_path, target_store):
                    _update_item_title_fields(item, new_title)
                    match_found = True
            except Exception:
                pass
            updated_items.append(item)
        if not match_found:
            return

        new_table = last_table.copy_with_title(getattr(last_table, "title", ""))

        for item in updated_items:
            new_table.add_result(item)

        # Keep the underlying history intact; update only the overlay so @.. can
        # clear the overlay then continue back to prior tables (e.g., the search list).
        publish_result_table(ctx, new_table, updated_items, overlay=True)
    except Exception:
        pass


def _refresh_tag_view(
    res: Any,
    target_hash: Optional[str],
    store_name: Optional[str],
    target_path: Optional[str],
    config: Dict[str,
                 Any],
) -> None:
    """Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
    try:
        from cmdlet import get as get_cmdlet  # type: ignore
    except Exception:
        return

    if not target_hash:
        return

    get_tag = None
    try:
        get_tag = get_cmdlet("metadata")
    except Exception:
        get_tag = None
    if not callable(get_tag):
        return

    try:
        subject = ctx.get_last_result_subject()
        if not subject or not _matches_target(subject, target_hash, target_path, store_name):
            return

        refresh_args: List[str] = ["-get", "-query", f"hash:{target_hash}"]
        
        # Build a lean subject so get-tag fetches fresh tags instead of reusing cached payloads.
        def _build_refresh_subject() -> Dict[str, Any]:
            payload: Dict[str, Any] = {}
            payload["hash"] = target_hash
            if sh.value_has_content(store_name):
                payload["store"] = store_name

            path_value = target_path or get_field(subject, "path")
            if not sh.value_has_content(path_value):
                path_value = get_field(subject, "target")
            if sh.value_has_content(path_value):
                payload["path"] = path_value

            for key in ("title", "name", "url", "relations", "service_name"):
                val = get_field(subject, key)
                if sh.value_has_content(val):
                    payload[key] = val

            extra_value = get_field(subject, "extra")
            if isinstance(extra_value, dict):
                cleaned = {
                    k: v for k, v in extra_value.items()
                    if str(k).lower() not in {"tag", "tags"}
                }
                if cleaned:
                    payload["extra"] = cleaned
            elif sh.value_has_content(extra_value):
                payload["extra"] = extra_value

            return payload

        refresh_subject = _build_refresh_subject()
        with ctx.suspend_live_progress():
            get_tag(refresh_subject, refresh_args, config)
    except Exception:
        pass


class Add_Tag(Cmdlet):
    """Class-based metadata -add tag handler with Cmdlet metadata inheritance."""

    def __init__(self, *, register_cmdlet: bool = True) -> None:
        super().__init__(
            name="tag",
            summary="Add tag to a file in a store.",
            usage=
            'metadata -add -instance <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
            arg=[
                CmdletArg(
                    "tag",
                    type="string",
                    required=False,
                    description=
                    "One or more tag to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tag from pipeline payload.",
                    variadic=True,
                ),
                SharedArgs.QUERY,
                SharedArgs.INSTANCE,
                CmdletArg(
                    "-extract",
                    type="string",
                    description=
                    'Extract tags from the item\'s title using a simple template with (field) placeholders. Example: -extract "(artist) - (album) - (disk)-(track) (title)" will add artist:, album:, disk:, track:, title: tags.',
                ),
                CmdletArg(
                    "--extract-debug",
                    type="flag",
                    description=
                    "Print debug info for -extract matching (matched title source and extracted tags).",
                ),
                CmdletArg(
                    "-duplicate",
                    type="string",
                    description=
                    "Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)",
                ),
                CmdletArg(
                    "-list",
                    type="string",
                    description=
                    "Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult).",
                ),
                CmdletArg(
                    "--all",
                    type="flag",
                    description=
                    "Include temporary files in tagging (by default, only tag non-temporary files).",
                ),
            ],
            detail=[
                "- By default, only tag non-temporary files (from pipelines). Use --all to tag everything.",
                "- Requires a store backend: use -instance or pipe items that include store.",
                "- If -query is not provided, uses the piped item's hash (or derives from its path when possible).",
                "- Multiple tag can be comma-separated or space-separated.",
                "- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
                '- tag can also reference lists with curly braces: metadata -add {philosophy} "other:tag"',
                "- Use -duplicate to copy EXISTING tag values to new namespaces:",
                "  Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
                "  Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
                "- The source namespace must already exist in the file being tagged.",
                "- Target namespaces that already have a value are skipped (not overwritten).",
                "- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.",
                "- Use #(namespace) inside a tag value to insert existing values, e.g. metadata -add \"title:#(track) - #(series)\".",
                "- Use angle-bracket transforms for advanced formatting, e.g. metadata -add \"code:e<padding(00,#(episode))>\".",
                "- Current documented transforms include padding, default, replace, and increment.",
                "- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
                "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
            ],
            exec=self.run,
        )
        if register_cmdlet:
            self.register()

    def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
        """Add tag to a file with smart filtering for pipeline results."""
        if should_show_help(args):
            log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
            return 0

        # Parse arguments
        parsed = parse_cmdlet_args(args, self)

        extract_template = parsed.get("extract")
        if extract_template is not None:
            extract_template = str(extract_template)

        extract_debug = bool(parsed.get("extract-debug", False))
        extract_debug_rx, extract_debug_err = _try_compile_extract_template(extract_template)

        raw_tag = parsed.get("tag", [])
        if isinstance(raw_tag, str):
            raw_tag = [raw_tag]

        # Normalize input early so a non-hash -query can be treated as the tag payload
        # when the target item is already coming from the pipeline.
        results = normalize_result_input(result)

        query_value = parsed.get("query")
        query_hash, query_valid = sh.require_single_hash_query(
            query_value,
            "[add_tag] Error: -query must be of the form hash:<sha256>",
            log_file=sys.stderr,
        )
        if not query_valid:
            if not raw_tag and results and query_value:
                raw_tag = [str(query_value)]
                query_hash = None
            else:
                return 1

        hash_override = query_hash

        # If add-tag is in the middle of a pipeline (has downstream stages), default to
        # including temp files. This enables common flows like:
        #   @N | download-file | add-tag ... | add-file ...
        store_override = parsed.get("instance")
        stage_ctx = ctx.get_stage_context()
        is_last_stage = (stage_ctx is None) or bool(
            getattr(stage_ctx, "is_last_stage", False)
        )
        has_downstream = bool(
            stage_ctx is not None and not getattr(stage_ctx,
                                                  "is_last_stage",
                                                  False)
        )

        include_temp = bool(parsed.get("all", False))
        if has_downstream and not include_temp and not store_override:
            include_temp = True

        # Filter by temp status (unless --all is set)
        if not include_temp:
            results = filter_results_by_temp(results, include_temp=False)

        # When no pipeline payload is present but -query/-instance pinpoints a hash, tag it directly.
        if not results and hash_override and store_override:
            results = [{"hash": hash_override, "store": store_override}]

        if not results:
            log(
                "No valid files to tag (all results were temporary; use --all to include temporary files)",
                file=sys.stderr,
            )
            return 1

        # Fallback: if no tag provided explicitly, try to pull from first result payload.
        # IMPORTANT: when -extract is used, users typically want *only* extracted tags,
        # not "re-add whatever tags are already in the payload".
        if not raw_tag and results and not extract_template:
            first = results[0]
            payload_tag = None

            # Try multiple tag lookup strategies in order
            tag_lookups = [
                lambda x: getattr(x, "tag", None),
                lambda x: x.get("tag") if isinstance(x, dict) else None,
            ]

            for lookup in tag_lookups:
                try:
                    payload_tag = lookup(first)
                    if payload_tag:
                        break
                except (AttributeError, TypeError, KeyError):
                    continue

            if payload_tag:
                if isinstance(payload_tag, str):
                    raw_tag = [payload_tag]
                elif isinstance(payload_tag, list):
                    raw_tag = payload_tag

        # Handle -list argument (convert to {list} syntax)
        list_arg = parsed.get("list")
        if list_arg:
            for l in list_arg.split(","):
                l = l.strip()
                if l:
                    raw_tag.append(f"{{{l}}}")

        # Parse and expand tag
        tag_to_add = parse_tag_arguments(raw_tag)
        tag_to_add = expand_tag_groups(tag_to_add)

        if not tag_to_add and not extract_template:
            log(
                "No tag provided to add (and no -extract template provided)",
                file=sys.stderr
            )
            return 1

        if extract_template and extract_debug and extract_debug_err:
            log(
                f"[add_tag] extract template error: {extract_debug_err}",
                file=sys.stderr
            )
            return 1

        # Get other flags
        duplicate_arg = parsed.get("duplicate")

        # tag ARE provided - apply them to each store-backed result
        total_added = 0
        total_modified = 0
        unresolved_template_count = 0
        store_registry: Any = None

        def _resolve_backend(name: Optional[str]) -> tuple[Any | None, Any, Exception | None]:
            nonlocal store_registry
            backend_name = str(name or "").strip()
            if not backend_name:
                return None, store_registry, KeyError("Missing store name")
            if backend_name in _backend_instance_cache:
                return _backend_instance_cache[backend_name], store_registry, None
            try:
                backend, registry, exc = sh.get_preferred_store_backend(
                    config,
                    backend_name,
                    store_registry=store_registry,
                    suppress_debug=True,
                )
            except TypeError as exc2:
                # Tests may monkeypatch get_store_backend with a reduced signature.
                if "store_registry" in str(exc2):
                    backend, registry, exc = sh.get_store_backend(
                        config,
                        backend_name,
                        suppress_debug=True,
                    )
                else:
                    raise
            if registry is not None:
                store_registry = registry
            if backend is not None:
                _backend_instance_cache[backend_name] = backend
            return backend, store_registry, exc

        pending_bulk_add: Dict[tuple[int, tuple[str, ...], tuple[str, ...]], Dict[str, Any]] = {}
        _backend_instance_cache: Dict[str, Any] = {}

        extract_matched_items = 0
        extract_no_match_items = 0
        display_items: List[Any] = []

        for res in results:
            store_name: Optional[str]
            raw_hash: Optional[str]
            raw_path: Optional[str]

            if isinstance(res, models.PipeObject):
                store_name = store_override or res.store
                raw_hash = res.hash
                raw_path = res.path
            elif isinstance(res, dict):
                store_name = store_override or res.get("store")
                raw_hash = res.get("hash")
                raw_path = res.get("path")
            else:
                ctx.emit(res)
                continue

            if not store_name:
                store_name = None

            # If the item isn't in a configured store backend yet (e.g., store=PATH) but has a local file,
            # treat add-tag as a pipeline mutation (carry tags forward for add-file) instead of a store write.
            if not store_override:
                store_name_str = str(store_name) if store_name is not None else ""
                
                is_known_backend = False
                try:
                    backend_probe, store_registry, _probe_exc = _resolve_backend(store_name_str)
                    is_known_backend = backend_probe is not None
                except Exception:
                    pass

                # If the item isn't in a configured store backend yet (e.g., store=PATH),
                # treat add-tag as a pipeline mutation (carry tags forward for add-file) 
                # instead of a store write.
                if not is_known_backend:
                    try:
                        # We allow metadata updates even if file doesn't exist locally, 
                        # but check path existence if valid path provided.
                        proceed_local = True
                        if raw_path:
                            try:
                                if not Path(str(raw_path)).expanduser().exists():
                                    # If path is provided but missing, we might prefer skipping?
                                    # But for pipeline metadata, purely missing file shouldn't block tagging.
                                    # So we allow it.
                                    pass
                            except Exception:
                                pass
                        
                        if proceed_local:
                            existing_tag_list = _extract_item_tags(res)
                            existing_lower = {
                                t.lower()
                                for t in existing_tag_list if isinstance(t, str)
                            }

                            item_tag_to_add = list(tag_to_add)

                            if extract_template:
                                candidates = _get_title_candidates_for_extraction(
                                    res,
                                    existing_tag_list
                                )
                                extracted, matched = _extract_tags_from_title_candidates(
                                    candidates, extract_template
                                )
                                if extracted:
                                    extract_matched_items += 1
                                    if extract_debug:
                                        log(
                                            f"[add_tag] extract matched: {matched!r} -> {extracted}",
                                            file=sys.stderr,
                                        )
                                    for new_tag in extracted:
                                        if new_tag.lower() not in existing_lower:
                                            item_tag_to_add.append(new_tag)
                                else:
                                    extract_no_match_items += 1
                                    if extract_debug:
                                        rx_preview = (
                                            extract_debug_rx.pattern
                                            if extract_debug_rx else "<uncompiled>"
                                        )
                                        cand_preview = "; ".join(
                                            [repr(c) for c in candidates[:3]]
                                        )
                                        log(
                                            f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview!r}. candidates: {cand_preview}",
                                            file=sys.stderr,
                                        )

                            item_tag_to_add = collapse_namespace_tags(
                                item_tag_to_add,
                                "title",
                                prefer="last"
                            )

                            if duplicate_arg:
                                parts = str(duplicate_arg).split(":")
                                source_ns = ""
                                targets: list[str] = []

                                if len(parts) > 1:
                                    source_ns = parts[0]
                                    targets = [
                                        t.strip() for t in parts[1].split(",")
                                        if t.strip()
                                    ]
                                else:
                                    parts2 = str(duplicate_arg).split(",")
                                    if len(parts2) > 1:
                                        source_ns = parts2[0]
                                        targets = [
                                            t.strip() for t in parts2[1:] if t.strip()
                                        ]

                                if source_ns and targets:
                                    source_prefix = source_ns.lower() + ":"
                                    for t in existing_tag_list:
                                        if not t.lower().startswith(source_prefix):
                                            continue
                                        value = t.split(":", 1)[1]
                                        for target_ns in targets:
                                            new_tag = f"{target_ns}:{value}"
                                            if new_tag.lower() not in existing_lower:
                                                item_tag_to_add.append(new_tag)

                            item_tag_to_add, unresolved_templates = render_tag_value_templates(
                                item_tag_to_add,
                                existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True),
                                result=res,
                            )
                            unresolved_template_count += len(unresolved_templates)

                            item_tag_to_add = collapse_namespace_tags(
                                item_tag_to_add,
                                "title",
                                prefer="last"
                            )

                            removed_namespace_tag: list[str] = []
                            for new_tag in item_tag_to_add:
                                if not isinstance(new_tag, str) or ":" not in new_tag:
                                    continue
                                ns = new_tag.split(":", 1)[0].strip()
                                if not ns:
                                    continue
                                ns_prefix = ns.lower() + ":"
                                for t in existing_tag_list:
                                    if (t.lower().startswith(ns_prefix)
                                            and t.lower() != new_tag.lower()):
                                        removed_namespace_tag.append(t)
                            removed_namespace_tag = sorted(
                                {t
                                 for t in removed_namespace_tag}
                            )

                            actual_tag_to_add = [
                                t for t in item_tag_to_add if isinstance(t, str)
                                and t.lower() not in existing_lower
                            ]

                            updated_tag_list = [
                                t for t in existing_tag_list
                                if t not in removed_namespace_tag
                            ]
                            updated_tag_list.extend(actual_tag_to_add)

                            updated_tag_list = collapse_namespace_tags(
                                updated_tag_list,
                                "title",
                                prefer="last"
                            )

                            _set_item_tags(res, updated_tag_list)
                            final_title = _extract_title_tag(updated_tag_list)
                            _apply_title_to_result(res, final_title)

                            total_added += len(actual_tag_to_add)
                            total_modified += (
                                1 if (removed_namespace_tag or actual_tag_to_add) else 0
                            )

                            ctx.emit(res)
                            continue
                    except Exception:
                        pass

                if store_name_str and not is_known_backend:
                    # If it's not a known backend and we didn't handle it above as a local/pipeline 
                    # metadata edit, then it's an error.
                    log(
                        f"[add_tag] Error: Unknown store '{store_name_str}'",
                        file=sys.stderr,
                    )
                    return 1

            resolved_hash = sh.resolve_hash_for_cmdlet(
                str(raw_hash) if raw_hash else None,
                str(raw_path) if raw_path else None,
                str(hash_override) if hash_override else None,
            )

            if not resolved_hash:
                log(
                    "[add_tag] Warning: Item missing usable hash (and could not derive from path); skipping",
                    file=sys.stderr,
                )
                ctx.emit(res)
                continue

            backend, store_registry, exc = _resolve_backend(str(store_name))
            if backend is None:
                log(
                    f"[add_tag] Error: Unknown store '{store_name}': {exc}",
                    file=sys.stderr
                )
                return 1

            inline_tags = _extract_item_tags(res)
            use_inline_tags = bool(inline_tags)

            if use_inline_tags:
                existing_tag_list = [t for t in inline_tags if isinstance(t, str)]
            else:
                try:
                    existing_tag, _src = backend.get_tag(resolved_hash, config=config)
                except Exception:
                    existing_tag = []
                existing_tag_list = [t for t in (existing_tag or []) if isinstance(t, str)]
            existing_lower = {t.lower()
                              for t in existing_tag_list}
            original_title = _extract_title_tag(existing_tag_list)

            # Per-item tag list (do not mutate shared list)
            item_tag_to_add = list(tag_to_add)

            if extract_template:
                candidates2 = _get_title_candidates_for_extraction(
                    res,
                    existing_tag_list
                )
                extracted2, matched2 = _extract_tags_from_title_candidates(
                    candidates2, extract_template
                )
                if extracted2:
                    extract_matched_items += 1
                    if extract_debug:
                        log(
                            f"[add_tag] extract matched: {matched2!r} -> {extracted2}",
                            file=sys.stderr,
                        )
                    for new_tag in extracted2:
                        if new_tag.lower() not in existing_lower:
                            item_tag_to_add.append(new_tag)
                else:
                    extract_no_match_items += 1
                    if extract_debug:
                        rx_preview2 = (
                            extract_debug_rx.pattern
                            if extract_debug_rx else "<uncompiled>"
                        )
                        cand_preview2 = "; ".join([repr(c) for c in candidates2[:3]])
                        log(
                            f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview2!r}. candidates: {cand_preview2}",
                            file=sys.stderr,
                        )

            item_tag_to_add = collapse_namespace_tags(
                item_tag_to_add,
                "title",
                prefer="last"
            )

            # Handle -duplicate logic (copy existing tag to new namespaces)
            if duplicate_arg:
                parts = str(duplicate_arg).split(":")
                source_ns = ""
                targets: list[str] = []

                if len(parts) > 1:
                    source_ns = parts[0]
                    targets = [t.strip() for t in parts[1].split(",") if t.strip()]
                else:
                    parts2 = str(duplicate_arg).split(",")
                    if len(parts2) > 1:
                        source_ns = parts2[0]
                        targets = [t.strip() for t in parts2[1:] if t.strip()]

                if source_ns and targets:
                    source_prefix = source_ns.lower() + ":"
                    for t in existing_tag_list:
                        if not t.lower().startswith(source_prefix):
                            continue
                        value = t.split(":", 1)[1]
                        for target_ns in targets:
                            new_tag = f"{target_ns}:{value}"
                            if new_tag.lower() not in existing_lower:
                                item_tag_to_add.append(new_tag)

            item_tag_to_add, unresolved_templates = render_tag_value_templates(
                item_tag_to_add,
                existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True),
                result=res,
            )
            unresolved_template_count += len(unresolved_templates)

            item_tag_to_add = collapse_namespace_tags(
                item_tag_to_add,
                "title",
                prefer="last"
            )

            if not any(
                isinstance(t, str) and t.strip().lower().startswith("title:")
                for t in item_tag_to_add
            ):
                existing_title_tags = [
                    t for t in existing_tag_list
                    if isinstance(t, str) and t.strip().lower().startswith("title:")
                ]
                if len(existing_title_tags) > 1:
                    item_tag_to_add.append(existing_title_tags[-1])
                    item_tag_to_add = collapse_namespace_tags(
                        item_tag_to_add,
                        "title",
                        prefer="last"
                    )

            changed = False
            refreshed_list = list(existing_tag_list)
            try:
                from SYS.metadata import compute_namespaced_tag_overwrite
            except Exception:
                compute_namespaced_tag_overwrite = None  # type: ignore

            tags_to_remove: List[str] = []
            tags_to_add: List[str] = []
            merged_tags: List[str] = list(existing_tag_list)
            if compute_namespaced_tag_overwrite:
                try:
                    tags_to_remove, tags_to_add, merged_tags = compute_namespaced_tag_overwrite(
                        existing_tag_list,
                        item_tag_to_add,
                    )
                except Exception:
                    tags_to_remove = []
                    tags_to_add = []
                    merged_tags = list(existing_tag_list)

            queued_bulk = False
            ok_add = False
            add_tags_bulk_fn = getattr(backend, "add_tags_bulk", None)
            if tags_to_add and callable(add_tags_bulk_fn):
                add_key = tuple(sorted({str(t).strip().lower() for t in tags_to_add if str(t).strip()}))
                remove_key = tuple(sorted({str(t).strip().lower() for t in tags_to_remove if str(t).strip()}))
                if add_key:
                    batch_key = (id(backend), add_key, remove_key)
                    bucket = pending_bulk_add.get(batch_key)
                    if bucket is None:
                        bucket = {
                            "backend": backend,
                            "add_tags": list(add_key),
                            "remove_tags": list(remove_key),
                            "hashes": [],
                        }
                        pending_bulk_add[batch_key] = bucket
                    bucket["hashes"].append(resolved_hash)
                    queued_bulk = True
                    ok_add = True

            if not queued_bulk:
                try:
                    ok_add = backend.add_tag(
                        resolved_hash,
                        item_tag_to_add,
                        config=config,
                        existing_tags=existing_tag_list,
                    )
                    if not ok_add:
                        log("[add_tag] Warning: Store rejected tag update", file=sys.stderr)
                except Exception as exc:
                    log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
                    ok_add = False

            if ok_add and merged_tags:
                refreshed_list = list(merged_tags)
            else:
                refreshed_list = list(existing_tag_list)

            if tags_to_add or tags_to_remove:
                changed = True
                total_added += len(tags_to_add)
                total_modified += 1

            # Update the result's tag using canonical field
            if isinstance(res, models.PipeObject):
                res.tag = refreshed_list
            elif isinstance(res, dict):
                res["tag"] = refreshed_list

            final_title = _extract_title_tag(refreshed_list)
            _apply_title_to_result(res, final_title)

            if final_title and (not original_title or final_title != original_title) and not is_last_stage:
                _refresh_result_table_title(
                    final_title,
                    resolved_hash,
                    str(store_name),
                    raw_path
                )

            if changed and not use_inline_tags:
                _refresh_tag_view(res, resolved_hash, str(store_name), raw_path, config)

            if is_last_stage:
                display_items.append(res)

            ctx.emit(res)

        for bucket in pending_bulk_add.values():
            backend = bucket.get("backend")
            add_tags_for_batch = list(bucket.get("add_tags") or [])
            remove_tags_for_batch = list(bucket.get("remove_tags") or [])
            hashes_for_batch = [str(h).strip().lower() for h in (bucket.get("hashes") or []) if str(h).strip()]
            if backend is None or not hashes_for_batch:
                continue

            batch_items = [(h, list(add_tags_for_batch), list(remove_tags_for_batch)) for h in hashes_for_batch]
            add_tags_bulk_fn = getattr(backend, "add_tags_bulk", None)
            applied = False
            if callable(add_tags_bulk_fn):
                try:
                    applied = bool(add_tags_bulk_fn(batch_items))
                except Exception:
                    applied = False

            if applied:
                continue

            # Fallback path: retain correctness if backend bulk call fails.
            for h in hashes_for_batch:
                try:
                    backend.add_tag(h, list(add_tags_for_batch), config=config)
                except Exception as exc:
                    log(f"[add_tag] Warning: Failed fallback add_tag for {h}: {exc}", file=sys.stderr)

        log(
            f"[add_tag] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
            file=sys.stderr,
        )

        if is_last_stage and display_items:
            try:
                live_progress = ctx.get_live_progress()
            except Exception:
                live_progress = None

            if live_progress is not None:
                try:
                    pipe_idx = getattr(stage_ctx, "pipe_index", None)
                    if isinstance(pipe_idx, int):
                        live_progress.finish_pipe(int(pipe_idx), force_complete=True)
                except Exception:
                    pass
                try:
                    live_progress.stop()
                except Exception:
                    pass
                try:
                    if hasattr(ctx, "set_live_progress"):
                        ctx.set_live_progress(None)
                except Exception:
                    pass

            try:
                from SYS.rich_display import render_item_details_panel
                from SYS.result_table import Table

                subject = display_items[0] if len(display_items) == 1 else list(display_items)
                # Use helper to display items and make them @-selectable
                from ._shared import display_and_persist_items
                display_type = "item" if len(display_items) <= _DETAIL_PANEL_LIMIT else "custom"
                display_and_persist_items(
                    list(display_items),
                    title="Result",
                    subject=subject,
                    display_type=display_type,
                )
            except Exception:
                pass

            try:
                if stage_ctx is not None:
                    stage_ctx.emits = []
            except Exception:
                pass

        if extract_template and extract_matched_items == 0:
            log(
                f"[add_tag] extract: no matches for template '{extract_template}' across {len(results)} item(s)",
                file=sys.stderr,
            )
        elif extract_template and extract_no_match_items > 0 and extract_debug:
            log(
                f"[add_tag] extract: matched {extract_matched_items}, no-match {extract_no_match_items}",
                file=sys.stderr,
            )

        if unresolved_template_count > 0:
            log(
                f"[add_tag] skipped {unresolved_template_count} tag template(s) with unresolved #(namespace) placeholders",
                file=sys.stderr,
            )

        return 0


