/***********************************************************************************

    Copyright (C) 2007-2026 Ahmet Öztürk (aoz_2@yahoo.com)

    This file is part of Lifeograph.

    Lifeograph is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Lifeograph is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with Lifeograph.  If not, see <http://www.gnu.org/licenses/>.

***********************************************************************************/


#ifdef HAVE_CONFIG_H
#include <config.h>
#endif

#include <pybind11/embed.h>
#include <pybind11/stl.h>
#include <filesystem>

#include "py_bindings.hpp"
#include "../app_window.hpp"
#include "../ui_diary.hpp"
#include "../diaryelements/diary.hpp"
#include "../dialogs/dialog_scriptprompt.hpp"


using namespace LoG;
namespace py = pybind11;

// NOTIFICATION SHORTHAND
void
notify_py_issue( const String& msg )
{ AppWindow::p->show_info( "Python Error: " + Glib::Markup::escape_text( msg ) ); }

DateV      s_last_entry_check_date;

//NOTE: __attribute__((visibility("hidden"))) is needed to suppress compiler complaints:
class __attribute__((visibility("hidden"))) Script : public Named, public PropertyContainer
{
public:
    bool belongs_to( LoGID entry_id ) const
    { return( m_properties.get( PROP::RELATED_ENTRY, DEID::UNSET ) == entry_id ); }
    bool belongs_to( const String& path ) const
    { return( m_properties.get< String >( PROP::PATH, "" ) == path ); }

    Entry* get_related_entry() const
    {
        auto id { m_properties.get( PROP::RELATED_ENTRY, DEID::UNSET ) };
        return( id == DEID::UNSET ? nullptr : Diary::d->get_entry_by_id( id ) );
    }
    Paragraph* get_related_paragraph() const
    {
        auto id { m_properties.get( PROP::RELATED_PARA, DEID::UNSET ) };
        return( id == DEID::UNSET ? nullptr : Diary::d->get_element2< Paragraph >( id ) );
    }
    String get_path() const
    { return m_properties.get< String >( PROP::PATH, "" ); }

    bool is_entry_based() const
    { return m_properties.has( PROP::RELATED_ENTRY ); }
    bool is_file_based() const
    { return m_properties.has( PROP::PATH ); }

    bool is_in_dirty_source()
    {
        if( !m_F_loaded ) return false; // it will be loaded anyway...

        if( m_properties.has( PROP::RELATED_ENTRY ) )
        {
            const auto  id_entry { m_properties.get( PROP::RELATED_ENTRY, DEID::UNSET ) };
            Entry*      p2entry  { Diary::d->get_entry_by_id( id_entry ) };
            if( !p2entry ) return false;

            return( p2entry->get_date_edited() > s_last_entry_check_date );
        }
        else if( m_properties.has( PROP::PATH ) )
        {
            // TODO: 3.2: add file change date check
        }

        return false;
    }

    bool load()
    {
        if( m_F_loaded ) return true;

        String py_code;

        if( m_properties.has( PROP::PATH ) )
        {
            const auto path { m_properties.get< String >( PROP::PATH, "" ) };
            try
            {
                if( !path.empty() ) py_code = read_text_file( path );
            }
            catch( Error& err )
            {
                notify_py_issue( STR::compose( err.what(), " (", path, ")" ) );
                return false;
            }
        }
        else if( m_properties.has( PROP::RELATED_PARA ) )
        {
            const D::DEID  id_para { m_properties.get( PROP::RELATED_PARA, DEID::UNSET ) };
            Paragraph*  p_bgn   { Diary::d->get_paragraph_by_id( id_para ) }, *p_end;
            if( !p_bgn ) return false;
            Entry*      p2entry { p_bgn->m_host };
            if( !p2entry ) return false;

            py_code = p2entry->get_script_bounds( p_bgn, p_end );
        }

        if( py_code.empty() ) return false;

        py::gil_scoped_acquire gil;

        // raw C API compile check only - keeps pybind11 safe as it may crash on syntax errors
        PyObject* compiled = Py_CompileString( py_code.c_str(), "<user_script>", Py_file_input );
        if( !compiled )
        {
            PyErr_Clear();
            notify_py_issue( "Syntax error in Python script" );
            return false;
        }
        Py_DECREF( compiled );

        try
        {
            py::exec( py_code, globals );

            // extract metadata
            assert( globals[ "NAME" ].cast< String >() == m_name );
            assert( globals[ "CATEGORY" ].cast< ScriptCategory >() == m_category );

            run_func = globals[ "run" ];
            // NOTE: should we check existence of run func?
        }
        catch( py::error_already_set &e )
        {
            notify_py_issue( e.what() );
            return false;
        }
        catch( ... )
        {
            notify_py_issue( "Unknown Python error" );
            return false;
        }

        m_F_loaded = true;
        return true;
    }

    const ScriptCategory  m_category;
    const String          m_source_text;
    py::dict              globals;
    py::object            run_func;
    bool                  m_F_loaded;
    static py::object     get_completions_func;
    inline static bool    s_F_suppress_redef_check { false };

protected:
    Script( const String& name, ScriptCategory ctg, py::dict&& g, const String& src_text )
    :   Named( name ), m_category( ctg ), m_source_text( src_text ),
        globals( std::move( g ) ), run_func ( globals[ "run" ] ), m_F_loaded( true )
    { }
    Script( const String& name, ScriptCategory ctg, const String& path )
    :   Named( name ), m_category( ctg ), m_F_loaded( false )
    { set_property( PROP::PATH, path ); }
    Script( const String& name, ScriptCategory ctg, const Paragraph* p2para_bgn )
    :   Named( name ), m_category( ctg ), m_F_loaded( false )
    {
        set_property( PROP::RELATED_PARA, p2para_bgn->get_id() );
        set_property( PROP::RELATED_ENTRY, p2para_bgn->m_host->get_id() );
    }

friend class MapScripts;
friend Script* add_script( const String& );
friend Script* add_script( const String&, ScriptCategory, const String& );
friend Script* add_script( const String&, ScriptCategory, const Paragraph* );
};

py::object Script::get_completions_func;

//NOTE: __attribute__((visibility("hidden"))) is needed to suppress compiler complaints:
class __attribute__((visibility("hidden"))) MapScripts :
        public std::set< Script*, FuncCmpNamedElemByName >
{
public:
    void delete_if( std::function< bool( const Script* ) >&& pred )
    {
        for( auto it_script = begin(); it_script != end(); )
        {
            if( pred( *it_script ) )
            {
                delete *it_script;
                it_script = erase( it_script );
            }
            else
                ++it_script;
        }
    }

    Script* get( const Ustring& name )
    {
        for( auto it_script = begin(); it_script != end(); ++it_script )
            if( ( *it_script )->get_name() == name )
                return *it_script;

        return nullptr;
    }

    bool redef_check( const Ustring& name )
    {
        if( get( name ) )
        {
            if( Script::s_F_suppress_redef_check )
                dismiss( name ); // dismiss the script
            else
            {
                notify_py_issue( STR::compose( "Redefinition of Script: ", name ) );
                return false;
            }
        }
        return true;
    }

    void clear( ScriptCategory ctg )
    { delete_if( [ & ]( const Script* s ) { return( s->m_category == ctg ); } ); }
    void clear_by_path( const String& path )
    { delete_if( [ & ]( const Script* s )
                 { return STR::begins_with( s->m_properties.get< String >( PROP::PATH, "" ),
                                            path ); } ); }

    void dismiss( const String& name )
    { delete_if( [ & ]( const Script* s ) { return( s->get_name() == name ); } ); }
};

// static members:
static MapScripts script_vault;

struct ParagraphIterator
{
    Paragraph* p2para;

    Paragraph* operator*() const { return p2para; }

    ParagraphIterator& operator++()
    {
        if( p2para )
            p2para = p2para->m_p2next;
        return *this;
    }

    bool operator==( const ParagraphIterator& other ) const { return p2para == other.p2para; }
    bool operator!=( const ParagraphIterator& other ) const { return p2para != other.p2para; }
};

#ifndef PY_STUB_BUILD
void
setup_bindings( py::module_& pyLoG )
{
#else
PYBIND11_MODULE( LoG, pyLoG )
{
    pyLoG.doc() = "LoG module documentation";
#endif

    py::enum_< ScriptCategory >( pyLoG, "SCtg" )
        .value( "TBD",              ScriptCategory::TBD )
        .value( "NONE",             ScriptCategory::NONE )
        .value( "IMPORTER",         ScriptCategory::IMPORTER )
        .value( "EXPORTER",         ScriptCategory::EXPORTER )
        .value( "TABLE_COLUMN",     ScriptCategory::TABLE_COLUMN )
        .value( "WORD_MODIFIER",    ScriptCategory::WORD_MODIFIER )
        .value( "PARA_MODIFIER",    ScriptCategory::PARA_MODIFIER )
        .value( "ENTRY_MODIFIER",   ScriptCategory::ENTRY_MODIFIER )
        .value( "FILTER",           ScriptCategory::FILTER )
        ;

    py::enum_< DialogScriptPrompt::FieldType >( pyLoG,
                                                "FT",
                                                "Types for user input fields" )
        .value( "DATE",           DialogScriptPrompt::FieldType::DATE )
        .value( "CHECK",          DialogScriptPrompt::FieldType::CHECK )
        .value( "OPTION",         DialogScriptPrompt::FieldType::OPTION )
        .value( "TEXT",           DialogScriptPrompt::FieldType::TEXT )
        .value( "FILE",           DialogScriptPrompt::FieldType::FILE )
        .value( "FOLDER",         DialogScriptPrompt::FieldType::FOLDER )
        .value( "TAG",            DialogScriptPrompt::FieldType::TAG )
        .value( "FILTER",         DialogScriptPrompt::FieldType::FILTER )
        .value( "HIDDEN",         DialogScriptPrompt::FieldType::HIDDEN )
        ;

    // py::implicitly_convertible< uint32_t, LoGID32 >();
    // py::implicitly_convertible< LoGID32, uint32_t >();

    py::enum_< ParaInhClass >( pyLoG, "ParaInhClass", py::arithmetic(),
                               "Paragraph Inheritance Class" )
        .value( "QUOT_TYPE",      ParaInhClass::QUOT_TYPE )
        .value( "LIST_TYPE",      ParaInhClass::LIST_TYPE )
        .value( "HEADING_LVL",    ParaInhClass::HEADING_LVL )
        .value( "INDENTATION",    ParaInhClass::INDENTATION )
        .value( "DEFAULT",        ParaInhClass::DEFAULT )
        .value( "NONE",           ParaInhClass::NONE )
        .value( "SET_TEXT",       ParaInhClass::SET_TEXT )
        ;

    py::module_ vt = pyLoG.def_submodule( "VT", "Value types" );
        py::module_ es = vt.def_submodule( "ES", "Element Statuses" );
        es.attr( "AUTO" )               = ES::NOT_TODO;
        es.attr( "TODO" )               = ES::TODO;
        es.attr( "PROGRESSED" )         = ES::PROGRESSED;
        es.attr( "DONE" )               = ES::DONE;
        es.attr( "CANCELED" )           = ES::CANCELED;

        py::module_ ets = vt.def_submodule( "ETS", "Entry Title Styles" );
        ets.attr( "NAME_ONLY" )         = VT::ETS::NAME_ONLY::I;
        ets.attr( "NAME_AND_DESCRIPT" ) = VT::ETS::NAME_AND_DESCRIPT::I;
        ets.attr( "NUMBER_AND_NAME" )   = VT::ETS::NUMBER_AND_NAME::I;
        ets.attr( "DATE_AND_NAME" )     = VT::ETS::DATE_AND_NAME::I;
        ets.attr( "MILESTONE" )         = VT::ETS::MILESTONE::I;
        ets.attr( "INHERIT" )           = VT::ETS::INHERIT::I;

        py::module_ fmt = vt.def_submodule( "FMT", "Rich Text Formats" );
        fmt.attr( "BOLD" )              = VT::FMT::BOLD::I;
        fmt.attr( "ITALIC" )            = VT::FMT::ITALIC::I;
        fmt.attr( "HIGHLIGHT" )         = VT::FMT::HIGHLIGHT::I;
        fmt.attr( "STRIKETHRU" )        = VT::FMT::STRIKETHRU::I;
        fmt.attr( "UNDERLINE" )         = VT::FMT::UNDERLINE::I;
        fmt.attr( "FADED" )             = VT::FMT::FADED::I;
        fmt.attr( "SUBSCRIPT" )         = VT::FMT::SUBSCRIPT::I;
        fmt.attr( "SUPERSCRIPT" )       = VT::FMT::SUPERSCRIPT::I;
        fmt.attr( "TAG" )               = VT::FMT::TAG::I;
        fmt.attr( "LINK_URI" )          = VT::FMT::LINK_URI::I;
        fmt.attr( "LINK_EVAL" )         = VT::FMT::LINK_EVAL::I;
        fmt.attr( "LINK_ID" )           = VT::FMT::LINK_ID::I;
        fmt.attr( "LINK_ONTHEFLY" )     = VT::HFT_LINK_ONTHEFLY;
        // fmt.attr( "LINK_BROKEN" )       = VT::HFT_LINK_BROKEN;
        fmt.attr( "TAG_VALUE" )         = VT::HFT_TAG_VALUE;
        fmt.attr( "COMMENT" )           = VT::HFT_COMMENT;
        fmt.attr( "TIME" )              = VT::HFT_TIME;
        fmt.attr( "DATE" )              = VT::HFT_DATE;
        fmt.attr( "CODE_KEYWORD" )      = VT::HFT_CODE_KEYWORD;
        fmt.attr( "CODE_STRING" )       = VT::HFT_CODE_STRING;
        fmt.attr( "CODE_COMMENT" )      = VT::HFT_CODE_COMMENT;

        py::module_ pa = vt.def_submodule( "PA", "Paragraph Alignment" );
        pa.attr( "LEFT" )               = VT::PA::LEFT::I;
        pa.attr( "CENTER" )             = VT::PA::CENTER::I;
        pa.attr( "RIGHT" )              = VT::PA::RIGHT::I;

        py::module_ phs = vt.def_submodule( "PHS", "Paragraph Heading Sizes" );
        // ps.attr( "HEADER_GEN" )         = VT::PS_HEADER_GEN;
        phs.attr( "NORMAL_TEXT" )       = VT::PHS::NORMAL::I;
        phs.attr( "MEDIUM" )            = VT::PHS::MEDIUM::I;
        phs.attr( "LARGE" )             = VT::PHS::LARGE::I;
        phs.attr( "TITLE" )             = VT::PHS::TITLE::I;

        py::module_ pls = vt.def_submodule( "PLS", "Paragraph List Styles" );
        pls.attr( "PLAIN" )             = VT::PLS::PLAIN::I;
        pls.attr( "TODO" )              = VT::PLS::TODO::I;
        pls.attr( "PROGRESSED" )        = VT::PLS::PROGRS::I;
        pls.attr( "DONE" )              = VT::PLS::DONE::I;
        pls.attr( "CANCELED" )          = VT::PLS::CANCLD::I;
        pls.attr( "BULLET" )            = VT::PLS::BULLET::I;
        pls.attr( "NUMBER" )            = VT::PLS::NUMBER::I;
        pls.attr( "LETTER" )            = VT::PLS::CLTTR::I;
        pls.attr( "LETTER_SMALL" )      = VT::PLS::SLTTR::I;
        pls.attr( "ROMAN" )             = VT::PLS::CROMAN::I;
        pls.attr( "ROMAN_SMALL" )       = VT::PLS::SROMAN::I;

        py::module_ ps = vt.def_submodule( "PS", "Paragraph Styles" );
        ps.attr( "IMAGE_FILE" )         = VT::PS_IMAGE_FILE;
        ps.attr( "IMAGE_CHART" )        = VT::PS_IMAGE_CHART;
        ps.attr( "IMAGE_TABLE" )        = VT::PS_IMAGE_TABLE;

        py::module_ qt = vt.def_submodule( "QT", "Quotation Types" );
        qt.attr( "OFF" )                = VT::QT::OFF::C;
        qt.attr( "LITERARY" )           = VT::QT::LITERARY::C;
        qt.attr( "GENERIC" )            = VT::QT::GENERIC::C;
        qt.attr( "PYTHON" )             = VT::QT::PYTHON::C;
        qt.attr( "BASH" )               = VT::QT::BASH::C;
        qt.attr( "CPP" )                = VT::QT::CPP::C;
        qt.attr( "FORTRAN" )            = VT::QT::FORTRAN::C;
        qt.attr( "GO" )                 = VT::QT::GO::C;
        qt.attr( "HASKELL" )            = VT::QT::HASKELL::C;
        qt.attr( "HTML" )               = VT::QT::HTML::C;
        qt.attr( "JAVA" )               = VT::QT::JAVA::C;
        qt.attr( "JAVASCRIPT" )         = VT::QT::JAVASCRIPT::C;
        qt.attr( "KOTLIN" )             = VT::QT::KOTLIN::C;
        qt.attr( "LISP" )               = VT::QT::LISP::C;
        qt.attr( "LUA" )                = VT::QT::LUA::C;
        qt.attr( "PASCAL" )             = VT::QT::PASCAL::C;
        qt.attr( "PERL" )               = VT::QT::PERL::C;
        qt.attr( "RUBY" )               = VT::QT::RUBY::C;
        qt.attr( "RUST" )               = VT::QT::RUST::C;
        qt.attr( "SCALA" )              = VT::QT::SCALA::C;
        qt.attr( "SQL" )                = VT::QT::SQL::C;

    py::module_ date = pyLoG.def_submodule( "Date", "Date utilities" );
    date.def( "get_now",      &Date::get_now );
    date.def( "get_today",    &Date::get_today );
    date.def( "get_day",      &Date::get_day );
    date.def( "get_month",    &Date::get_month );
    date.def( "get_year",     &Date::get_year );
    date.def( "make",         py::overload_cast< unsigned, unsigned, unsigned >( &Date::make ),
              py::arg( "year" ), py::arg( "month" ), py::arg( "day" ) );
    date.def( "make",         py::overload_cast< unsigned, unsigned, unsigned,
                                                 unsigned, unsigned, unsigned >( &Date::make ),
              py::arg( "year" ), py::arg( "month" ), py::arg( "day" ),
              py::arg( "hour" ), py::arg( "minute" ), py::arg( "second" ) );
    date.def( "make",         py::overload_cast< const String& >( &Date::make ) );
    date.def( "is_valid",     &Date::is_valid );
    date.def( "format_str",   py::overload_cast< const DateV >( &Date::format_string ),
              py::arg( "date" ),
R"(
Converts a date into a string in the default format
Parameters:
date: The date to be converted
returns: The date string
)"
            );
    date.def( "format_str",   &Date::format_string_adv,
              py::arg( "date" ), py::arg( "format" ),
R"(
Converts a date into a string in a custom format.
Format is a string where special chars are replaced with date fileds as below:
M: month number
n: month name
D: day
d: week day
W: week no
h: hour
m: minute
s: second
F: date in default format
T: time in default format
Parameters:
date: The date to be converted
format: The format string
returns: The date string
)"
            );

    py::class_< HiddenFormat >( pyLoG, "HiddenFormat" )
        .def( py::init< int, const String&, StringSize, StringSize >() )
        .def_readwrite( "pos_bgn",    &HiddenFormat::pos_bgn )
        .def_readwrite( "pos_end",    &HiddenFormat::pos_end )
        .def_readwrite( "uri",        &HiddenFormat::uri )
        .def_readwrite( "ref_id",     &HiddenFormat::ref_id )
        .def_readwrite( "type",       &HiddenFormat::type )
        .def_readwrite( "var_i",      &HiddenFormat::var_i )
        .def_readwrite( "var_d",      &HiddenFormat::var_d )
        ;

    py::class_< Named >( pyLoG, "Named" )
        .def( "get_name",     []( Named& self ) { return self.get_name().raw(); } );

    py::class_< DiaryElement, Named >( pyLoG, "DiaryElement",
            "Base class for all diary elements.\n"
            "\n"
            "Diary elements are objects stored inside a diary.\n"
            "They have an ID and occupy space." )
        .def( "get_id",       []( DiaryElement& self ) { return self.get_id().get_raw(); } )
        .def( "get_size",     &DiaryElement::get_size )
        ;

    py::class_< DiaryElemTag, DiaryElement >( pyLoG, "DiaryElemTag",
            "Base class for Paragraphs and Entries.\n"
            "\n"
            "Diary elements are objects stored inside a diary.\n"
            "They have an ID and occupy space." )
        .def( "get_date",             &DiaryElemTag::get_date )
        .def( "get_date_finish",      &DiaryElemTag::get_date_finish )
        .def( "get_ancestry_path",
             []( DiaryElemTag& self ) { return self.get_ancestry_path().raw(); } )
        .def( "get_description",
             []( DiaryElemTag& self ) { return self.get_description().raw(); } )
        .def( "is_expanded",          &DiaryElemTag::is_expanded,
R"(
Returns true if the entry/paragraph is expanded (i.e. shows its children)
)"
            )
        .def( "set_expanded",         &DiaryElemTag::set_expanded,
              py::arg( "is_expanded" ),
R"(
For parent entries/tags, sets the collapsed/expanded state
Parameters:
is_expanded: Supply True to expanded or False to collapse
)"
            )
        ;

    auto pyDiary = py::class_< Diary, DiaryElement >( pyLoG, "Diary" ); // define in advance
    auto pyEntry = py::class_< Entry, DiaryElemTag >( pyLoG, "Entry" ); // define in advance

    // PARAGRAPH ===================================================================================
    py::class_< Paragraph, DiaryElemTag >( pyLoG, "Paragraph" )
        // .def( py::init< Entry*, const Ustring&, ParserBackGround* >() )
        .def( "get_text", []( Paragraph& self ) { return self.get_text().raw(); } )
        .def( "set_text",
              []( Paragraph& self, const String& text, bool F_add_undo )
              { self.set_text( text, nullptr, F_add_undo ); },
              py::arg( "text_new" ), py::arg( "add_undo" ) = true )
        .def( "insert_text",
              []( Paragraph& self, UstringSize pos, const String& text, bool F_add_undo )
              { self.insert_text( pos, text, nullptr, F_add_undo ); },
              py::arg( "pos" ), py::arg( "text" ), py::arg( "add_undo" ) = true )
        .def( "erase_text",
              []( Paragraph& self, UstringSize pos, UstringSize length, bool F_add_undo )
              { self.erase_text( pos, length, nullptr, F_add_undo ); },
              py::arg( "pos_bgn" ), py::arg( "len_erase" ), py::arg( "add_undo" ) = true )
        .def( "replace_text",
              []( Paragraph& self, UstringSize bgn, UstringSize length, const String& new_text,
                  bool F_add_undo )
              { self.replace_text( bgn, length, new_text, nullptr, F_add_undo ); },
              py::arg( "pos_bgn" ), py::arg( "len_erase" ), py::arg( "new_text" ),
              py::arg( "add_undo" ) = true )
        .def( "is_empty_completely",&Paragraph::is_empty_completely )
        .def( "get_heading_level",  &Paragraph::get_heading_level )
        .def( "set_heading_level",  &Paragraph::set_heading_level )// TODO: 3.2: consider adding undo
        .def( "get_indent_level",   &Paragraph::get_indent_level )
        .def( "set_indent_level",   &Paragraph::set_indent_level ) // TODO: 3.2: consider adding undo
        .def( "get_list_type",      &Paragraph::get_list_type )
        .def( "set_list_type",      &Paragraph::set_list_type,     // TODO: 3.2: consider adding undo
              py::arg( "type" ),
              "Set list type from the options in VT.PLS.*" )       // TODO: 3.2: consider adding undo
        .def( "get_alignment",      &Paragraph::get_alignment )
        .def( "set_alignment",      &Paragraph::set_alignment )    // TODO: 3.2: consider adding undo
        .def( "get_quot_type",      &Paragraph::get_quot_type )
        .def( "set_quot_type",      &Paragraph::set_quot_type,
              py::arg( "type" ),
              "Set quotation type from the options in VT.QT.*" )   // TODO: 3.2: consider adding undo
        .def( "is_quote",           &Paragraph::is_quote )
        .def( "set_quote",          &Paragraph::set_quote,         // TODO: 3.2: consider adding undo
              py::arg( "is_quote" ) = true,
              "Set to literary quote" )                            // TODO: 3.2: consider adding undo
        .def( "is_code",            &Paragraph::is_code )
        .def( "get_code_lang",
              []( Paragraph& self ) -> String
              { return VT::get_v< VT::QT, char const *, char >( self.get_quot_type() ); } )
        .def( "add_format", // TODO: 3.2: consider adding undo
              []( Paragraph& self, int fmt, int bgn, int end ) -> HiddenFormat*
              { if( !HiddenFormat::is_type_saveable( fmt ) ) return nullptr; // don't allow on the fly formats
                return self.add_format( fmt, "", bgn, end ); },
              py::return_value_policy::reference_internal )
        .def( "add_format", // TODO: 3.2: consider adding undo
              []( Paragraph& self, int fmt ) -> HiddenFormat*
              { if( !HiddenFormat::is_type_saveable( fmt ) ) return nullptr; // don't allow on the fly formats
                return self.add_format( fmt, "", 0, self.get_size() ); },
              py::return_value_policy::reference_internal,
              "Convenience method for adding a format to the whole paragraph" )
        .def( "add_tag",            &Paragraph::add_format_tag, // TODO: 3.2: consider adding undo
              py::return_value_policy::reference_internal )
        .def( "add_link", // TODO: 3.2: consider adding undo
              []( Paragraph& self, const String& uri, int bgn, int end )
              { return self.add_link( uri, bgn, end ); },
              py::return_value_policy::reference_internal )
        .def( "remove_format", // TODO: 3.2: consider adding undo
              []( Paragraph& self, HiddenFormat* fmt )
              { self.remove_format( fmt ); },
              py::return_value_policy::reference_internal )
        .def( "get_format_at",
             []( Paragraph& self, int fmt, int pos ) -> HiddenFormat*
             { return self.get_format_at( fmt, pos ); },
              py::return_value_policy::reference_internal )
        .def( "get_formats",
             []( Paragraph& self )
             { return std::vector< HiddenFormat* >( self.m_formats.begin(),
                                                    self.m_formats.end() ); },
              py::return_value_policy::reference_internal )
        .def( "has_hrule",          &Paragraph::has_hrule )
        .def( "set_hrule",          &Paragraph::set_hrule ) // TODO: 3.2: consider adding undo
        .def( "is_image",           &Paragraph::is_image )
        .def( "set_image_type",     &Paragraph::set_image_type, // TODO: 3.2: consider adding undo
              "Set the image type as FILE, CHART or TABLE. Setting to 0 clears")
        .def( "get_image_size",     &Paragraph::get_image_size )
        .def( "set_image_size",     &Paragraph::set_image_size, // TODO: 3.2: consider adding undo
              "Image size has to be an integer between [0,3]" )
        .def( "get_uri",            &Paragraph::get_uri )
        .def( "set_uri",            &Paragraph::set_uri ) // TODO: 3.2: consider adding undo
        .def( "get_tag_def_bound",  &Paragraph::get_tag_bound )
        .def( "set_tag_def_bound",  &Paragraph::set_tag_bound ) // TODO: 3.2: consider adding undo
        .def( "get_host",           []( Paragraph& self ) { return self.m_host; },
              py::return_value_policy::reference_internal, "Get the host Entry" )
        .def( "get_parent",         &Paragraph::get_parent,
              py::return_value_policy::reference_internal )
        .def( "get_prev",           &Paragraph::get_prev,
              py::return_value_policy::reference_internal )
        .def( "get_next",           &Paragraph::get_next,
              py::return_value_policy::reference_internal )
        .def( "get_prev_sibling",   &Paragraph::get_prev_sibling,
              py::return_value_policy::reference_internal )
        .def( "get_next_sibling",   &Paragraph::get_next_sibling,
              py::return_value_policy::reference_internal )
        .def( "get_last_sub",       &Paragraph::get_sub_last,
              py::return_value_policy::reference_internal )
        .def( "is_expanded",        &Paragraph::is_expanded )
        .def( "set_expanded",       &Paragraph::set_expanded )
        .def( "get_subtag_first",   &Paragraph::get_subtag_first,
              py::return_value_policy::reference_internal )
        .def( "get_subtag_last",    &Paragraph::get_subtag_last,
              py::return_value_policy::reference_internal )
        .def( "get_subtag_lowest",  &Paragraph::get_subtag_lowest,
              py::return_value_policy::reference_internal )
        .def( "get_subtag_highest", &Paragraph::get_subtag_highest,
              py::return_value_policy::reference_internal )
        .def( "has_location",
              []( Paragraph& self ) -> bool
              { return self.has_property( PROP::LOCATION ); },
              "Returns True if the Paragraph has map location" )
        .def( "clear_location", &Paragraph::clear_location, // TODO: consider adding undo
              "Clears the location associated with the paragraph if any" )
        .def( "set_location",       &Paragraph::set_location, // TODO: consider adding undo
              "Set map location of a Paragraph as latitude and longitude" )
        .def( "get_latitude",
              []( Paragraph& self ) -> double
              { auto coords { self.get_location() };
                return( coords ? coords->latitude : 0.0 ); } )
        .def( "get_longitude",
              []( Paragraph& self ) -> double
              { auto coords { self.get_location() };
                return( coords ? coords->longitude : 0.0 ); } )
        .def( "move_to_entry",      &Paragraph::move_to_entry,
              py::arg( "new_host" ), py::arg( "para_before" ) = nullptr,
              "Move Paragraph to a different Entry after para_before. "
              "Appends to end if para_before is None.\n Clears undo history of the source Entry" )
        ;

    py::class_< ParserBackGround >( pyLoG, "ParserBackGround" )
        .def( py::init< Diary* >() );  // Minimal exposure

    // ENTRY =======================================================================================
    pyEntry
        .def( "get_parent", &Entry::get_parent,
              py::return_value_policy::reference_internal,
R"(
Returns the Entry's parent Entry if any or None
)"
            )
        .def( "get_text", []( Entry& self ) { return self.get_text().raw(); },
R"(
Returns the Entry's text
)"
             )
        .def( "set_text",
              []( Entry& self, const String& text )
              { self.set_text( text, nullptr ); self.get_undo_stack()->clear(); },
              py::arg( "text" ),
R"(
Sets the Entry's text
Does not update Entry's edit date. Clears undo history
)"
            )
        .def( "set_creation_date",
              []( Entry& self, DateV date ) { self.m_date_created = date; },
              py::arg( "date" ),
R"(
Sets the Entry's creation date
)"
            )
        .def( "get_paragraph", &Entry::get_paragraph_by_no,
              py::arg( "para_no" ),
              py::return_value_policy::reference_internal,
R"(
Returns a Paragraph from the Entry by order (first paragraph is 0)
)"
            )
        .def( "get_paragraph_last", &Entry::get_paragraph_last,
              py::return_value_policy::reference_internal,
R"(
Returns the last Paragraph of the Entry
)"
            )
        .def( "add_paragraphs_after",
              []( Entry& self, Paragraph* para, Paragraph* para_before, ParaInhClass ic )
              { return self.add_paragraphs_after( para,
                                                  para_before,
                                                  ParaInhClasses( ic ) ); },
              py::arg( "para_first_to_add" ), py::arg( "para_before" ),
              py::arg_v( "inheritance_classes", ParaInhClass::DEFAULT, "ParaInhClass.DEFAULT" ),
              py::return_value_policy::reference_internal,
R"(
Add Paragraph chain starting with para_first_to_add
)"
            )
        .def( "add_paragraph_before",
              []( Entry& self, const String& text, Paragraph* para_after )
              { return self.add_paragraph_before( text,
                                                  para_after,
                                                  &Diary::d->m_parser_bg,
                                                  ParaInhClass::DEFAULT ); },
              py::arg( "text" ), py::arg( "para_after" ),
              py::return_value_policy::reference_internal,
R"(
Creates a paragraph and places it before another
Parameters:
text: The content of the new paragraph (mustn't contain new line char)
para_after: The paragraph before which the new para will be inserted
returns: The newly created paragraph
)"
            )
        .def( "append_paragraph",
              []( Entry& self, const String& text )
              { return self.add_paragraph_before( text, nullptr ); },
              py::arg( "text" ),
              py::return_value_policy::reference_internal,
R"(
Creates a paragraph and adds it to the end of the entry
Parameters:
text: The content of the new paragraph (mustn't contain new line char)
returns: The newly created paragraph
)"
             )
        .def( "remove_paragraphs", &Entry::remove_paragraphs,
              py::arg( "para_begin" ), py::arg( "para_end" ) = nullptr,
              py::arg( "add_undo" ) = true,
R"(
Remove Paragraphs begin, end, and anything between from the Entry
Only removes a single Paragraph when end is None
Parameters:
para_begin: The first Paragraph to remove
para_end: The last Paragraph to remove
add_undo: Whether the action will be undoable
)"
             )
        .def( "remove_paragraphs",
              []( Entry& self, int bgn, int end, bool F_add_undo )
              { self.remove_paragraphs( self.get_paragraph_by_no( bgn ),
                                        end > bgn ? self.get_paragraph_by_no( end ) : nullptr,
                                        F_add_undo );
              },
              py::arg( "para_no_begin" ), py::arg( "para_no_end" ) = -1,
              py::arg( "add_undo" ) = true,
R"(
Remove Paragraphs by order between para_no_begin and para_no_end from the Entry
Only removes a single Paragraph when para_no_end < para_no_begin
)"
             )
        .def( "get_todo_status", &Entry::get_todo_status,
R"(
Returns the todo status of the Entry as a VT.ES.* value
)"
             )
        .def( "get_completion", &Entry::get_completion,
R"(
Returns the completion percentage of the Entry as a float between in [0,1] interval
)"
             )
        .def( "get_completion_str", &Entry::get_completion_str,
R"(
Get Entry completion as a string with both percentage and fraction
)"
             )
        .def( "beautify",
              []( Entry& self, Paragraph* p_bgn, Paragraph* p_end )
              { self.beautify_text( p_bgn, p_end, &Diary::d->m_parser_bg ); },
              py::arg( "para_begin" ), py::arg( "para_end" ),
R"(
Beautify the text in the specified range
Converts * to bullet, removes excessive empty lines, etc..
Parameters:
para_begin: first paragraph to beautify
para_end: last paragraph to beautify
)"
             )
        .def( "__iter__",
                []( Entry& self )
                {
                    return py::make_iterator(
                        ParagraphIterator{ self.get_paragraph_1st() },
                        ParagraphIterator{ nullptr } );
                },
                py::keep_alive< 0, 1 >() );  // keep Entry alive while iterator is used

    auto pyTableLine = py::class_< TableLine >( pyLoG, "TableLine" ); // define in advance

    // TABLE COLUMN ================================================================================
    py::class_< TableColumn, Named >( pyLoG, "TableColumn" )
        .def( "format_number",        py::overload_cast< TableLine* >( &TableColumn::format_number,
                                                                       py::const_ )  )
        .def( "get_index",            &TableColumn::get_index );

    // TABLE LINE ==================================================================================
    pyTableLine
        .def( "get_value_num",        &TableLine::get_value_num  )
        .def( "set_value_num",        &TableLine::set_value_num  )
        .def( "get_value_txt",
              []( TableLine& self, int i ) { return self.get_value_txt( i ).raw(); } )
        .def( "set_value_txt",
              []( TableLine& self, int i_col, const String& text )
              { self.set_value_txt( i_col, text ); } )
        .def( "get_elem",
              []( TableLine& self ) { return self.m_p2elem; } )
        ;

    pyDiary
        // .def( py::init<>() )
        // below is not suppted by stubgenerator. nor is d clear. so we may better ditch it in Py
        // .def_property_readonly_static( "d", []( py::object self ) -> Diary* { return Diary::d; },
        //       py::return_value_policy::reference_internal,
        //       "The currently opened main Diary" )
        .def_static( "get_main", []() { return Diary::d; },
              py::return_value_policy::reference,
"Returns the currently opened main Diary"
                   )
        .def( "get_rel_folder_path",  &Diary::get_rel_folder_path,
"Returns path to the relative folder for the current entry"
" which is a subfolder in the Diary's folder in the form [DIARYNAME]"

            )
        .def( "get_rel_folder_name",  &Diary::get_rel_folder_name,

"Returns the name of the relative folder for the current entry"
" which takes the form [DIARYNAME] (sanitized)"
            )
        .def( "create_entry",
              []( py::object self, int ets, const String& text )
              { return Diary::d->create_entry( nullptr, true, Date::get_now(), text, ets ); },
              py::arg( "title_style" ), py::arg( "text" ) = "",
              py::return_value_policy::reference_internal,
R"(
Creates an empty Entry and adds it to the end of hierarchy
Parameters:
title_style: Title style (VT.ETS) to be used in the created Entry
text: Contents for the new Entry (optional)
returns: the newly created Entry
)"
            )
        .def( "create_entry_rel",
              []( py::object self, Entry* e, bool F_parent, const String& text )
              { return Diary::d->create_entry( e, F_parent,
                                                  Date::get_now(),
                                                  text,
                                                  VT::ETS::INHERIT::I ); },
              py::arg( "entry_rel" ), py::arg( "is_parent" ), py::arg( "text" ) = "",
              py::return_value_policy::reference_internal,
R"(
Creates an Entry with given text and places it in the specified position
Parameters:
entry_rel: Related Entry, a parent or sibling. If it is None, Entry will be added as a top level
)"
"is_parent: If True entry_rel is a parent else a sibling."
" If True, Entry is added as the first child to the entry_rel."
" If entry_rel is None, determines if entry will be added to the beginning (False) or end (True)\n"
"text: Contents for the new Entry (optional)\n"
"returns: The newly created Entry"
            )
        .def( "create_entry_child",
              []( py::object self, Entry* e, const String& text )
              { return Diary::d->create_entry_child( e,
                                                     Date::get_now(),
                                                     text,
                                                     VT::ETS::INHERIT::I ); },
              py::arg( "entry_parent" ), py::arg( "text" ) = "",
              py::return_value_policy::reference_internal,
R"(
Creates an Entry with given text as the last child of entry_parent
Parameters:
entry_parent: Parent Entry
text: Contents for the new Entry (optional)
returns: The newly created Entry
)"
            )
        .def( "get_size",             &Diary::get_size,
"Returns the number of entries in the Diary"
            )
        .def( "get_entry_by_id",
              []( Diary& self, uint32_t id ) -> Entry*
              { return self.get_entry_by_id( LoGID32( id ) ); },
              py::arg( "id" ),
              py::return_value_policy::reference_internal,
"Returns the Entry with the given id if any"
            )
        .def( "get_entry_by_name",
              []( Diary& self, const String& name ) { return self.get_entry_by_name( name ); },
              py::arg( "name" ),
              py::return_value_policy::reference_internal,
R"(
Returns the first Entry with the given name
Please note that there may be multiple entries sharing a name
)"
             )
        .def( "get_entry_by_name_fuzzy",
              []( Diary& self, const String& name )
              { return self.get_entry_by_name_fuzzy( name ); },
              py::arg( "name" ),
              py::return_value_policy::reference_internal,
R"(
Returns the Entry with the name most closely matching the argument
)"
             )
        .def( "get_tag_by_id",
              []( Diary& self, uint32_t id ) -> DiaryElemTag*
              { return self.get_tag_by_id( LoGID32( id ) ); },
              py::arg( "id" ),
              py::return_value_policy::reference_internal,
"Returns the DiaryElemTag with the given id if any"
             )
        .def( "get_paragraph_by_id",
              []( Diary& self, uint32_t id ) ->Paragraph*
              { return self.get_paragraph_by_id( LoGID32( id ) ); },
              py::arg( "id" ),
              py::return_value_policy::reference_internal,
"Returns the Paragraph with the given id if any"
             )
        ;

    pyLoG.def( "show_element",
              []( uint32_t id ) { AppWindow::p->show_elem( LoGID32( id ) ); },
              py::arg( "id" ),
"Shows the DiaryElement with the id if it exists"
" (Works for Entries, Paragraphs, Charts, and Tables)"
             );
    pyLoG.def( "update_for_new_entry",
              []( Entry* e ) { AppWindow::p->UI_diary->update_for_added_entry( e ); },
              py::arg( "entry" ),
"Adds entry to the list and displays it"
             );
    pyLoG.def( "refresh_panels",
           []() { AppWindow::p->refresh_panels(); },
"Refreshes text editor, entry list, and the extra panel to reflect changes"
             );
    pyLoG.def( "show_message",
           []( const String& msg ) { AppWindow::p->show_info( msg ); },
           py::arg( "message" ),
"Displays the given message in the info bar"
             );
    pyLoG.def( "get_user_input",
           []( const String& info, const String& name,
               const String& cb_name, const py::list& fields )
           { DialogScriptPrompt::show( info, name, cb_name, fields ); },
           py::arg( "info" ),
           py::arg( "name of the function to call after" ),
           py::arg( "Name of the dialog" ),
           py::arg( "List of fields" ),
R"(
Displays a dialog for collecting user input and executes a script upon completion
Parameters:
info: Descriptive test to be shown on the dialog
name: Name of the script to call upon completion
cb_name: Name of the callback funtion in that script
fields: List of input fields as a fields in the form of FTs
)"
             );

    // make available globally
    py::module_::import( "sys" ).attr( "modules" )[ "LoG" ] = pyLoG;
}

py::module_&
init_if_needed()
{
    // static std::unique_ptr< py::scoped_interpreter >   interpreter  { nullptr };
    static py::module_                                pyLoG;
    std::mutex                                        init_mutex;
    std::lock_guard< std::mutex >                     lock         { init_mutex };
    static const char*                                embedded_jedi_module =
R"PYCODE(
import jedi

# reused for performance
LIFEOGRAPH_PROJECT = jedi.Project(
    path=".", # dummy value
    added_sys_path=[PATH_STUBS], # filled in C++
)

def get_completions(source: str, cursor_pos: int):
    """Return list of (name, type, docstring) for completions at cursor_pos."""
    try:
        # Compute line and column from absolute offset
        line = source[:cursor_pos].count('\n') + 1
        last_newline = source[:cursor_pos].rfind('\n')
        if last_newline == -1:
            column = cursor_pos
        else:
            column = cursor_pos - last_newline - 1

        script = jedi.Script(source, path='buffer.py', project=LIFEOGRAPH_PROJECT)
        completions = script.complete(line=line, column=column)

        result = []
        for c in completions:
            result.append((c.name, c.type, c.docstring(), c.get_completion_prefix_length()))
        return result
    except Exception:
        return []
)PYCODE";

    if( !Py_IsInitialized() )
    {
        PRINT_DEBUG( " ******* PYTHON IS BEING INITIALIZED *********" );

        Py_Initialize();
        // Register an atexit handler that runs BEFORE static destructors
        std::atexit(
            []()
            {
                if( Py_IsInitialized() )
                {
                    Py_Finalize();
                }
            } );
    // }
    // if( !interpreter )
    // {
        // interpreter = std::make_unique< py::scoped_interpreter >();

        py::object sys = py::module_::import( "sys" );

        pyLoG = py::module_::create_extension_module( "LoG", nullptr, new py::module_::module_def );

        // add py dir for internal imports to work:
        sys.attr("path").attr("insert")(0, Lifeograph::SHAREDIR + "/py");

        // add user defined dir for extra environment:
        if( !Lifeograph::settings.python_path.empty() )
            sys.attr( "path" ).attr( "insert" )( 0, Lifeograph::settings.python_path );

        py::dict module_dict; // create a new empty module namespace (a Python dict)
        module_dict["PATH_STUBS"] = ( Lifeograph::SHAREDIR + "/pystubs" );
        sys.attr( "modules" )[ "__embedded_jedi__" ] = module_dict; // register the new module name
        try
        {
            py::exec( embedded_jedi_module, module_dict ); // execute inside that module's dictionary
            Script::get_completions_func = module_dict[ "get_completions" ];
        }
        catch( py::error_already_set& e )
        {
            notify_py_issue( STR::compose( "Error initializing Jedi: ", e.what() ) );
        }
        catch( ... )
        {
            notify_py_issue( "Unknown error while initializing Jedi" );
        }

#ifndef PY_STUB_BUILD
        setup_bindings( pyLoG );
#endif
    }

    return pyLoG;
}

ScriptCategory
get_script_ctg_from_str( const Ustring& str )
{
    if( str == "ENTRY_MODIFIER" )   return ScriptCategory::ENTRY_MODIFIER;
    if( str == "EXPORTER" )         return ScriptCategory::EXPORTER;
    if( str == "FILTER" )           return ScriptCategory::FILTER;
    if( str == "IMPORTER" )         return ScriptCategory::IMPORTER;
    if( str == "PARA_MODIFIER" )    return ScriptCategory::PARA_MODIFIER;
    if( str == "TABLE_COLUMN" )     return ScriptCategory::TABLE_COLUMN;
    if( str == "WORD_MODIFIER" )    return ScriptCategory::WORD_MODIFIER;
    return ScriptCategory::NONE;
}

PyBindings::VecCompletions
PyBindings::get_completions( const String& source, int cursor_pos )
{
    init_if_needed();

    auto            result { Script::get_completions_func( source, cursor_pos ) };
    VecCompletions  completions;

    for( auto item : result )
    {
        py::sequence seq { py::cast< py::sequence >( item ) };
        completions.emplace_back( seq[ 0 ].cast< String >(),
                                  seq[ 1 ].cast< String >(),
                                  seq[ 2 ].cast< String >(),
                                  seq[ 3 ].cast< int >() );
    }
    return completions;
}

// Script*
// add_script( const String& source )
// {
//     auto      script_env  { init_if_needed() };
//     Script*   script      { nullptr };
//     py::dict  globals; // create a fresh dictionary as the script's global namespace
//
//     globals[ "__builtins__" ] = py::module_::import( "builtins" );
//
//     try
//     {
//         py::exec( source, globals );
//
//         if( !( globals.contains( "NAME" ) && globals.contains( "CATEGORY" ) ) ) return nullptr;
//
//         // extract metadata
//         String name = py::str( globals[ "NAME" ] );
//         ScriptCategory ctg = globals[ "CATEGORY" ].cast< ScriptCategory >();
//         py::object run_func = globals[ "run" ];
//
//         if( !script_vault.redef_check( name ) )
//             return nullptr;
//         else if( !name.empty() && ctg != ScriptCategory::NONE && run_func )
//         // check for run may be dropped, we can regard scripts without run...
//         // ...as runnable from other functions by supplying their names
//         {
//             script = new Script( name, ctg, std::move( globals ), source );
//             script_vault.insert( script );
//         }
//         else
//             notify_py_issue( "Script could not be added" );
//     }
//     catch( py::error_already_set &e )
//     {
//         notify_py_issue( e.what() );
//     }
//     catch( ... )
//     {
//         notify_py_issue( "Unknown Python error" );
//     }
//
//     return script;
// }
Script*
add_script( const String& name, ScriptCategory ctg, const String& path )
{
    auto      script_env  { init_if_needed() };
    Script*   script      { nullptr };

    if( !script_vault.redef_check( name ) )
        return nullptr;
    else if( !name.empty() && ctg != ScriptCategory::NONE )
    {
        script = new Script( name, ctg, path );
        script_vault.insert( script );
    }

    return script;
}
Script*
add_script( const String& name, ScriptCategory ctg, const Paragraph* p2para_bgn )
{
    auto      script_env  { init_if_needed() };
    Script*   script      { nullptr };

    if( !script_vault.redef_check( name ) )
        return nullptr;
    else if( !name.empty() && ctg != ScriptCategory::NONE )
    {
        script = new Script( name, ctg, p2para_bgn );
        script_vault.insert( script );
    }

    return script;
}
// void
// PyBindings::remove_script( const String& name )
// {
//     script_vault.dismiss( name );
// }

void
PyBindings::remove_entry_based_scripts()
{
    script_vault.delete_if( [ & ]( const Script* s ){ return s->is_entry_based(); } );
}
void
PyBindings::remove_file_based_scripts()
{
    script_vault.delete_if( [ & ]( const Script* s ){ return s->is_file_based(); } );
}

inline Script*
prepare_script_for_run( const String& name )
{
    auto s { script_vault.get( name ) };

    if( !s )
    {
        notify_py_issue( STR::compose( "Script not found: ", name ) );
        return nullptr;
    }

    if( s->is_in_dirty_source() ) PyBindings::update_for_entry( s->get_related_entry(), true );

    return( ( s->m_F_loaded || s->load() ) ? s : nullptr );
}

inline void
notify_run_error( const py::error_already_set& e )
{
    notify_py_issue( STR::compose( ( e.matches( PyExc_SyntaxError )
                                     ? ""
                                     : "Syntax error: " ), e.what() ) );

    // Optional: restore error so Python can re-raise if needed
    // e.restore();
}

template< typename... Args >
bool
PyBindings::run_script_name( const String& name, Args&&... args )
{
    if( auto* s = prepare_script_for_run( name ); s ) // C++17 if with initializer
    {
        try
        {
            s->run_func( std::forward< Args >( args )... );
            return true;
        }
        catch( const py::error_already_set& e )
        {
            notify_run_error( e );
        }
    }

    return false;
}

template< typename... Args >
bool
PyBindings::run_script_name_func( const String& name, const String& func_name, Args&&... args )
{
    if( auto* s = prepare_script_for_run( name ); s ) // C++17 if with initializer
    {
        try
        {
            if( !s->globals.contains( func_name ) )
                notify_py_issue( STR::compose( "Script does not have the function: ", func_name ) );

            auto func { s->globals[ func_name.c_str() ] };
            func( std::forward< Args >( args )... );
            return true;
        }
        catch( const py::error_already_set& e )
        {
            notify_run_error( e );
        }
    }

    return false;
}

template< typename T, typename... Args >
T
PyBindings::run_script_name_return( const String& name, const String& func_name, Args&&... args )
{
    if( auto* s = prepare_script_for_run( name ); s ) // C++17 if with initializer
    {
        try
        {
            if( !s->globals.contains( func_name ) )
            {
                notify_py_issue( STR::compose( "Script does not have the function: ", func_name ) );
                return T{};
            }

            auto        func    { s->globals[ func_name.c_str() ] };
            py::object  result  { func( std::forward< Args >( args )... ) };

            if constexpr( std::is_same_v< T, py::object > )
                return result; // no cast
            else
                return result.cast< T >();
        }
        catch( const py::error_already_set& e )
        {
            notify_run_error( e );
        }
        catch( const py::cast_error& )
        {
            notify_py_issue( STR::compose( "Invalid Python return type: ", func_name ) );
        }
    }
    return T{};
}

// explicit instantiations
template bool PyBindings::run_script_name<>( const String& );
template bool PyBindings::run_script_name< const String& >( const String&, const String& );
template bool PyBindings::run_script_name< String >( const String&, String&& );
template bool PyBindings::run_script_name< const py::list& >( const String&, const py::list& );
template bool PyBindings::run_script_name< Paragraph*&, const int&, const int& >( const String&,
                                                                                  Paragraph*&,
                                                                                  const int&,
                                                                                  const int& );
template<> bool PyBindings::run_script_name< const ListParagraphs& >( const String& name,
                                                                      const ListParagraphs& paras )
{
    pybind11::list py_paras;
    for( auto* p : paras )
        py_paras.append( pybind11::cast( p ) );  // wrap each Paragraph* as Python object
    return PyBindings::run_script_name( name, py_paras );
}
template bool PyBindings::run_script_name< Entry*& >( const String&, Entry*& );
template<> bool PyBindings::run_script_name< const String&, const VecEntries& >(
        const String& name, const String& path, const VecEntries& entries )
{
    pybind11::list py_entries;
    for( auto* e : entries )
        if( !e->is_locked() ) // do not execute scripts on locked entries (for now)
            py_entries.append( pybind11::cast( e ) );  // wrap each Entry* as Python object
    return PyBindings::run_script_name( name, path, py_entries );
}
template<> bool PyBindings::run_script_name< const EntrySelection& >(
        const String& name, const EntrySelection& entries )
{
    pybind11::list py_entries;
    for( auto* e : entries )
        if( !e->is_locked() ) // do not execute scripts on locked entries (for now)
            py_entries.append( pybind11::cast( e ) );  // wrap each Entry* as Python object
    return PyBindings::run_script_name( name, py_entries );
}
// template bool PyBindings::run_script_name< TableColumn*&, TableLine*& >( const String&,
//                                                                          TableColumn*&,
//                                                                          TableLine*& );

template bool PyBindings::run_script_name_func< const py::dict& >( const String&,
                                                                   const String&,
                                                                   const py::dict& );
template String PyBindings::run_script_name_return< String >( const String&, const String& );
template bool PyBindings::run_script_name_return< bool, const Paragraph*& >( const String&,
                                                                             const String&,
                                                                             const Paragraph*& );
template bool PyBindings::run_script_name_return< bool, const Entry*& >( const String&,
                                                                         const String&,
                                                                           const Entry*& );
template bool PyBindings::run_script_name_return< bool, const double& >( const String&,
                                                                         const String&,
                                                                         const double& );
template<> bool PyBindings::run_script_name_return< bool, const String&, const VecEntries& >(
        const String& name, const String& func, const String& path, const VecEntries& entries )
{
    pybind11::list py_entries;
    for( auto* e : entries )
        if( !e->is_locked() ) // do not execute scripts on locked entries (for now)
            py_entries.append( pybind11::cast( e ) );  // wrap each Entry* as Python object
    return PyBindings::run_script_name_return< bool >( name, func, path, py_entries );
}
template py::object PyBindings::run_script_name_return< py::object, TableColumn*&, TableLine*& >(
                                                                    const String&,
                                                                    const String&,
                                                                    TableColumn*&,
                                                                    TableLine*& );

void
PyBindings::load_script_files( const String& root_path )
{
    if ( !std::filesystem::exists( root_path ) )
    {
        print_info( "Directory does not exist: ", root_path );
        return;
    }

    script_vault.clear_by_path( root_path );

    for( const auto& node : std::filesystem::recursive_directory_iterator( root_path ) )
    {
        PRINT_DEBUG( "**** node path = ", node.path().string() );
        if ( node.is_regular_file() && node.path().extension() == ".py" )
            preload_py_file( node.path() );
    }
}

void
process_script_line( const String& line, String& name, String& category )
{
    static const auto  regex_name { Glib::Regex::create( R"((^NAME\s*=\s*\")([^\"]+)(\"))" ) };
    static const auto  regex_ctg  { Glib::Regex::create( R"((^CATEGORY\s*=\s*SCtg\.)(\w+))" ) };

    // search for NAME pattern
    if( name.empty() )
    {
        Glib::MatchInfo match_info;
        if( regex_name->match( line.c_str(), match_info ) )
        {
            int pos_bgn, pos_end;
            match_info.fetch_pos( 2, pos_bgn, pos_end );
            name = line.substr( pos_bgn, pos_end - pos_bgn );
        }
    }

    // search for CATEGORY pattern
    if( category.empty() )
    {
        Glib::MatchInfo match_info;
        if( regex_ctg->match( line.c_str(), match_info ) )
        {
            int pos_bgn, pos_end;
            match_info.fetch_pos( 2, pos_bgn, pos_end );
            category = line.substr( pos_bgn, pos_end - pos_bgn );
        }
    }
}

void
PyBindings::preload_py_file( const std::filesystem::path& filePath )
{
    std::ifstream file( filePath );
    if( !file.is_open() )
    {
        print_error( "Warning: Could not open file: ", filePath );
        return;
    }

    String  name;
    String  category;
    String  line;

    while( std::getline( file, line ) )
    {
        if( line.empty() ) continue; // skip empty lines

        process_script_line( line, name, category );

        if( !name.empty() && !category.empty() ) break; // early exit if we found both values
    }

    file.close();

    // only add to results if we found both NAME and CATEGORY
    if( !name.empty() && !category.empty() )
    {
        add_script( name, get_script_ctg_from_str( category ), filePath.string() );
        PRINT_DEBUG( "Found: ", filePath.string(), " NAME=", name, "CATEGORY=Sctg.", category );
    }
}
void
PyBindings::preload_py_paras( const Paragraph* p_bgn, const Paragraph* p_end )
{
    String  name;
    String  category;

    for( const Paragraph* p = p_bgn; p; p = p->m_p2next )
    {
        if( !p->is_empty() ) // skip empty lines
        {
            process_script_line( p->get_text(), name, category );
            if( !name.empty() && !category.empty() ) break; // early exit if we found both values
        }
        if( p == p_end ) break; // end of script
    }

    // only add to results if we found both NAME and CATEGORY
    if( !name.empty() && !category.empty() )
    {
        add_script( name, get_script_ctg_from_str( category ), p_bgn );
        PRINT_DEBUG( "Found in paras ", "NAME=", name, "CATEGORY=Sctg.", category );
    }
}

void
PyBindings::update_scripts_in_entries( bool F_dirty_only )
{
    for( Entry* e : *Diary::d )
    {
        if( e->registers_scripts() )
        {
            if( !F_dirty_only || e->get_date_edited() > get_entry_check_date() )
                update_for_entry( e, true );
        }
    }

    set_entry_check_date( Date::get_now() );
}

void
PyBindings::update_for_entry( const Entry* p2entry, bool F_add )
{
    script_vault.delete_if(
            [ & ]( const Script* s ){ return s->belongs_to( p2entry->get_id() ); } );

    if( !F_add ) return;

    for( Paragraph* p_bgn = p2entry->get_paragraph_1st(), * p_end = nullptr;
         p_bgn;
         p_bgn = ( p_end ? p_end->m_p2next : nullptr ) )
    {
        const auto src_text { p2entry->get_script_bounds( p_bgn, p_end ) };
        if( src_text.empty() ) break;

        preload_py_paras( p_bgn, p_end );
    }
}

void
PyBindings::get_scripts( ScriptCategory ctg, VecUstrings& scripts )
{
    for( Script* s : script_vault )
    {
        if( s->m_category == ctg )
            scripts.push_back( s->get_name() );
    }

}

void
PyBindings::get_scripts( const Entry* p2entry, VecUstrings& scripts )
{
    for( Script* s : script_vault )
    {
        if( s->belongs_to( p2entry->get_id() ) )
            scripts.push_back( s->get_name() );
    }
}

DateV
PyBindings::get_entry_check_date()
{ return s_last_entry_check_date; }
void
PyBindings::set_entry_check_date( DateV date )
{ s_last_entry_check_date = date; };

void
PyBindings::edit_script( const String& name )
{
    auto s { script_vault.get( name ) };
    if( !s )
    {
        notify_py_issue( STR::compose( "Script not found: ", name ) );
        return;
    }

    Paragraph* p { s->get_related_paragraph() };
    if( p )
        AppWindow::p->show_elem( p );
    else
    {
        if( Lifeograph::settings.external_text_edit_cmd.empty() )
        {
            Gio::AppInfo::launch_default_for_uri_async( s->get_path() );
        }
        else
        {
            try
            {
                String  editor_cmd  { Lifeograph::settings.external_text_edit_cmd };
                auto    pos_f       { editor_cmd.find( "%f" ) };

                if( pos_f == String::npos )
                    editor_cmd += s->get_path();
                else
                    editor_cmd.replace( pos_f, 2, s->get_path() );

                Glib::spawn_command_line_async( editor_cmd );
            }
            catch( const Glib::SpawnError& ex )
            {
                print_error( "Failed to launch editor: ", ex.what() );
            }
        }
    }
}

void
PyBindings::run_script_onthefly( Paragraph* para )
{
    auto        script_env  { init_if_needed() };
    Paragraph*  p_bgn, * p_end;
    para->get_code_block( p_bgn, p_end );
    auto        source      { p_bgn->m_host->get_text_partial_code( p_bgn, p_end ) };
    py::dict    globals; // create a fresh dictionary as the script's global namespace

    Script::s_F_suppress_redef_check = true; // override the existing if any
    preload_py_paras( p_bgn, p_end ); // adds the script to vault
    Script::s_F_suppress_redef_check = false;

    globals[ "__builtins__" ] = py::module_::import( "builtins" );

    try
    {
        py::exec( source, globals );
    }
    catch( py::error_already_set &e )
    {
        notify_py_issue( e.what() );
    }
    catch( ... )
    {
        notify_py_issue( "Unknown Python error" );
    }
}

