#!/usr/bin/env python3
"""Build pinned i3lock-color with Monocoat's skin and submit button. No root writes."""
import argparse
import shutil
import subprocess
from pathlib import Path

REVISION = "e6c0caf9b7aa22cc7864493132a1a2e258da1761"

# The i3lock.c additions below are plain text so the native tests can compile
# them against the skin (tests/test_lockscreen_interaction.py).

# libpam calls this instead of sleeping off a module's fail delay (pam_unix
# asks for ~2 s), which would freeze the single event loop on one frame. The
# rejection is held at least that long instead: Return and the arrow are
# deferred while it shows, so the attempt rate stays throttled.
FAIL_DELAY = '''
#ifndef __OpenBSD__
static double monocoat_wrong_seconds = 2;
static void monocoat_fail_delay(int status, unsigned int usec, void *data) {
    (void)data;
    double seconds = usec / 1000000.0;
    monocoat_wrong_seconds = status != PAM_SUCCESS && seconds > 2 ? seconds : 2;
}
#endif
'''

# The arrow and Space submit exactly as Return does: deferred to the end of a
# rejection (retry_verification), never past its throttle.
SUBMIT = '''
static void monocoat_submit(void) {
    if (input_position == 0) return;
    if (auth_state == STATE_AUTH_WRONG) retry_verification = true;
    else if (auth_state == STATE_AUTH_IDLE) finish_input();
}
'''

# The skin draws a dot per character, but input_position counts the UTF-8
# bytes in i3lock.c's static password buffer: count the characters there
# (every byte but a continuation byte), never keep a second counter that a
# reset path could miss. Only a count leaves this file, never a byte.
INPUT_CHARS = '''
int monocoat_input_chars(void) {
    int chars = 0;
    for (int i = 0; i < input_position; i++)
        if (((unsigned char)password[i] & 0xC0) != 0x80) chars++;
    return chars;
}
'''

# What PAM tells the user beside its prompts (pam_faillock's lockout notice,
# which refuses even the right password) reaches the skin's hint instead of
# being dropped: module text only, never a password.
CONV_NEEDLE = '''        if (msg[c]->msg_style != PAM_PROMPT_ECHO_OFF &&
            msg[c]->msg_style != PAM_PROMPT_ECHO_ON)
            continue;
'''
CONV_HOOK = '''        if (msg[c]->msg_style != PAM_PROMPT_ECHO_OFF &&
            msg[c]->msg_style != PAM_PROMPT_ECHO_ON) {
            if (msg[c]->msg_style == PAM_ERROR_MSG || msg[c]->msg_style == PAM_TEXT_INFO)
                monocoat_pam_message(msg[c]->msg);
            continue;
        }
'''

POINTER_HOOK = '''
            case XCB_MOTION_NOTIFY:
            case XCB_BUTTON_RELEASE:
            case XCB_BUTTON_PRESS: {
                xcb_button_press_event_t *button = (xcb_button_press_event_t *)event;
                if (type == XCB_MOTION_NOTIFY || button->detail == 1) {
                    int action = type == XCB_MOTION_NOTIFY ? 0 : (type == XCB_BUTTON_PRESS ? 1 : 2);
                    if (monocoat_pointer(button->root_x, button->root_y, action)) monocoat_submit();
                }
                break;
            }
'''

KEY_HOOK = '''
    int mc_action = monocoat_key(ksym,
        xkb_state_mod_name_is_active(xkb_state, XKB_MOD_NAME_SHIFT, XKB_STATE_MODS_EFFECTIVE),
        xkb_state_mod_name_is_active(xkb_state, XKB_MOD_NAME_CAPS, XKB_STATE_MODS_LOCKED));
    if (mc_action) {
        if (mc_action == 2) monocoat_submit();
        return;
    }
'''


# Upstream re-raises the lock window on VisibilityNotify only, which its
# depth-32 window (redirected by the server, and by any compositor) never
# receives: a notification or the dock's banner mapped while locked stayed
# on top, showing its text. The raise child watches the lock window's
# siblings instead and puts the lock back on top of whatever maps or is
# restacked over it. Raising only a window actually over the lock keeps
# it from restacking on every move of the windows under it.
RAISE = '''
static bool monocoat_covered(xcb_connection_t *conn, xcb_window_t parent, xcb_window_t lock,
                             xcb_window_t other) {
    xcb_query_tree_reply_t *tree = xcb_query_tree_reply(conn, xcb_query_tree(conn, parent), NULL);
    if (!tree) return false;
    xcb_window_t *children = xcb_query_tree_children(tree);
    int count = xcb_query_tree_children_length(tree), at_lock = -1, at_other = -1;
    for (int i = 0; i < count; i++) {
        if (children[i] == lock) at_lock = i;
        if (children[i] == other) at_other = i;
    }
    free(tree);
    return at_lock >= 0 && at_other > at_lock;
}

static xcb_window_t monocoat_watch_siblings(xcb_connection_t *conn, xcb_window_t lock) {
    xcb_query_tree_reply_t *tree = xcb_query_tree_reply(conn, xcb_query_tree(conn, lock), NULL);
    xcb_window_t parent = tree ? tree->parent : XCB_NONE;
    free(tree);
    if (parent != XCB_NONE)
        xcb_change_window_attributes(conn, parent, XCB_CW_EVENT_MASK,
                                     (uint32_t[]){XCB_EVENT_MASK_SUBSTRUCTURE_NOTIFY});
    xcb_flush(conn);
    return parent;
}

static void monocoat_raise(xcb_connection_t *conn, xcb_window_t lock) {
    xcb_configure_window(conn, lock, XCB_CONFIG_WINDOW_STACK_MODE, (uint32_t[]){XCB_STACK_MODE_ABOVE});
    xcb_flush(conn);
}
'''

RAISE_WATCH_NEEDLE = '''                                     XCB_EVENT_MASK_VISIBILITY_CHANGE |
                                     XCB_EVENT_MASK_STRUCTURE_NOTIFY});
    xcb_flush(conn);
'''

RAISE_EVENTS = '''            case XCB_MAP_NOTIFY: {
                /* A window mapped above the lock (one created after it). */
                xcb_window_t other = ((xcb_map_notify_event_t *)event)->window;
                if (other != window && monocoat_parent != XCB_NONE &&
                    monocoat_covered(conn, monocoat_parent, window, other))
                    monocoat_raise(conn, window);
                break;
            }
            case XCB_CONFIGURE_NOTIFY: {
                /* A window restacked directly over the lock. */
                xcb_configure_notify_event_t *configure = (xcb_configure_notify_event_t *)event;
                if (configure->window != window && configure->above_sibling == window)
                    monocoat_raise(conn, window);
                break;
            }
'''

# Upstream maps the window with only its --color background and sets the
# rendered frame as its background afterwards, which a mapped window does
# not repaint: a flat panel showed until the post-grab redraw (110 ms at
# 5120x1440, more under a compositor), then the picture cut in. The window
# is mapped once the first frame is its background instead, still before
# the grab and the sleep inhibitor's release.
XCB_MAP_NEEDLE = '''    /* Map the window (= make it visible) */
    xcb_map_window(conn, win);

    /* Raise window (put it on top) */
    values[0] = XCB_STACK_MODE_ABOVE;
    xcb_configure_window(conn, win, XCB_CONFIG_WINDOW_STACK_MODE, values);

    /* Ensure that the window is created and set up before returning */
    xcb_aux_sync(conn);
'''
XCB_MAP_LATER = '''    /* Mapped by main() once the first frame is the window's background. */
    xcb_aux_sync(conn);
'''
MAP_NEEDLE = '''    xcb_change_window_attributes(conn, win, XCB_CW_BACK_PIXMAP, (uint32_t[]){pixmap});
    xcb_free_pixmap(conn, pixmap);
'''
MAP_AFTER_PAINT = '''
    /* Map the window (= make it visible) with its first frame in place */
    xcb_map_window(conn, win);
    xcb_configure_window(conn, win, XCB_CONFIG_WINDOW_STACK_MODE, (uint32_t[]){XCB_STACK_MODE_ABOVE});
    xcb_aux_sync(conn);
    monocoat_shown();
'''

# The skin ignores unlock_state, so finish_input's own redraw drew the frame
# already on screen, a full-root render added to every unlock before PAM.
FINISH_NEEDLE = '''    unlock_state = STATE_KEY_PRESSED;
    redraw_screen();
    input_done();
'''
FINISH_HOOK = '''    unlock_state = STATE_KEY_PRESSED;
    if (!monocoat_skin()) redraw_screen();
    input_done();
'''

# PAM stays synchronous on the event loop; the skin's ticker thread turns
# the "Unlocking..." spinner while it runs and is joined before any state
# changes (macos.h monocoat_verifying).
PAM_NEEDLE = "    if (pam_authenticate(pam_handle, 0) == PAM_SUCCESS) {"
PAM_HOOK = '''    monocoat_wrong_seconds = 2;
    monocoat_pam_message(NULL);
    monocoat_verifying(true);
    int monocoat_rc = pam_authenticate(pam_handle, 0);
    monocoat_verifying(false);
    if (monocoat_rc == PAM_SUCCESS) {'''


def run(*args, cwd=None):
    subprocess.run(args, cwd=cwd, check=True)


def replace_once(text, needle, replacement):
    assert text.count(needle) == 1, needle
    return text.replace(needle, replacement)


def patch_i3lock(text):
    text = replace_once(text, "            case XCB_KEY_PRESS:", POINTER_HOOK + "            case XCB_KEY_PRESS:")
    needle = '#include "unlock_indicator.h"'
    text = replace_once(text, needle, needle + '''
extern bool monocoat_pointer(int16_t x, int16_t y, int event);
extern int monocoat_key(uint32_t key, bool shift, bool caps);
extern void monocoat_caps(bool caps);
extern void monocoat_ui_start(struct ev_loop *loop);
extern void monocoat_pam_message(const char *text);
extern bool monocoat_skin(void);
extern void monocoat_shown(void);
extern void monocoat_verifying(bool verifying);''')
    needle = "static void handle_key_press(xcb_key_press_event_t *event) {"
    text = replace_once(text, needle, SUBMIT + INPUT_CHARS + "\n" + needle)
    needle = "    ctrl = xkb_state_mod_name_is_active(xkb_state, XKB_MOD_NAME_CTRL, XKB_STATE_MODS_DEPRESSED);"
    text = replace_once(text, needle, needle + KEY_HOOK)
    needle = "                                  event->state_notify.lockedGroup);"
    text = replace_once(text, needle, needle + '''
            monocoat_caps(xkb_state_mod_name_is_active(xkb_state, XKB_MOD_NAME_CAPS, XKB_STATE_MODS_LOCKED));
''')
    needle = "    /* Explicitly call the screen redraw in case"
    text = replace_once(text, needle, "    monocoat_caps(xkb_state_mod_name_is_active(xkb_state, XKB_MOD_NAME_CAPS, XKB_STATE_MODS_LOCKED));\n" + needle)
    text = replace_once(text, "    ev_loop(main_loop, 0);", "    monocoat_ui_start(main_loop);\n    ev_loop(main_loop, 0);")
    needle = "#define TSTAMP_N_SECS(n) (n * 1.0)"
    text = replace_once(text, needle, needle + "\n" + FAIL_DELAY)
    text = replace_once(text, PAM_NEEDLE, PAM_HOOK)
    text = replace_once(text, CONV_NEEDLE, CONV_HOOK)
    text = replace_once(text, "    START_TIMER(clear_auth_wrong_timeout, TSTAMP_N_SECS(2), clear_auth_wrong);",
                        "    START_TIMER(clear_auth_wrong_timeout, TSTAMP_N_SECS(monocoat_wrong_seconds), clear_auth_wrong);")
    needle = "    if ((ret = pam_set_item(pam_handle, PAM_TTY, getenv(\"DISPLAY\"))) != PAM_SUCCESS)\n" \
             "        errx(EXIT_FAILURE, \"PAM: %s\", pam_strerror(pam_handle, ret));\n"
    text = replace_once(text, needle, needle + '''
    if ((ret = pam_set_item(pam_handle, PAM_FAIL_DELAY, (const void *)monocoat_fail_delay)) != PAM_SUCCESS)
        errx(EXIT_FAILURE, "PAM: %s", pam_strerror(pam_handle, ret));
''')
    text = replace_once(text, FINISH_NEEDLE, FINISH_HOOK)
    text = replace_once(text, MAP_NEEDLE, MAP_NEEDLE + MAP_AFTER_PAINT)
    needle = "static void raise_loop(xcb_window_t window) {"
    text = replace_once(text, needle, RAISE + "\n" + needle)
    text = replace_once(text, RAISE_WATCH_NEEDLE, RAISE_WATCH_NEEDLE
                        + "    xcb_window_t monocoat_parent = monocoat_watch_siblings(conn, window);\n"
                        + "    monocoat_raise(conn, window);\n")
    needle = "            case XCB_UNMAP_NOTIFY:"
    text = replace_once(text, needle, RAISE_EVENTS + needle)
    return text


def patch_xcb(text):
    needle = "XCB_NONE,            /* which events to let through */"
    text = replace_once(text, needle, "XCB_EVENT_MASK_BUTTON_PRESS | XCB_EVENT_MASK_BUTTON_RELEASE | "
                        "XCB_EVENT_MASK_POINTER_MOTION, /* UI events stay grabbed */")
    return replace_once(text, XCB_MAP_NEEDLE, XCB_MAP_LATER)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    root = Path(__file__).resolve().parent.parent
    source = args.out.resolve() / "i3lock-color"
    source.parent.mkdir(parents=True, exist_ok=True)
    if not source.exists():
        run("git", "clone", "https://github.com/Raymo111/i3lock-color.git", str(source))
    run("git", "checkout", "--detach", REVISION, cwd=source)
    target = source / "unlock_indicator.c"
    text = subprocess.check_output(["git", "show", f"{REVISION}:unlock_indicator.c"], cwd=source, text=True)
    needle = "static void draw_elements(cairo_t *const ctx, DrawData const *const draw_data) {"
    assert text.count(needle) == 1
    text = text.replace(needle, '#include "monocoat-macos.h"\n\n' + needle + '''
    const char *style = getenv("MONOCOAT_LOCK_STYLE");
    if (style && strcmp(style, "macos") == 0) {
        mc_draw(ctx, draw_data);
        return;
    }
''')
    text = text.replace("void render_lock(uint32_t *resolution, xcb_drawable_t drawable) {",
                        "void render_lock(uint32_t *resolution, xcb_drawable_t drawable) {\n    mc_ui.count = 0;")
    target.write_text(text)
    source_text = subprocess.check_output(["git", "show", f"{REVISION}:i3lock.c"], cwd=source, text=True)
    (source / "i3lock.c").write_text(patch_i3lock(source_text))
    xcb_text = subprocess.check_output(["git", "show", f"{REVISION}:xcb.c"], cwd=source, text=True)
    (source / "xcb.c").write_text(patch_xcb(xcb_text))
    # The skin's themed I-beam and hand (macos.h mc_cursor) come from xcb-cursor.
    configure = subprocess.check_output(["git", "show", f"{REVISION}:configure.ac"], cwd=source, text=True)
    (source / "configure.ac").write_text(replace_once(
        configure, "PKG_CHECK_MODULES([XCB_UTIL], [xcb-event xcb-util xcb-atom])",
        "PKG_CHECK_MODULES([XCB_UTIL], [xcb-event xcb-util xcb-atom xcb-cursor])"))
    shutil.copyfile(root / "native/lockscreen/macos.h", source / "monocoat-macos.h")
    shutil.copyfile(root / "native/lockscreen/interaction.h", source / "monocoat-interaction.h")
    run("autoreconf", "-fi", cwd=source)
    build = source / "build"
    build.mkdir(exist_ok=True)
    run("../configure", "--prefix=/usr/local", "--sysconfdir=/etc", cwd=build)
    run("make", "-j2", cwd=build)
    shutil.copy2(build / "i3lock", args.out / "monocoat-locker")
    print(f"Built {args.out / 'monocoat-locker'} from {REVISION}; install as a regular executable.")


if __name__ == "__main__":
    main()
