# openclatura/assembler.py

import re

from .assembly_charge import (
    positive_parent_n_charges,
)
from .assembly_parent import (
    apply_replacement_prefix,
    format_parent_tail,
    format_substituent_tail,
    parent_stem_and_terminal,
    promote_benzene_retained_name,
    promote_retained_functional_parent,
    promote_retained_substituent_name,
)
from .assembly_parts import AssemblyParts
from .assembly_prefixes import format_replacement_prefixes, format_substituent_prefixes
from .assembly_spiro import format_spiro_core, split_spiro_substituents
from .assembly_utils import needs_hyphen, parse_locant
from .formatting import ensure_stereo_descriptor_boundary, format_multiplier
from .fused_ion_templates import consume_fused_ion_operation, select_fused_ion_operation
from .name_assembly import NameAssemblyResult, rewrite_history_trace_data, token_span_trace_data
from .name_bindings import refresh_name_atom_bindings, refresh_parent_binding
from .name_postprocessing import (
    apply_acyl_amido_postprocessing,
    apply_connection_boundary_postprocessing,
    apply_data_postprocessing,
)

LEGACY_POSTPROCESS_LITERAL_REPLACEMENTS = (
    ("1-hydroxymethanoic acid", "carbonic acid"),
    ("1-hydroxymethanoate", "carbonate"),
    ("1-hydroxymethanamide", "carbamic acid"),
    ("1-hydroxymethanenitrile", "cyanic acid"),
    ("1-hydroxymethanoyl", "carboxy"),
    ("1-hydroxymethanoic anhydride", "dicarbonic acid"),
    ("hydroxymethanoic acid", "carbonic acid"),
    ("hydroxymethanoate", "carbonate"),
    ("hydroxymethanamide", "carbamic acid"),
    ("hydroxymethanenitrile", "cyanic acid"),
    ("hydroxymethanoyl", "carboxy"),
    ("hydroxymethanoic anhydride", "dicarbonic acid"),
    ("benzene-1-carboxylic acid", "benzoic acid"),
    ("benzene-1-carboxamide", "benzamide"),
    ("benzene-1-carboxylate", "benzoate"),
    ("benzene-1-carbonitrile", "benzonitrile"),
    ("benzene-1-carbaldehyde", "benzaldehyde"),
    ("benzene-1-carbonyl", "benzoyl"),
    ("methanoyl", "formyl"),
    ("ethanoyl", "acetyl"),
    ("methylcarbonyloxy", "acetoxy"),
    ("methylcarbonyl", "acetyl"),
    ("ethylcarbonyl", "propionyl"),
    ("propylcarbonyl", "butyryl"),
    ("phenylcarbonyl", "benzoyl"),
    ("(methylcarbonyl)oxy", "acetoxy"),
    ("(ethylcarbonyl)oxy", "propionyloxy"),
    ("(phenylcarbonyl)oxy", "benzoyloxy"),
    ("aminocarbonothioyl", "carbamothioyl"),
    ("(phenylsulfonyl)amino", "benzenesulfonamido"),
    ("phenylsulfonylamino", "benzenesulfonamido"),
    ("(benzenesulfonyl)amino", "benzenesulfonamido"),
    ("benzenesulfonylamino", "benzenesulfonamido"),
    ("(methanesulfonyl)amino", "methanesulfonamido"),
    ("methanesulfonylamino", "methanesulfonamido"),
    ("(ethanesulfonyl)amino", "ethanesulfonamido"),
    ("ethanesulfonylamino", "ethanesulfonamido"),
    ("(chloromethyl)carbonyl", "chloroacetyl"),
    ("((chloromethyl)carbonyl)amino", "2-chloroacetamido"),
    ("(prop-2-enoyl)amino", "acrylamido"),
    ("prop-2-enoylamino", "acrylamido"),
    ("prop-2-enoyloxy", "acryloyloxy"),
    ("1-oxoethan-1-yl", "acetyl"),
    ("1-oxopropan-1-yl", "propionyl"),
    ("1-oxobutan-1-yl", "butyryl"),
    ("1-oxopentan-1-yl", "pentanoyl"),
    ("1-oxohexan-1-yl", "hexanoyl"),
    ("1-oxoethyl", "acetyl"),
    ("1-oxopropyl", "propionyl"),
    ("1-oxobutyl", "butyryl"),
    ("1-oxopentyl", "pentanoyl"),
    ("1-oxohexyl", "hexanoyl"),
    ("benzylcarbonyl", "phenylacetyl"),
    ("methanehydrazine", "methylhydrazine"),
    ("ethanehydrazine", "ethylhydrazine"),
    ("propanehydrazine", "propylhydrazine"),
    ("benzenehydrazine", "phenylhydrazine"),
    ("fluoroethanoyl", "fluoroacetyl"),
    ("chloroethanoyl", "chloroacetyl"),
    ("bromoethanoyl", "bromoacetyl"),
    ("iodoethanoyl", "iodoacetyl"),
)

_LEGACY_SUBST_ALKYL_ACYL = {"ethylcarbonyl", "propylcarbonyl"}


def _compile_legacy_replacements() -> tuple[tuple[str, re.Pattern, str], ...]:
    """Precompile the literal-replacement patterns once (they are name-independent).

    Each entry keeps its literal so callers can skip the regex entirely when the literal
    is absent -- every pattern requires it as a substring, so the sub would be a no-op.
    """

    compiled: list[tuple[str, re.Pattern, str]] = []
    for old, new in LEGACY_POSTPROCESS_LITERAL_REPLACEMENTS:
        esc = re.escape(old)
        if old in _LEGACY_SUBST_ALKYL_ACYL:
            compiled.append((old, re.compile(rf"(?<![a-zA-Z)]){esc}(?![a-zA-Z])"), new))
        else:
            compiled.append((old, re.compile(rf"(?<![a-zA-Z]){esc}(?![a-zA-Z])"), new))
    return tuple(compiled)


_LEGACY_COMPILED_REPLACEMENTS = _compile_legacy_replacements()


def _balanced_group_end(name: str, start: int) -> int | None:
    """If ``name[start]`` opens a parenthesis, return the index just past its match."""

    if start >= len(name) or name[start] != "(":
        return None
    depth = 0
    for i in range(start, len(name)):
        if name[i] == "(":
            depth += 1
        elif name[i] == ")":
            depth -= 1
            if depth == 0:
                return i + 1
    return None


def _oxo_group_methyl_to_carbonyl(name: str) -> str:
    """Contract a one-carbon ``methyl`` parent bearing ``(oxo)`` and one parenthesized
    group into ``(group)carbonyl``, for either substituent order.

    Paren-aware replacement for two regexes that mis-matched the inner ``)methyl`` of a
    group ending in a substituent methyl (e.g. heteroaryl-methyl), which corrupted names
    like ``(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl``.
    """

    def _word_char(idx: int) -> bool:
        return idx < len(name) and (name[idx].isalnum() or name[idx] == "_")

    out: list[str] = []
    i = 0
    while i < len(name):
        # (oxo)(GROUP)methyl
        if name.startswith("(oxo)", i):
            g_end = _balanced_group_end(name, i + 5)
            if g_end is not None and name.startswith("methyl", g_end) and not _word_char(g_end + 6):
                out.append(name[i + 5 : g_end] + "carbonyl")
                i = g_end + 6
                continue
        # (GROUP)(oxo)methyl
        if name[i] == "(":
            g_end = _balanced_group_end(name, i)
            if g_end is not None and name.startswith("(oxo)methyl", g_end) and not _word_char(g_end + 11):
                out.append(name[i:g_end] + "carbonyl")
                i = g_end + 11
                continue
        out.append(name[i])
        i += 1
    return "".join(out)


def _post_process_name(name: str) -> str:
    name = apply_data_postprocessing(name)
    name = ensure_stereo_descriptor_boundary(name)
    name = re.sub(r"-1-formate\b", "-formate", name)

    name = _oxo_group_methyl_to_carbonyl(name)
    name = name.replace("(oxo)methyl", "formyl")
    name = re.sub(r"(?<!thi)oxomethyl\b", "formyl", name)
    name = name.replace("thioxomethyl", "carbonothioyl")

    name = name.replace("aminoiminomethyl", "carbamimidoyl")
    name = name.replace("amino(imino)methyl", "carbamimidoyl")
    name = name.replace("iminoamino", "diazenyl")
    name = name.replace("aminoimino", "hydrazono")

    name = re.sub(r"\b1-\((.*?)amino\)methanenitrile\b", r"\1cyanamide", name)
    name = re.sub(r"\b1-([a-zA-Z0-9\-\[\]\(\)\,]+?)aminomethanenitrile\b", r"\1cyanamide", name)
    name = name.replace("aminomethanenitrile", "cyanamide")

    # Substituted-alkyl acyl contractions (ethyl/propyl) renumber the chain, so their
    # patterns exclude a leading group paren; see _compile_legacy_replacements.
    for _, pattern, new in _LEGACY_COMPILED_REPLACEMENTS:
        name = pattern.sub(new, name)

    name = apply_data_postprocessing(name)

    name = re.sub(r"(?<!m)ethanoyl\b", "acetyl", name)

    name = apply_acyl_amido_postprocessing(name)

    name = re.sub(r"\b1-methanethiol\b", "methanethiol", name)
    name = re.sub(r"-1-methanethiol\b", "methanethiol", name)

    name = re.sub(r"\b([a-z]+o)iminomethyl\b", r"\1carbonimidoyl", name)
    name = re.sub(r"\bimino\(([a-z]+o)\)methyl\b", r"\1carbonimidoyl", name)
    name = re.sub(r"\bimino([a-z]+o)methyl\b", r"\1carbonimidoyl", name)

    name = re.sub(r"ane-(\d+(?:,\d+)*)-((?:di|tri)?yl(?:idene|idyne)?)\b", r"an-\1-\2", name)
    name = re.sub(r"ene-(\d+(?:,\d+)*)-((?:di|tri)?yl(?:idene|idyne)?)\b", r"en-\1-\2", name)
    name = re.sub(r"yne-(\d+(?:,\d+)*)-((?:di|tri)?yl(?:idene|idyne)?)\b", r"yn-\1-\2", name)

    name = re.sub(r"one-(\d+(?:,\d+)*)-((?:di|tri)?yl(?:idene|idyne)?)\b", r"on-\1-\2", name)
    name = name.replace("one-diyl", "on-diyl")
    name = name.replace("one-yl", "on-yl")

    name = apply_data_postprocessing(name)

    name = name.replace("methan-1-one hydrazone", "formaldehyde hydrazone")
    name = name.replace("methanone hydrazone", "formaldehyde hydrazone")
    name = name.replace("methanal hydrazone", "formaldehyde hydrazone")
    name = name.replace("ethanal hydrazone", "acetaldehyde hydrazone")

    name = name.replace("oxo(hydroxy)aminooxy", "nitrooxy")
    name = name.replace("(oxo(hydroxy)amino)oxy", "nitrooxy")
    name = name.replace("(oxo)(oxido)aminooxy", "nitrooxy")
    name = name.replace("((oxo)(oxido)amino)oxy", "nitrooxy")

    name = re.sub(r"\b(amino|imino)([a-z]+oxy)methyl\b", r"\1(\2)methyl", name)
    name = re.sub(r"\b(amino|imino)([a-z]+oxy)methylidene\b", r"\1(\2)methylidene", name)

    name = re.sub(r"ane-(\d+(?:,\d+)*)-hydrazine\b", r"an-\1-ylhydrazine", name)
    name = re.sub(r"ene-(\d+(?:,\d+)*)-hydrazine\b", r"en-\1-ylhydrazine", name)
    name = re.sub(r"yne-(\d+(?:,\d+)*)-hydrazine\b", r"yn-\1-ylhydrazine", name)

    name = re.sub(r"\b(amino|imino)\(", r"(\1)(", name)
    name = apply_data_postprocessing(name)
    name = apply_connection_boundary_postprocessing(name)

    return name


def _add_indicated_hydrogen_prefix(parts: AssemblyParts, core_name: str) -> str:
    additive_hydrogens = [
        locant
        for operation in parts.hydro_operations
        if operation.operation_kind == "additive_hydrogen"
        for locant in operation.locants
    ]
    indicated_hydrogens = [
        locant
        for operation in parts.hydro_operations
        if operation.operation_kind == "indicated_hydrogen"
        for locant in operation.locants
    ] or parts.indicated_hydrogens
    if not indicated_hydrogens and not additive_hydrogens:
        return core_name
    if positive_parent_n_charges(parts):
        # The ium suffix states the hydrogen on its own nitrogen, but a second,
        # neutral ring NH still has to be cited: 4-imino-1H-pyrimidin-3-ium.
        cationic_locants = {charge.locant for charge in positive_parent_n_charges(parts)}
        indicated_hydrogens = [locant for locant in indicated_hydrogens if locant not in cationic_locants]
        if not additive_hydrogens and not indicated_hydrogens:
            return core_name
    if indicated_hydrogens:
        indicated_hydrogens = sorted(set(indicated_hydrogens), key=parse_locant)
        core_name = _drop_stem_indicated_hydrogen(core_name, indicated_hydrogens)
        core_name = ",".join(f"{locant}H" for locant in indicated_hydrogens) + "-" + core_name
    if additive_hydrogens:
        additive_hydrogens = sorted(set(additive_hydrogens), key=parse_locant)
        separator = "-" if core_name[:1].isdigit() else ""
        hydro = format_multiplier("hydro", len(additive_hydrogens))

        stated = parts.retained_parent_metadata.indicated_hydrogen_count if parts.retained_parent_metadata else 0
        if len(additive_hydrogens) + max(
            len(indicated_hydrogens), stated
        ) == parts.parent_length and not core_name.startswith("spiro["):
            return f"{hydro}{separator}{core_name}"
        core_name = f"{','.join(additive_hydrogens)}-{hydro}{separator}{core_name}"
    return core_name


def _drop_stem_indicated_hydrogen(core_name: str, indicated_hydrogens: list[str]) -> str:
    """
    Drop a stem's built-in ``1H-``; the cited set replaces it.
    """

    match = re.match(r"^(\d+[a-z]?H(?:,\d+[a-z]?H)*)-", core_name)
    if match is None:
        return core_name
    return core_name[match.end() :]


def _move_added_hydrogen_to_suffix(parts: AssemblyParts, core_name: str, suffix_str: str) -> tuple[str, str]:
    """P-14.7: added hydrogen follows the suffix locant -- quinolin-4(1H)-one."""

    added = {
        locant
        for operation in parts.hydro_operations
        if operation.key == "added_hydrogen"
        for locant in operation.locants
    }
    if not added:
        return core_name, suffix_str
    cite = ",".join(f"{locant}H" for locant in sorted(added, key=parse_locant))
    if not core_name.startswith(cite + "-"):
        return core_name, suffix_str
    match = re.match(r"^-(\d+[a-z]?)-", suffix_str)
    if match is None:
        return core_name, suffix_str
    return core_name[len(cite) + 1 :], f"-{match.group(1)}({cite})-{suffix_str[match.end() :]}"


def _add_stereo_prefix(parts: AssemblyParts, final_word: str) -> str:
    if not parts.stereo_features:
        return final_word
    unique_stereo = []
    seen = set()
    for feature in parts.stereo_features:
        if feature not in seen:
            seen.add(feature)
            unique_stereo.append(feature)
    unlocanted_descriptors = {descriptor for locant, descriptor in unique_stereo if not locant}
    if unlocanted_descriptors:
        unique_stereo = [
            feature for feature in unique_stereo if not (feature[0] == "1" and feature[1] in unlocanted_descriptors)
        ]
    sorted_stereo = sorted(unique_stereo, key=lambda f: parse_locant(f[0]) if f[0] else (0, ""))
    stereo_str = "(" + ",".join(f"{loc}{st}" if loc else st for loc, st in sorted_stereo) + ")-"
    return stereo_str + final_word


def _add_relative_stereo_prefix(parts: AssemblyParts, final_word: str) -> str:
    if not parts.relative_stereo_prefixes:
        return final_word
    prefixes = []
    seen = set()
    for prefix in parts.relative_stereo_prefixes:
        if prefix not in seen:
            prefixes.append(prefix)
            seen.add(prefix)
    return "".join(f"{prefix}-" for prefix in prefixes) + final_word


def _front_modifier_sort_key(name: str) -> str:
    """Alphanumeric ordering key: ignore the italic ``tert-``/``sec-`` prefixes."""

    key = name
    for prefix in ("tert-", "sec-"):
        if key.startswith(prefix):
            key = key[len(prefix) :]
            break
    return key.lstrip("([").lower()


def _add_front_modifiers(parts: AssemblyParts, final_word: str) -> str:
    if not parts.front_modifiers:
        return final_word
    mods = parts.front_modifiers
    locants = parts.front_modifier_locants
    have_locants = len(locants) == len(mods) and all(loc is not None for loc in locants)

    if have_locants and len(set(mods)) > 1:
        by_name: dict[str, list[str]] = {}
        for mod, loc in zip(mods, locants):
            by_name.setdefault(mod, []).append(loc)
        entries = []
        for name in sorted(by_name, key=_front_modifier_sort_key):
            group_locants = sorted(by_name[name], key=lambda loc: (len(loc), loc))
            locant_str = ",".join(group_locants)
            count = len(group_locants)
            rendered = format_multiplier(name, count, safe_enclose=True) if count > 1 else name
            entries.append(f"{locant_str}-{rendered}")
        return f"{' '.join(entries)} {final_word}"
    counts: dict[str, int] = {}
    for mod in mods:
        counts[mod] = counts.get(mod, 0) + 1
    front_words = [format_multiplier(m, c, safe_enclose=True) if c > 1 else m for m, c in sorted(counts.items())]
    return f"{' '.join(front_words)} {final_word}"


def post_process_name(name: str) -> str:
    return _post_process_name(name)


def post_process_rewrite_rules():
    """Return shared post-processing rewrites for metadata-aware assembly paths."""

    return (("post_process_name", _post_process_name),)


def assemble_name_raw(parts: AssemblyParts) -> str:
    fused_ion_candidate = select_fused_ion_operation(parts)
    if fused_ion_candidate is not None:
        consume_fused_ion_operation(parts, fused_ion_candidate)

    # Has to run before the prefixes are formatted: it takes one of them away.
    promote_retained_substituent_name(parts)
    spiro_subs = split_spiro_substituents(parts)
    prefix_str = format_substituent_prefixes(parts, spiro_subs)
    a_prefix_str = format_replacement_prefixes(parts)
    promote_benzene_retained_name(parts)
    promote_retained_functional_parent(parts)
    if parts.retained_substituent_name is not None and parts.name_atom_bindings:
        refresh_parent_binding(parts)
    if parts.retained_absorbs_principal_group and parts.name_atom_bindings:
        refresh_parent_binding(parts)
    if fused_ion_candidate is not None and fused_ion_candidate.rendered_name is not None:
        core_name = fused_ion_candidate.rendered_name
    elif parts.retained_substituent_name is not None:
        # The retained prefix is the whole word -- skeleton, branch and ``yl``.
        core_name = parts.retained_substituent_name
    else:
        stem_str, terminal_e = parent_stem_and_terminal(parts)
        stem_str = apply_replacement_prefix(stem_str, a_prefix_str)
        if parts.is_substituent:
            stem_str, unsat_str, terminal_e, suffix_str = format_substituent_tail(
                parts, stem_str, terminal_e, spiro_subs
            )
        else:
            stem_str, unsat_str, terminal_e, suffix_str = format_parent_tail(parts, stem_str, terminal_e, spiro_subs)

        core_name, terminal_e, suffix_str = format_spiro_core(stem_str, unsat_str, terminal_e, spiro_subs, suffix_str)
        core_name = _add_indicated_hydrogen_prefix(parts, core_name)
        core_name, suffix_str = _move_added_hydrogen_to_suffix(parts, core_name, suffix_str)
        core_name += suffix_str
    parent_needs_prefix_hyphen = bool(
        prefix_str and positive_parent_n_charges(parts) and parts.retained_name and parts.indicated_hydrogens
    )
    final_word = (
        prefix_str + "-" + core_name
        if prefix_str and (needs_hyphen(prefix_str, core_name) or parent_needs_prefix_hyphen)
        else prefix_str + core_name
    )
    final_word = _add_stereo_prefix(parts, final_word)
    final_word = _add_relative_stereo_prefix(parts, final_word)
    final_word = _add_front_modifiers(parts, final_word)
    return final_word


def assemble_name(parts: AssemblyParts) -> str:
    return assemble_name_result(parts).text


def assemble_name_result(parts: AssemblyParts) -> NameAssemblyResult:
    """Assemble a name while preserving final atom/bond binding metadata."""

    if not parts.name_atom_bindings:
        refresh_name_atom_bindings(parts)
    raw_name = assemble_name_raw(parts)
    result = NameAssemblyResult.from_raw_name(raw_name, parts.name_atom_bindings, postprocess=post_process_name)
    parts.name_atom_bindings = list(result.bindings)
    parts.name_token_spans = token_span_trace_data(result)
    parts.name_rewrite_history = rewrite_history_trace_data(result)
    return result
