/*
 * HelpPane.java 20 juil. 07
 *
 * Sweet Home 3D, Copyright (c) 2007 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program 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 2 of the License, or
 * (at your option) any later version.
 *
 * This program 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 this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.swing;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.ComponentOrientation;
import java.awt.Container;
import java.awt.Cursor;
import java.awt.DefaultFocusTraversalPolicy;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.event.ActionEvent;
import java.awt.geom.AffineTransform;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.util.ArrayList;
import java.util.Locale;

import javax.swing.Action;
import javax.swing.ActionMap;
import javax.swing.ImageIcon;
import javax.swing.InputMap;
import javax.swing.JButton;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JRootPane;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.HyperlinkEvent;
import javax.swing.event.HyperlinkListener;
import javax.swing.text.AbstractDocument;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultHighlighter;
import javax.swing.text.Element;
import javax.swing.text.Highlighter;
import javax.swing.text.Position;
import javax.swing.text.StyleConstants;
import javax.swing.text.View;
import javax.swing.text.ViewFactory;
import javax.swing.text.html.BlockView;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.HTMLEditorKit.HTMLFactory;

import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.viewcontroller.HelpController;
import com.eteks.sweethome3d.viewcontroller.HelpView;

/**
 * A pane displaying Sweet Home 3D help.
 * @author Emmanuel Puybaret
 */
public class HelpPane extends JRootPane implements HelpView {
  private enum ActionType {SHOW_PREVIOUS, SHOW_NEXT, SEARCH, CLOSE}

  private final UserPreferences preferences;
  private JFrame                frame;
  private JLabel                searchLabel;
  private JTextField            searchTextField;
  private JEditorPane           helpEditorPane;

  public HelpPane(UserPreferences preferences,
                  final HelpController controller) {
    this.preferences = preferences;
    createActions(preferences, controller);
    createComponents(preferences, controller);
    setMnemonics(preferences);
    layoutComponents();
    addLanguageListener(preferences);
    if (controller != null) {
      addHyperlinkListener(controller);
      installKeyboardActions();
    }

    setPage(controller.getHelpPage());
    controller.addPropertyChangeListener(HelpController.Property.HELP_PAGE,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            setPage(controller.getHelpPage());
            highlightText(controller.getHighlightedText());
          }
        });
    controller.addPropertyChangeListener(HelpController.Property.BROWSER_PAGE,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            SwingTools.showDocumentInBrowser(controller.getBrowserPage());
          }
        });
  }

  /**
   * Creates actions bound to <code>controller</code>.
   */
  private void createActions(UserPreferences preferences,
                             final HelpController controller) {
    ActionMap actions = getActionMap();
    try {
      final ControllerAction showPreviousAction = new ControllerAction(
          preferences, HelpPane.class, ActionType.SHOW_PREVIOUS.name(), controller, "showPrevious");
      showPreviousAction.setEnabled(controller.isPreviousPageEnabled());
      controller.addPropertyChangeListener(HelpController.Property.PREVIOUS_PAGE_ENABLED,
          new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent ev) {
              showPreviousAction.setEnabled(controller.isPreviousPageEnabled());
            }
          });
      actions.put(ActionType.SHOW_PREVIOUS, showPreviousAction);

      final ControllerAction showNextAction = new ControllerAction(
          preferences, HelpPane.class, ActionType.SHOW_NEXT.name(), controller, "showNext");
      showNextAction.setEnabled(controller.isNextPageEnabled());
      controller.addPropertyChangeListener(HelpController.Property.NEXT_PAGE_ENABLED,
          new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent ev) {
              showNextAction.setEnabled(controller.isNextPageEnabled());
            }
          });
      actions.put(ActionType.SHOW_NEXT, showNextAction);

      actions.put(ActionType.SEARCH, new ResourceAction(preferences, HelpPane.class, ActionType.SEARCH.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            final Cursor previousCursor = getCursor();
            setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
            try {
              String searchedText = searchTextField.getText().trim();
              if (searchedText.length() > 0) {
                controller.search(searchedText);
              }
            } finally {
              setCursor(previousCursor);
            }
          }
        });
    } catch (NoSuchMethodException ex) {
      throw new RuntimeException(ex);
    }
    actions.put(ActionType.CLOSE, new ResourceAction(
          preferences, HelpPane.class, ActionType.CLOSE.name(), true) {
        @Override
        public void actionPerformed(ActionEvent ev) {
          frame.setVisible(false);
        }
      });
  }

  /**
   * Adds a property change listener to <code>preferences</code> to update
   * actions when preferred language changes.
   */
  private void addLanguageListener(UserPreferences preferences) {
    preferences.addPropertyChangeListener(UserPreferences.Property.LANGUAGE,
        new LanguageChangeListener(this));
  }

  /**
   * Preferences property listener bound to this component with a weak reference to avoid
   * strong link between preferences and this component.
   */
  private static class LanguageChangeListener implements PropertyChangeListener {
    private WeakReference<HelpPane> helpPane;

    public LanguageChangeListener(HelpPane helpPane) {
      this.helpPane = new WeakReference<HelpPane>(helpPane);
    }

    public void propertyChange(PropertyChangeEvent ev) {
      // If help pane was garbage collected, remove this listener from preferences
      HelpPane helpPane = this.helpPane.get();
      UserPreferences preferences = (UserPreferences)ev.getSource();
      if (helpPane == null) {
        preferences.removePropertyChangeListener(
            UserPreferences.Property.LANGUAGE, this);
      } else {
        // Update frame title and search label with new locale
        if (helpPane.frame != null) {
          helpPane.frame.setTitle(preferences.getLocalizedString(HelpPane.class, "helpFrame.title"));
          helpPane.frame.applyComponentOrientation(
              ComponentOrientation.getOrientation(Locale.getDefault()));
        }
        helpPane.searchLabel.setText(SwingTools.getLocalizedLabelText(preferences, HelpPane.class, "searchLabel.text"));
        helpPane.searchTextField.setText("");
        helpPane.setMnemonics(preferences);
      }
    }
  }

  /**
   * Creates the components displayed by this view.
  */
  private void createComponents(UserPreferences preferences, final HelpController controller) {
    this.searchLabel = new JLabel(SwingTools.getLocalizedLabelText(preferences, HelpPane.class, "searchLabel.text"));
    this.searchTextField = new JTextField(12);
    // Under Mac OS 10.5 use client properties to use search text field look and feel
    if (OperatingSystem.isMacOSXLeopardOrSuperior()) {
      this.searchTextField.putClientProperty("JTextField.variant", "search");
      this.searchTextField.putClientProperty("JTextField.Search.FindAction",
          getActionMap().get(ActionType.SEARCH));
    }
    this.searchTextField.addActionListener(getActionMap().get(ActionType.SEARCH));
    // Enable search only if search text field isn't empty
    this.searchTextField.getDocument().addDocumentListener(new DocumentListener() {
        public void changedUpdate(DocumentEvent ev) {
          getActionMap().get(ActionType.SEARCH).setEnabled(searchTextField.getText().trim().length() > 0);
          controller.setHighlightedText(searchTextField.getText());
        }

        public void insertUpdate(DocumentEvent ev) {
          changedUpdate(ev);
        }

        public void removeUpdate(DocumentEvent ev) {
          changedUpdate(ev);
        }
      });

    this.helpEditorPane = new JEditorPane();
    this.helpEditorPane.setBorder(null);
    this.helpEditorPane.setEditable(false);
    this.helpEditorPane.setContentType("text/html");
    this.helpEditorPane.putClientProperty(JEditorPane.W3C_LENGTH_UNITS, Boolean.TRUE);
    this.helpEditorPane.setHighlighter(new DefaultHighlighter());
    PropertyChangeListener highlightingTextListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          highlightText(controller.getHighlightedText());
        }
      };
    controller.addPropertyChangeListener(HelpController.Property.HIGHLIGHTED_TEXT, highlightingTextListener);
    this.helpEditorPane.addPropertyChangeListener("page", highlightingTextListener);
    final float resolutionScale = SwingTools.getResolutionScale();
    if (resolutionScale != 1) {
      final ViewFactory htmlFactory = new HTMLFactory() {
          @Override
          public View create(Element element) {
            AttributeSet attributes = element.getAttributes();
            if (attributes.getAttribute(AbstractDocument.ElementNameAttribute) == null
                && attributes.getAttribute(StyleConstants.NameAttribute) == HTML.Tag.HTML) {
              return new BlockView(element, View.Y_AXIS) {
                  @Override
                  protected void layout(int width, int height) {
                    super.layout(Math.round(width / resolutionScale), Math.round(height * resolutionScale));
                  }

                  @Override
                  public void paint(Graphics g, Shape allocation) {
                    Graphics2D g2D = (Graphics2D)g;
                    AffineTransform oldTransform = g2D.getTransform();
                    g2D.scale(resolutionScale, resolutionScale);
                    super.paint(g2D, allocation);
                    g2D.setTransform(oldTransform);
                  }

                  @Override
                  public float getMinimumSpan(int axis) {
                    return super.getMinimumSpan(axis) * resolutionScale;
                  }

                  @Override
                  public float getMaximumSpan(int axis) {
                    return super.getMaximumSpan(axis) * resolutionScale;
                  }

                  @Override
                  public float getPreferredSpan(int axis) {
                    return super.getPreferredSpan(axis) * resolutionScale;
                  }

                  @Override
                  public Shape modelToView(int pos, Shape shape, Position.Bias b) throws BadLocationException {
                    Rectangle bounds = shape.getBounds();
                    shape = super.modelToView(pos, bounds, b);
                    bounds = shape.getBounds();
                    bounds.x *= resolutionScale;
                    bounds.y *= resolutionScale;
                    bounds.width *= resolutionScale;
                    bounds.height *= resolutionScale;
                    return bounds;
                  }

                  @Override
                  public int viewToModel(float x, float y, Shape shape, Position.Bias[] bias) {
                    Rectangle bounds = shape.getBounds();
                    x /= resolutionScale;
                    y /= resolutionScale;
                    bounds.x /= resolutionScale;
                    bounds.y /= resolutionScale;
                    bounds.width /= resolutionScale;
                    bounds.height /= resolutionScale;
                    return super.viewToModel(x, y, bounds, bias);
                  }
                };
            }
            return super.create(element);
          }
        };
      this.helpEditorPane.setEditorKit(new HTMLEditorKit() {
          @Override
          public ViewFactory getViewFactory() {
            return htmlFactory;
          }
        });
    }

    setFocusTraversalPolicy(new DefaultFocusTraversalPolicy() {
        @Override
        public Component getDefaultComponent(Container container) {
          return helpEditorPane;
        }
      });
  }

  /**
   * Highlights the words of the given text in HTML pane.
   */
  private void highlightText(String highlightedText) {
    DefaultHighlighter.DefaultHighlightPainter highlightPainter =
        new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 204, 51));
    this.helpEditorPane.getHighlighter().removeAllHighlights();
    if (highlightedText != null) {
      ArrayList<String> highlightedWords = new ArrayList<String>();
      for (String highlightedWord : highlightedText.split("\\s")) {
        if (highlightedWord.length() > 0) {
          highlightedWords.add(highlightedWord);
        }
      }
      highlightWords(this.helpEditorPane.getDocument().getDefaultRootElement(),
          highlightedWords.toArray(new String [highlightedWords.size()]), highlightPainter);
    }
  }

  private void highlightWords(Element element,
                              String [] highlightedWords,
                              Highlighter.HighlightPainter highlightPainter) {
    if (element.isLeaf()) {
      int startOffset = element.getStartOffset();
      try {
        String text = element.getDocument().getText(element.getStartOffset(),
            element.getEndOffset() - startOffset).toLowerCase();
        for (String highlightedWord : highlightedWords) {
          for (int index = 0; index < text.length() - 1 &&  (index = text.indexOf(highlightedWord, index)) >= 0; index += highlightedWord.length() + 1) {
            this.helpEditorPane.getHighlighter().addHighlight(
                startOffset + index, startOffset + index + highlightedWord.length(), highlightPainter);
          }
        }
      } catch (BadLocationException ex) {
        // Ignore unexpected exception
      }
    } else {
      for (int i = 0, n = element.getElementCount(); i < n; i++) {
        highlightWords(element.getElement(i), highlightedWords, highlightPainter);
      }
    }
  }

  /**
   * Sets components mnemonics and label / component associations.
   */
  private void setMnemonics(UserPreferences preferences) {
    if (!OperatingSystem.isMacOSX()) {
      this.searchLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(
          preferences.getLocalizedString(HelpPane.class, "searchLabel.mnemonic")).getKeyCode());
      this.searchLabel.setLabelFor(this.searchTextField);
    }
  }

  /**
   * Layouts the components displayed by this view.
   */
  private void layoutComponents() {
    final JToolBar toolBar = new JToolBar();
    toolBar.setFloatable(false);
    // Change layout because BoxLayout glue doesn't work well under Linux
    toolBar.setLayout(new GridBagLayout());
    ActionMap actions = getActionMap();
    final JButton previousButton = new JButton(actions.get(ActionType.SHOW_PREVIOUS));
    final JButton nextButton = new JButton(actions.get(ActionType.SHOW_NEXT));
    toolBar.add(previousButton);
    toolBar.add(nextButton);
    layoutToolBarButtons(toolBar, previousButton, nextButton);
    toolBar.addPropertyChangeListener("componentOrientation",
        new PropertyChangeListener () {
          public void propertyChange(PropertyChangeEvent evt) {
            layoutToolBarButtons(toolBar, previousButton, nextButton);
          }
        });
    toolBar.add(new JLabel(),
        new GridBagConstraints(2, 0, 1, 1, 1, 0, GridBagConstraints.CENTER,
            GridBagConstraints.NONE, new Insets(0, 0, 0, 0), 0, 0));

    int standardGap = Math.round(5 * SwingTools.getResolutionScale());
    if (!OperatingSystem.isMacOSXLeopardOrSuperior()) {
      toolBar.add(this.searchLabel,
          new GridBagConstraints(3, 0, 1, 1, 0, 0, GridBagConstraints.CENTER,
              GridBagConstraints.NONE, new Insets(0, 0, 0, standardGap), 0, 0));
    }
    toolBar.add(this.searchTextField,
        new GridBagConstraints(4, 0, 1, 1, 0, 0, GridBagConstraints.CENTER,
            GridBagConstraints.HORIZONTAL, new Insets(0, 0, 0, 0), 0, 0));
    this.searchTextField.setMaximumSize(this.searchTextField.getPreferredSize());
    // Ignore search button under Mac OS X 10.5 (it's included in the search field)
    if (!OperatingSystem.isMacOSXLeopardOrSuperior()) {
      toolBar.add(new JButton(actions.get(ActionType.SEARCH)),
          new GridBagConstraints(5, 0, 1, 1, 0, 0, GridBagConstraints.CENTER,
              GridBagConstraints.NONE, new Insets(0, standardGap, 0, 0), 0, 0));
    }
    // Remove focusable property on buttons
    for (int i = 0, n = toolBar.getComponentCount(); i < n; i++) {
      Component component = toolBar.getComponent(i);
      if (component instanceof JButton) {
        component.setFocusable(false);
      }
    }

    getContentPane().add(toolBar, BorderLayout.NORTH);
    getContentPane().add(new JScrollPane(this.helpEditorPane), BorderLayout.CENTER);
  }

  /**
   * Updates buttons layout and under Mac OS X 10.5 use segmented buttons with properties
   * depending on toolbar orientation.
   */
  private void layoutToolBarButtons(JToolBar toolBar,
                                    JButton previousButton,
                                    JButton nextButton) {
    int buttonPadY;
    int buttonsTopBottomInset;
    if (OperatingSystem.isMacOSXLeopardOrSuperior()
        && OperatingSystem.isJavaVersionGreaterOrEqual("1.7")) {
      // Ensure the top and bottom of segmented buttons are correctly drawn
      buttonPadY = 6;
      buttonsTopBottomInset = -2;
    } else {
      buttonPadY = 0;
      buttonsTopBottomInset = 0;
    }
    ComponentOrientation orientation = toolBar.getComponentOrientation();
    GridBagLayout layout = (GridBagLayout)toolBar.getLayout();
    GridBagConstraints firstButtonConstraints = new GridBagConstraints(
        0, 0, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(buttonsTopBottomInset, 0, buttonsTopBottomInset, 0), 0, buttonPadY);
    GridBagConstraints secondButtonContraints = new GridBagConstraints(
        1, 0, 1, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(buttonsTopBottomInset, 0, buttonsTopBottomInset, 5), 0, buttonPadY);
    layout.setConstraints(orientation.isLeftToRight() ? previousButton : nextButton,
        firstButtonConstraints);
    layout.setConstraints(orientation.isLeftToRight() ? nextButton : previousButton,
        secondButtonContraints);
    // Use segmented buttons under Mac OS X 10.5
    if (OperatingSystem.isMacOSXLeopardOrSuperior()) {
      previousButton.putClientProperty("JButton.buttonType", "segmentedTextured");
      previousButton.putClientProperty("JButton.segmentPosition", "first");
      nextButton.putClientProperty("JButton.buttonType", "segmentedTextured");
      nextButton.putClientProperty("JButton.segmentPosition", "last");
    }
    toolBar.revalidate();
  }

  /**
   * Adds an hyperlink listener on the editor pane displayed by this pane.
   */
  private void addHyperlinkListener(final HelpController controller) {
    this.helpEditorPane.addHyperlinkListener(new HyperlinkListener() {
        public void hyperlinkUpdate(HyperlinkEvent ev) {
          if (ev.getEventType() == HyperlinkEvent.EventType.ACTIVATED) {
            controller.showPage(ev.getURL());
          }
        }
      });
  }

  /**
   * Installs keys bound to actions.
   */
  private void installKeyboardActions() {
    ActionMap actions = getActionMap();
    // As no menu is created for this pane, change its input map to ensure accelerators work
    InputMap inputMap = getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
    inputMap.put((KeyStroke)actions.get(ActionType.SHOW_PREVIOUS).getValue(Action.ACCELERATOR_KEY),
        ActionType.SHOW_PREVIOUS);
    inputMap.put((KeyStroke)actions.get(ActionType.SHOW_NEXT).getValue(Action.ACCELERATOR_KEY),
        ActionType.SHOW_NEXT);
    inputMap.put((KeyStroke)actions.get(ActionType.CLOSE).getValue(Action.ACCELERATOR_KEY),
        ActionType.CLOSE);
    inputMap.put((KeyStroke)actions.get(ActionType.SEARCH).getValue(Action.ACCELERATOR_KEY),
        ActionType.SEARCH);
  }

  /**
   * Displays this pane in a frame.
   */
  public void displayView() {
    if (this.frame == null) {
      this.frame = new JFrame() {
          {
            // Replace frame rootPane by help view
            setRootPane(HelpPane.this);
          }
        };
      // Update frame image and title
      this.frame.setIconImage(new ImageIcon(HelpPane.class.getResource(
          this.preferences.getLocalizedString(HelpPane.class, "helpFrame.icon"))).getImage());
      this.frame.setTitle(this.preferences.getLocalizedString(HelpPane.class, "helpFrame.title"));
      this.frame.applyComponentOrientation(ComponentOrientation.getOrientation(Locale.getDefault()));
      // Compute frame size and location
      computeFrameBounds(this.frame);
      // Just hide help frame when user close it
      this.frame.setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
    }

    // Show frame
    this.frame.setVisible(true);
    this.frame.setState(JFrame.NORMAL);
    this.frame.toFront();
  }

  /**
   * Computes <code>frame</code> size and location to fit into screen.
   */
  private void computeFrameBounds(JFrame frame) {
    frame.setLocationByPlatform(true);
    Dimension screenSize = getToolkit().getScreenSize();
    Insets screenInsets = getToolkit().getScreenInsets(getGraphicsConfiguration());
    screenSize.width -= screenInsets.left + screenInsets.right;
    screenSize.height -= screenInsets.top + screenInsets.bottom;
    frame.setSize(Math.min(2 * screenSize.width / 3, Math.round(800 * SwingTools.getResolutionScale())),
        screenSize.height * 4 / 5);
  }

  /**
   * Displays <code>url</code> in this pane.
   */
  private void setPage(URL url) {
    try {
      this.helpEditorPane.setPage(url);
    } catch (IOException ex) {
      throw new RuntimeException(ex);
    }
  }
}
