// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html#License
/*
 *******************************************************************************
 * Copyright (C) 2004-2011, International Business Machines Corporation and    *
 * others. All Rights Reserved.                                                *
 *******************************************************************************
 */

package com.ibm.icu.dev.tool.ime.translit;

import java.awt.AWTEvent;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.Window;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
import java.awt.event.InputMethodEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseEvent;
import java.awt.font.TextAttribute;
import java.awt.font.TextHitInfo;
import java.awt.im.InputMethodHighlight;
import java.awt.im.spi.InputMethod;
import java.awt.im.spi.InputMethodContext;
import java.text.AttributedString;
import java.text.Collator;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.TreeSet;

import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.ListCellRenderer;

import com.ibm.icu.impl.Utility;
import com.ibm.icu.lang.UCharacter;
import com.ibm.icu.text.ReplaceableString;
import com.ibm.icu.text.Transliterator;

public class TransliteratorInputMethod implements InputMethod {

    private static boolean usesAttachedIME() {
        // we're in the ext directory so permissions are not an issue
        String os = System.getProperty("os.name");
        if (os != null) {
            return os.indexOf("Windows") == -1;
        }
        return false;
    }

    // true if Solaris style; false if PC style, assume Apple uses PC style for now
    private static final boolean attachedStatusWindow = usesAttachedIME();

    // the shared status window
    private static Window statusWindow;

    // current or last owner
    private static TransliteratorInputMethod statusWindowOwner;

    // cache location limits for attached
    private static Rectangle attachedLimits;

    // convenience of access, to reflect the current state
    private static JComboBox choices;

    //
    // per-instance state
    //

    // if we're attached, the status window follows the client window
    private Point attachedLocation;

    private static int gid;

    private int id = gid++;

    InputMethodContext imc;
    private boolean enabled = true;

    private int selectedIndex = -1; // index in JComboBox corresponding to our transliterator
    private Transliterator transliterator;
    private int desiredContext;
    private StringBuffer buffer;
    private ReplaceableString replaceableText;
    private Transliterator.Position index;

    // debugging
    private static boolean TRACE_EVENT = false;
    private static boolean TRACE_MESSAGES = false;
    private static boolean TRACE_BUFFER = false;

    public TransliteratorInputMethod() {
        if (TRACE_MESSAGES)
            dumpStatus("<constructor>");

        buffer = new StringBuffer();
        replaceableText = new ReplaceableString(buffer);
        index = new Transliterator.Position();
    }

    public void dumpStatus(String msg) {
        System.out.println("(" + this + ") " + msg);
    }

    public void setInputMethodContext(InputMethodContext context) {
        initStatusWindow(context);

        imc = context;
        imc.enableClientWindowNotification(this, attachedStatusWindow);
    }

    private static void initStatusWindow(InputMethodContext context) {
        if (statusWindow == null) {
            String title;
            try {
                ResourceBundle rb = ResourceBundle
                        .getBundle("com.ibm.icu.dev.tool.ime.translit.Transliterator");
                title = rb.getString("title");
            } catch (MissingResourceException m) {
                System.out.println("Transliterator resources missing: " + m);
                title = "Transliterator Input Method";
            }

            Window sw = context.createInputMethodWindow(title, false);

            // get all the ICU Transliterators
            Enumeration en = Transliterator.getAvailableIDs();
            TreeSet types = new TreeSet(new LabelComparator());

            while (en.hasMoreElements()) {
                String id = (String) en.nextElement();
                String name = Transliterator.getDisplayName(id);
                JLabel label = new JLabel(name);
                label.setName(id);
                types.add(label);
            }

            // add the transliterators to the combo box

            choices = new JComboBox(types.toArray());

            choices.setEditable(false);
            choices.setSelectedIndex(0);
            choices.setRenderer(new NameRenderer());
            choices.setActionCommand("transliterator");

            choices.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    if (statusWindowOwner != null) {
                        statusWindowOwner.statusWindowAction(e);
                    }
                }
            });

            sw.add(choices);
            sw.pack();

            Dimension sd = Toolkit.getDefaultToolkit().getScreenSize();
            Dimension wd = sw.getSize();
            if (attachedStatusWindow) {
                attachedLimits = new Rectangle(0, 0, sd.width - wd.width,
                        sd.height - wd.height);
            } else {
                sw.setLocation(sd.width - wd.width, sd.height - wd.height - 25);
            }

            synchronized (TransliteratorInputMethod.class) {
                if (statusWindow == null) {
                    statusWindow = sw;
                }
            }
        }
    }

    private void statusWindowAction(ActionEvent e) {
        if (TRACE_MESSAGES)
            dumpStatus(">>status window action");
        JComboBox cb = (JComboBox) e.getSource();
        int si = cb.getSelectedIndex();
        if (si != selectedIndex) { // otherwise, we don't need to change
            if (TRACE_MESSAGES)
                dumpStatus("status window action oldIndex: " + selectedIndex
                        + " newIndex: " + si);

            selectedIndex = si;

            JLabel item = (JLabel) cb.getSelectedItem();

            // construct the actual transliterator
            // commit any text that may be present first
            commitAll();

            transliterator = Transliterator.getInstance(item.getName());
            desiredContext = transliterator.getMaximumContextLength();

            reset();
        }
        if (TRACE_MESSAGES)
            dumpStatus("<<status window action");
    }

    // java has no pin to rectangle function?
    private static void pin(Point p, Rectangle r) {
        if (p.x < r.x) {
            p.x = r.x;
        } else if (p.x > r.x + r.width) {
            p.x = r.x + r.width;
        }
        if (p.y < r.y) {
            p.y = r.y;
        } else if (p.y > r.y + r.height) {
            p.y = r.y + r.height;
        }
    }

    public void notifyClientWindowChange(Rectangle location) {
        if (TRACE_MESSAGES)
            dumpStatus(">>notify client window change: " + location);
        synchronized (TransliteratorInputMethod.class) {
            if (statusWindowOwner == this) {
                if (location == null) {
                    statusWindow.setVisible(false);
                } else {
                    attachedLocation = new Point(location.x, location.y
                            + location.height);
                    pin(attachedLocation, attachedLimits);
                    statusWindow.setLocation(attachedLocation);
                    statusWindow.setVisible(true);
                }
            }
        }
        if (TRACE_MESSAGES)
            dumpStatus("<<notify client window change: " + location);
    }

    public void activate() {
        if (TRACE_MESSAGES)
            dumpStatus(">>activate");

        synchronized (TransliteratorInputMethod.class) {
            if (statusWindowOwner != this) {
                if (TRACE_MESSAGES)
                    dumpStatus("setStatusWindowOwner from: " + statusWindowOwner + " to: " + this);

                statusWindowOwner = this;
                // will be null before first change notification
                if (attachedStatusWindow && attachedLocation != null) {
                    statusWindow.setLocation(attachedLocation);
                }
                choices.setSelectedIndex(selectedIndex == -1 ? choices
                        .getSelectedIndex() : selectedIndex);
            }

            choices.setForeground(Color.BLACK);
            statusWindow.setVisible(true);
        }
        if (TRACE_MESSAGES)
            dumpStatus("<<activate");
    }
    
    public void deactivate(boolean isTemporary) {
        if (TRACE_MESSAGES)
            dumpStatus(">>deactivate" + (isTemporary ? " (temporary)" : ""));
        if (!isTemporary) {
            synchronized (TransliteratorInputMethod.class) {
                choices.setForeground(Color.LIGHT_GRAY);
            }
        }
        if (TRACE_MESSAGES)
            dumpStatus("<<deactivate" + (isTemporary ? " (temporary)" : ""));
    }
    
    public void hideWindows() {
        if (TRACE_MESSAGES)
            dumpStatus(">>hideWindows");
        synchronized (TransliteratorInputMethod.class) {
            if (statusWindowOwner == this) {
                if (TRACE_MESSAGES)
                    dumpStatus("hiding");
                statusWindow.setVisible(false);
            }
        }
        if (TRACE_MESSAGES)
            dumpStatus("<<hideWindows");
    }
    
    public boolean setLocale(Locale locale) {
        return false;
    }
    
    public Locale getLocale() {
        return Locale.getDefault();
    }
    
    public void setCharacterSubsets(Character.Subset[] subsets) {
    }

    public void reconvert() {
        throw new UnsupportedOperationException();
    }

    public void removeNotify() {
        if (TRACE_MESSAGES)
            dumpStatus("**removeNotify");
    }

    public void endComposition() {
        commitAll();
    }

    public void dispose() {
        if (TRACE_MESSAGES)
            dumpStatus("**dispose");
    }
    
    public Object getControlObject() {
        return null;
    }
    
    public void setCompositionEnabled(boolean enable) {
        enabled = enable;
    }

    public boolean isCompositionEnabled() {
        return enabled;
    }

    // debugging
    private String eventInfo(AWTEvent event) {
        String info = event.toString();
        StringBuffer buf = new StringBuffer();
        int index1 = info.indexOf("[");
        int index2 = info.indexOf(",", index1);
        buf.append(info.substring(index1 + 1, index2));

        index1 = info.indexOf("] on ");
        index2 = info.indexOf("[", index1);
        if (index2 != -1) {
            int index3 = info.lastIndexOf(".", index2);
            if (index3 < index1 + 4) {
                index3 = index1 + 4;
            }
            buf.append(" on ");
            buf.append(info.substring(index3 + 1, index2));
        }
        return buf.toString();
    }

    public void dispatchEvent(AWTEvent event) {
        final int MODIFIERS = 
            InputEvent.CTRL_MASK | 
            InputEvent.META_MASK | 
            InputEvent.ALT_MASK | 
            InputEvent.ALT_GRAPH_MASK;
    
        switch (event.getID()) {
        case MouseEvent.MOUSE_PRESSED:
            if (enabled) {
                if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(event));
                // we'll get this even if the user is scrolling, can we rely on the component?
                // commitAll(); // don't allow even clicks within our own edit area
            }
            break;
    
        case KeyEvent.KEY_TYPED: {
            if (enabled) {
                KeyEvent ke = (KeyEvent)event;
                if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(ke));
                if ((ke.getModifiers() & MODIFIERS) != 0) {
                    commitAll(); // assume a command, let it go through
                } else {
                    if (handleTyped(ke.getKeyChar())) {
                        ke.consume();
                    }
                }
            }
        } break;
    
        case KeyEvent.KEY_PRESSED: {
            if (enabled) {
            KeyEvent ke = (KeyEvent)event;
            if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(ke));
                if (handlePressed(ke.getKeyCode())) {
                    ke.consume();
                }
            }
        } break;
    
        case KeyEvent.KEY_RELEASED: {
            // this won't autorepeat, which is better for toggle actions
            KeyEvent ke = (KeyEvent)event;
            if (ke.getKeyCode() == KeyEvent.VK_SPACE && ke.isControlDown()) {
                setCompositionEnabled(!enabled);
            }
        } break;
    
        default:
            break;
        }
    }

    /** Wipe clean */
    private void reset() {
        buffer.delete(0, buffer.length());
        index.contextStart = index.contextLimit = index.start = index.limit = 0;
    }

    // committed}context-composed|composed
    //          ^       ^        ^
    //         cc     start    ctxLim

    private void traceBuffer(String msg, int cc, int off) {
        if (TRACE_BUFFER)
            System.out.println(Utility.escape(msg + ": '"
                    + buffer.substring(0, cc) + '}'
                    + buffer.substring(cc, index.start) + '-'
                    + buffer.substring(index.start, index.contextLimit) + '|'
                    + buffer.substring(index.contextLimit) + '\''));
    }

    private void update(boolean flush) {
        int len = buffer.length();
        String text = buffer.toString();
        AttributedString as = new AttributedString(text);

        int cc, off;
        if (flush) {
            off = index.contextLimit - len; // will be negative
            cc = index.start = index.limit = index.contextLimit = len;
        } else {
            cc = index.start > desiredContext ? index.start - desiredContext
                    : 0;
            off = index.contextLimit - cc;
        }

        if (index.start < len) {
            as.addAttribute(TextAttribute.INPUT_METHOD_HIGHLIGHT,
                    InputMethodHighlight.SELECTED_RAW_TEXT_HIGHLIGHT,
                    index.start, len);
        }

        imc.dispatchInputMethodEvent(
                InputMethodEvent.INPUT_METHOD_TEXT_CHANGED, as.getIterator(),
                cc, TextHitInfo.leading(off), null);

        traceBuffer("update", cc, off);

        if (cc > 0) {
            buffer.delete(0, cc);
            index.start -= cc;
            index.limit -= cc;
            index.contextLimit -= cc;
        }
    }

    private void updateCaret() {
        imc.dispatchInputMethodEvent(InputMethodEvent.CARET_POSITION_CHANGED,
                null, 0, TextHitInfo.leading(index.contextLimit), null);
        traceBuffer("updateCaret", 0, index.contextLimit);
    }
    
    private void caretToStart() {
        if (index.contextLimit > index.start) {
            index.contextLimit = index.limit = index.start;
            updateCaret();
        }
    }

    private void caretToLimit() {
        if (index.contextLimit < buffer.length()) {
            index.contextLimit = index.limit = buffer.length();
            updateCaret();
        }
    }

    private boolean caretTowardsStart() {
        int bufpos = index.contextLimit;
        if (bufpos > index.start) {
            --bufpos;
            if (bufpos > index.start
                    && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
                --bufpos;
            }
            index.contextLimit = index.limit = bufpos;
            updateCaret();
            return true;
        }
        return commitAll();
    }

    private boolean caretTowardsLimit() {
        int bufpos = index.contextLimit;
        if (bufpos < buffer.length()) {
            ++bufpos;
            if (bufpos < buffer.length()
                    && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
                ++bufpos;
            }
            index.contextLimit = index.limit = bufpos;
            updateCaret();
            return true;
        }
        return commitAll();
    }

    private boolean canBackspace() {
        return index.contextLimit > 0;
    }

    private boolean backspace() {
        int bufpos = index.contextLimit;
        if (bufpos > 0) {
            int limit = bufpos;
            --bufpos;
            if (bufpos > 0 && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
                --bufpos;
            }
            if (bufpos < index.start) {
                index.start = bufpos;
            }
            index.contextLimit = index.limit = bufpos;
            doDelete(bufpos, limit);
            return true;
        }
        return false;
    }

    private boolean canDelete() {
        return index.contextLimit < buffer.length();
    }

    private boolean delete() {
        int bufpos = index.contextLimit;
        if (bufpos < buffer.length()) {
            int limit = bufpos + 1;
            if (limit < buffer.length()
                    && UCharacter.isHighSurrogate(buffer.charAt(limit - 1))
                    && UCharacter.isLowSurrogate(buffer.charAt(limit))) {
                ++limit;
            }
            doDelete(bufpos, limit);
            return true;
        }
        return false;
    }

    private void doDelete(int start, int limit) {
        buffer.delete(start, limit);
        update(false);
    }

    private boolean commitAll() {
        if (buffer.length() > 0) {
            boolean atStart = index.start == index.contextLimit;
            boolean didConvert = buffer.length() > index.start;
            index.contextLimit = index.limit = buffer.length();
            transliterator.finishTransliteration(replaceableText, index);
            if (atStart) {
                index.start = index.limit = index.contextLimit = 0;
            }
            update(true);
            return didConvert;
        }
        return false;
    }

    private void clearAll() {
        int len = buffer.length();
        if (len > 0) {
            if (len > index.start) {
                buffer.delete(index.start, len);
            }
            update(true);
        }
    }

    private boolean insert(char c) {
        transliterator.transliterate(replaceableText, index, c);
        update(false);
        return true;
    }

    private boolean editing() {
        return buffer.length() > 0;
    }

    /**
     * The big problem is that from release to release swing changes how it
     * handles some characters like tab and backspace.  Sometimes it handles
     * them as keyTyped events, and sometimes it handles them as keyPressed
     * events.  If you want to allow the event to go through so swing handles
     * it, you have to allow one or the other to go through.  If you don't want
     * the event to go through so you can handle it, you have to stop the
     * event both places.
     * @return whether the character was handled
     */
    private boolean handleTyped(char ch) {
        if (enabled) {
            switch (ch) {
            case '\b': if (editing()) return backspace(); break;
            case '\t': if (editing()) { return commitAll(); } break;
            case '\u001b': if (editing()) { clearAll(); return true; } break;
            case '\u007f': if (editing()) return delete(); break;
            default: return insert(ch);
            }
        }
        return false;
    }

    /**
     * Handle keyPressed events.
     */
    private boolean handlePressed(int code) {
        if (enabled && editing()) {
            switch (code) {
            case KeyEvent.VK_PAGE_UP:
            case KeyEvent.VK_UP:
            case KeyEvent.VK_KP_UP:
            case KeyEvent.VK_HOME:
            caretToStart(); return true;
            case KeyEvent.VK_PAGE_DOWN:
            case KeyEvent.VK_DOWN:
            case KeyEvent.VK_KP_DOWN:
            case KeyEvent.VK_END:
            caretToLimit(); return true;
            case KeyEvent.VK_LEFT:
            case KeyEvent.VK_KP_LEFT:
            return caretTowardsStart();
            case KeyEvent.VK_RIGHT:
            case KeyEvent.VK_KP_RIGHT:
            return caretTowardsLimit();
            case KeyEvent.VK_BACK_SPACE: 
            return canBackspace(); // unfortunately, in 1.5 swing handles this in keyPressed instead of keyTyped
            case KeyEvent.VK_DELETE: 
            return canDelete(); // this too?
            case KeyEvent.VK_TAB:
            case KeyEvent.VK_ENTER:
            return commitAll(); // so we'll never handle VK_TAB in keyTyped
            
            case KeyEvent.VK_SHIFT:
            case KeyEvent.VK_CONTROL:
            case KeyEvent.VK_ALT:
            return false; // ignore these unless a key typed event gets generated
            default: 
            // by default, let editor handle it, and we'll assume that it will tell us
            // to endComposition if it does anything funky with, e.g., function keys.
            return false;
            }
        }
        return false;
    }

    public String toString() {
        final String[] names = { 
            "alice", "bill", "carrie", "doug", "elena", "frank", "gertie", "howie", "ingrid", "john" 
        };
    
        if (id < names.length) {
            return names[id];
        } else {
            return names[id] + "-" + (id/names.length);
        }
    }
}

class NameRenderer extends JLabel implements ListCellRenderer {

    /**
     * For serialization
     */
    private static final long serialVersionUID = -210152863798631747L;

    public Component getListCellRendererComponent(
        JList list,
        Object value,
        int index,
        boolean isSelected,
        boolean cellHasFocus) {

        String s = ((JLabel)value).getText();
        setText(s);

        if (isSelected) {
            setBackground(list.getSelectionBackground());
            setForeground(list.getSelectionForeground());
        } else {
            setBackground(list.getBackground());
            setForeground(list.getForeground());
        }

        setEnabled(list.isEnabled());
        setFont(list.getFont());
        setOpaque(true);
        return this;
    }
}

class LabelComparator implements Comparator {
    public int compare(Object obj1, Object obj2) {
        Collator collator = Collator.getInstance();
        return collator.compare(((JLabel)obj1).getText(), ((JLabel)obj2).getText());
    }

    public boolean equals(Object obj1) {
        return obj1 instanceof LabelComparator;
    }
}
