#!/usr/bin/env python3
# (C) 2026 Cadence Design Systems, Inc. (Cadence)
# All rights reserved.
# TERMS FOR USE OF SAMPLE CODE The software below ("Sample Code") is
# provided to current licensees or subscribers of Cadence products or
# SaaS offerings (each a "Customer").
# Customer is hereby permitted to use, copy, and modify the Sample Code,
# subject to these terms. Cadence claims no rights to Customer's
# modifications. Modification of Sample Code is at Customer's sole and
# exclusive risk. Sample Code may require Customer to have a then
# current license or subscription to the applicable Cadence offering.
# THE SAMPLE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED.  OPENEYE DISCLAIMS ALL WARRANTIES, INCLUDING, BUT
# NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
# PARTICULAR PURPOSE AND NONINFRINGEMENT. In no event shall Cadence be
# liable for any damages or liability in connection with the Sample Code
# or its use.

"""Similarity search in monomer set."""

import argparse
import json
import os
import pathlib
import sys

import rich.console
import rich.table
from openeye import oechem, oegraphsim
from rich_argparse import HelpPreviewAction, RichHelpFormatter

__SCRIPT_NAME__ = pathlib.Path(__file__).absolute().stem
__SCRIPT_DESC__ = "Similarity search in monomer set."
__SCRIPT_TOOLKITS__ = ["oechem", "oegraphsim"]
__SCRIPT_KEYWORDS__ = [
    "monomer",
    "peptide",
    "peptide-informatics",
    "similarity",
    "search",
]
__SCRIPT_CATEGORIES__ = ["peptide-informatics"]

MONOMER_SET_STANDARD = "Standard"
MONOMER_SET_OPENEYE = "OpenEye"


def parse_options() -> argparse.Namespace:
    """Parse command-line options for monomer similarity search."""
    parser = argparse.ArgumentParser(
        add_help=True,
        formatter_class=RichHelpFormatter,
        description="[yellow]" + __SCRIPT_DESC__ + "[/yellow]",
    )

    search_group = parser.add_argument_group("Query options")
    exclusive_search_group = search_group.add_mutually_exclusive_group(required=True)
    exclusive_search_group.add_argument(
        "--smiles",
        type=str,
        metavar="SMILES",
        help="smiles of the query structure",
    )
    exclusive_search_group.add_argument(
        "--code",
        type=str,
        help="monomer code in any code-set in loaded monomers",
    )

    monomers_group = parser.add_argument_group("Monomer set options")
    _add_monomer_collection(monomers_group)

    sim_group = parser.add_argument_group("Similarity options")
    sim_group.add_argument(
        "--cutoff",
        type=float,
        metavar="[0.0, 1.0]",
        default=0.3,
        help="similarity score cutoff value  in range (default: %(default)s)",
    )
    sim_group.add_argument(
        "--limit",
        type=int,
        metavar="INT",
        default=20,
        help="maximum number of similar monomers (default: %(default)s)",
    )

    parser.add_argument("--help-image", action=HelpPreviewAction)
    parser.add_argument(
        "--save-console-svg",
        default=False,
        action="store_true",
        help=f"run command and capture console output in {__SCRIPT_NAME__}.svg file",
    )

    return parser.parse_args()


def main() -> int:
    """Run monomer similarity search and print results."""
    args = parse_options()

    console = rich.console.Console(record=args.save_console_svg, highlight=False)
    monomers = _get_monomer_collection(args)

    query_mol = _get_query_mol(args, monomers, console)
    if query_mol is None:  # warning was already printed
        return os.EX_DATAERR
    console.print(f"[green]Query {oechem.OEMolToSmiles(query_mol)}[/green]")

    # generating fingerprints
    fp_type = oegraphsim.OEGetFPType(oegraphsim.OEFPType_Path)
    console.print(f"[green]Using fingerprint {fp_type.GetFPTypeString()}[/green]")
    query_fp = oegraphsim.OEFingerPrint()
    oegraphsim.OEMakeFP(query_fp, query_mol, fp_type)

    scores: list[tuple[float, oechem.OEMonomer]] = []

    for monomer in monomers.GetMonomers():
        score = _get_sim_score(monomer, query_fp)
        scores.append((score, monomer))
        oegraphsim.OEMakeFP(query_fp, query_mol, fp_type)

    limit = min(max(1, args.limit), len(scores))
    scores.sort(key=lambda s: s[0], reverse=True)
    scores = scores[:limit]
    cutoff = min(max(0.01, args.cutoff), 1.0)

    primary_code_set = monomers.GetPrimaryCodeSet()
    code_sets: list[str] = [
        primary_code_set,
        *[c for c in monomers.GetCodeSets() if c != primary_code_set],
    ]
    columns = ["idx", "tanimoto", *code_sets, "monomer smiles"]
    table = rich.table.Table(*columns)

    color_gradient = oechem.OELinearColorGradient()
    color_gradient.AddStop(oechem.OEColorStop(0.0, oechem.OERedOrange))
    color_gradient.AddStop(oechem.OEColorStop(1.0, oechem.OESeaGreen))
    for index, (score, monomer) in enumerate(scores, start=1):
        if score < cutoff:
            break
        score_color = color_gradient.GetColorAt(score)
        row_data: list[str | rich.text.Text] = [
            rich.text.Text(f"{index}", justify="right"),
            rich.text.Text(
                f"{score:.4f}", style=score_color.GetText(), justify="right"
            ),
            *[
                rich.text.Text(monomer.GetCode(s)) if monomer.HasCode(s) else ""
                for s in code_sets
            ],
            monomer.GetCanonicalSmiles(),
        ]
        table.add_row(*row_data)

    console.print(table, markup=False)

    if args.save_console_svg:
        console.save_svg(f"{__SCRIPT_NAME__}.svg", title="output")
    return os.EX_OK


def _get_sim_score(
    monomer: oechem.OEMonomer, query_fp: oegraphsim.OEFingerPrint
) -> float:
    """Compute Tanimoto similarity between a monomer and a query fingerprint."""
    mol = oechem.OEGraphMol()
    oechem.OESmilesToMol(mol, monomer.GetCanonicalSmiles())
    fp = oegraphsim.OEFingerPrint()
    oegraphsim.OEMakeFP(fp, mol, query_fp.GetFPTypeBase())
    return oegraphsim.OETanimoto(query_fp, fp)


class MonomerSetParameter:  # noqa: PLW1641
    """Utility class to handle both built-in and user defined monomer sets."""

    def __init__(self) -> None:  # noqa: D107
        self._monomer_sets = [
            MONOMER_SET_STANDARD,
            MONOMER_SET_OPENEYE,
            "JSON-FILENAME",
        ]

    def __repr__(self) -> str:  # noqa: D105
        return ",".join(self._monomer_sets)

    def __eq__(self, param: object) -> bool:  # noqa: D105
        if not isinstance(param, str):
            return False
        if param in [MONOMER_SET_STANDARD, MONOMER_SET_OPENEYE]:
            return True

        console = rich.console.Console()
        monomer_set_filepath = pathlib.Path(param)
        if (
            not monomer_set_filepath.exists()
            or monomer_set_filepath.suffix.lower() != ".json"
        ):
            console.print(f"[red]Invalid monomer set file '{param}' ![/red]")
            return False

        is_valid = True
        try:
            with monomer_set_filepath.open("r") as json_file:
                json.load(json_file)
        except json.JSONDecodeError as e:
            console.print(f"[red]Invalid monomer set file '{param}' ![/red]")
            console.print(f"[red]Error decoding JSON: {e} ![/red]")
            is_valid = False
        return is_valid


def _add_monomer_collection(arg_group: argparse._ArgumentGroup) -> None:
    arg_group.add_argument(
        "-m",
        "--monomers",
        type=str,
        default=MONOMER_SET_STANDARD,
        choices=[MonomerSetParameter()],
        help="built-in monomer-set type or json file of monomers",
    )


def _get_monomer_collection(args: argparse.Namespace) -> oechem.OEMonomerSet:
    monomers = oechem.OEMonomerSet()
    match args.monomers:
        case "Standard":
            oechem.OELoadStandardMonomerSet(monomers)
        case "OpenEye":
            oechem.OELoadOpenEyeMonomerSet(monomers)
        case _:
            oechem.OEReadMonomerSet(monomers, args.monomers)
    return monomers


def _get_query_mol(
    args: argparse.Namespace,
    monomers: oechem.OEMonomerSet,
    console: rich.console.Console,
) -> None | oechem.OEMolBase:
    query_mol = oechem.OEGraphMol()
    # --smiles
    if args.smiles:
        opts = oechem.OEParseSmilesOptions()
        opts.SetQuiet(True)
        if (
            not oechem.OEParseSmiles(query_mol, args.smiles, opts)
            or query_mol.NumAtoms() == 0
        ):
            console.print(f"[red]Error: invalid SMILES '{args.smiles}'![/red]")
            return None
        return query_mol

    # --code
    for code_set in monomers.GetCodeSets():
        if (monomer := monomers.GetMonomer(code_set, args.code)) is not None:
            oechem.OESmilesToMol(query_mol, monomer.GetCanonicalSmiles())
            return query_mol

    console.print(f"[red]Error: no monomer with code '{args.code}' identified![/red]")
    return None


setattr(main, "__SCRIPT_NAME__", __SCRIPT_NAME__)
setattr(main, "__SCRIPT_DESC__", __SCRIPT_DESC__)
setattr(main, "__SCRIPT_TOOLKITS__", __SCRIPT_TOOLKITS__)
setattr(main, "__SCRIPT_KEYWORDS__", __SCRIPT_KEYWORDS__)
setattr(main, "__SCRIPT_CATEGORIES__", __SCRIPT_CATEGORIES__)

if __name__ == "__main__":
    sys.exit(main())
