/*
 * HomeController.java 15 mai 2006
 *
 * Sweet Home 3D, Copyright (c) 2006 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.viewcontroller;

import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.security.AccessControlException;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.Callable;

import javax.swing.event.UndoableEditEvent;
import javax.swing.event.UndoableEditListener;
import javax.swing.undo.AbstractUndoableEdit;
import javax.swing.undo.CannotRedoException;
import javax.swing.undo.CannotUndoException;
import javax.swing.undo.CompoundEdit;
import javax.swing.undo.UndoManager;
import javax.swing.undo.UndoableEdit;
import javax.swing.undo.UndoableEditSupport;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import com.eteks.sweethome3d.model.AspectRatio;
import com.eteks.sweethome3d.model.BackgroundImage;
import com.eteks.sweethome3d.model.Camera;
import com.eteks.sweethome3d.model.CatalogPieceOfFurniture;
import com.eteks.sweethome3d.model.CatalogTexture;
import com.eteks.sweethome3d.model.CollectionEvent;
import com.eteks.sweethome3d.model.CollectionListener;
import com.eteks.sweethome3d.model.Compass;
import com.eteks.sweethome3d.model.DimensionLine;
import com.eteks.sweethome3d.model.Elevatable;
import com.eteks.sweethome3d.model.FurnitureCatalog;
import com.eteks.sweethome3d.model.Home;
import com.eteks.sweethome3d.model.HomeApplication;
import com.eteks.sweethome3d.model.HomeDoorOrWindow;
import com.eteks.sweethome3d.model.HomeEnvironment;
import com.eteks.sweethome3d.model.HomeFurnitureGroup;
import com.eteks.sweethome3d.model.HomePieceOfFurniture;
import com.eteks.sweethome3d.model.HomeRecorder;
import com.eteks.sweethome3d.model.InterruptedRecorderException;
import com.eteks.sweethome3d.model.Label;
import com.eteks.sweethome3d.model.Level;
import com.eteks.sweethome3d.model.Library;
import com.eteks.sweethome3d.model.RecorderException;
import com.eteks.sweethome3d.model.Room;
import com.eteks.sweethome3d.model.Selectable;
import com.eteks.sweethome3d.model.SelectionEvent;
import com.eteks.sweethome3d.model.SelectionListener;
import com.eteks.sweethome3d.model.TexturesCatalog;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.model.Wall;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.viewcontroller.PlanController.Mode;

/**
 * A MVC controller for the home view.
 * @author Emmanuel Puybaret
 */
public class HomeController implements Controller {
  private final Home                  home;
  private final UserPreferences       preferences;
  private final HomeApplication       application;
  private final ViewFactory           viewFactory;
  private final ContentManager        contentManager;
  private final UndoableEditSupport   undoSupport;
  private final UndoManager           undoManager;
  private HomeView                    homeView;
  private FurnitureCatalogController  furnitureCatalogController;
  private FurnitureController         furnitureController;
  private PlanController              planController;
  private HomeController3D            homeController3D;
  private static HelpController       helpController;  // Only one help controller
  private int                         saveUndoLevel;
  private boolean                     notUndoableModifications;
  private View                        focusedView;

  /**
   * Creates the controller of home view.
   * @param home the home edited by this controller and its view.
   * @param application the instance of current application.
   * @param viewFactory a factory able to create views.
   * @param contentManager the content manager of the application.
   */
  public HomeController(Home home, 
                        HomeApplication application,
                        ViewFactory    viewFactory, 
                        ContentManager contentManager) {
    this(home, application.getUserPreferences(), viewFactory, 
        contentManager, application);
  }

  /**
   * Creates the controller of home view.
   * @param home the home edited by this controller and its view.
   * @param application the instance of current application.
   * @param viewFactory a factory able to create views.
   */
  public HomeController(Home home, 
                        HomeApplication application,
                        ViewFactory viewFactory) {
    this(home, application.getUserPreferences(), viewFactory, null, application);
  }

  /**
   * Creates the controller of home view. 
   * @param home        the home edited by this controller and its view.
   * @param preferences the preferences of the application.
   * @param viewFactory a factory able to create views.
   */
  public HomeController(Home home, 
                        UserPreferences preferences,
                        ViewFactory viewFactory) {
    this(home, preferences, viewFactory, null, null);
  }

  /**
   * Creates the controller of home view. 
   * @param home        the home edited by this controller and its view.
   * @param preferences the preferences of the application.
   * @param viewFactory a factory able to create views.
   * @param contentManager the content manager of the application.
   */
  public HomeController(Home home, 
                        UserPreferences preferences,
                        ViewFactory    viewFactory,
                        ContentManager contentManager) {
    this(home, preferences, viewFactory, contentManager, null);
  }

  private HomeController(final Home home, 
                         final UserPreferences preferences,
                         ViewFactory    viewFactory,
                         ContentManager contentManager,
                         HomeApplication application) {
    this.home = home;
    this.preferences = preferences;
    this.viewFactory = viewFactory;
    this.contentManager = contentManager;
    this.application = application;
    this.undoSupport = new UndoableEditSupport() {
        @Override
        protected void _postEdit(UndoableEdit edit) {
          // Ignore not significant compound edit
          if (!(edit instanceof CompoundEdit)
              || edit.isSignificant()) {
            super._postEdit(edit);
          }
        }
      };
    this.undoManager = new UndoManager();
    this.undoSupport.addUndoableEditListener(this.undoManager);
    
    // Update recent homes list
    if (home.getName() != null) {
      List<String> recentHomes = new ArrayList<String>(this.preferences.getRecentHomes());
      recentHomes.remove(home.getName());
      recentHomes.add(0, home.getName());
      updateUserPreferencesRecentHomes(recentHomes);
      
      // If home version is more recent than current version
      if (home.getVersion() > Home.CURRENT_VERSION) {
        // Warn the user that view will display a home created with a more recent version 
        getView().invokeLater(new Runnable() { 
            public void run() {
              String message = preferences.getLocalizedString(HomeController.class, 
                  "moreRecentVersionHome", home.getName());
              getView().showMessage(message);
            }
          });
      }
    }
  }

  /**
   * Enables actions at controller instantiation. 
   */
  private void enableDefaultActions(HomeView homeView) {
    boolean applicationExists = this.application != null;
    
    homeView.setEnabled(HomeView.ActionType.NEW_HOME, applicationExists);
    homeView.setEnabled(HomeView.ActionType.OPEN, applicationExists);
    homeView.setEnabled(HomeView.ActionType.DELETE_RECENT_HOMES, 
        applicationExists && !this.preferences.getRecentHomes().isEmpty());
    homeView.setEnabled(HomeView.ActionType.CLOSE, applicationExists);
    homeView.setEnabled(HomeView.ActionType.SAVE, applicationExists);
    homeView.setEnabled(HomeView.ActionType.SAVE_AS, applicationExists);
    homeView.setEnabled(HomeView.ActionType.SAVE_AND_COMPRESS, applicationExists);
    homeView.setEnabled(HomeView.ActionType.PAGE_SETUP, true);
    homeView.setEnabled(HomeView.ActionType.PRINT_PREVIEW, true);
    homeView.setEnabled(HomeView.ActionType.PRINT, true);
    homeView.setEnabled(HomeView.ActionType.PRINT_TO_PDF, true);
    homeView.setEnabled(HomeView.ActionType.PREFERENCES, true);
    homeView.setEnabled(HomeView.ActionType.EXIT, applicationExists);
    homeView.setEnabled(HomeView.ActionType.IMPORT_FURNITURE, true);
    homeView.setEnabled(HomeView.ActionType.IMPORT_FURNITURE_LIBRARY, true);
    homeView.setEnabled(HomeView.ActionType.IMPORT_TEXTURE, true);
    homeView.setEnabled(HomeView.ActionType.IMPORT_TEXTURES_LIBRARY, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_CATALOG_ID, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_NAME, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_WIDTH, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_HEIGHT, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_DEPTH, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_X, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_Y, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_ELEVATION, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_ANGLE, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_LEVEL, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_COLOR, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_TEXTURE, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_MOVABILITY, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_TYPE, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_VISIBILITY, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_PRICE, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_VALUE_ADDED_TAX_PERCENTAGE, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_VALUE_ADDED_TAX, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_PRICE_VALUE_ADDED_TAX_INCLUDED, true);
    homeView.setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_DESCENDING_ORDER, 
        this.home.getFurnitureSortedProperty() != null);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_CATALOG_ID, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_NAME, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_WIDTH, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_DEPTH, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_HEIGHT, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_X, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_Y, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_ELEVATION, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_ANGLE, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_LEVEL, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_COLOR, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_TEXTURE, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_MOVABLE, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_DOOR_OR_WINDOW, true); 
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_VISIBLE, true);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_PRICE, true);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_VALUE_ADDED_TAX_PERCENTAGE, true);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_VALUE_ADDED_TAX, true);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_HOME_FURNITURE_PRICE_VALUE_ADDED_TAX_INCLUDED, true);
    homeView.setEnabled(HomeView.ActionType.EXPORT_TO_CSV, true);
    homeView.setEnabled(HomeView.ActionType.SELECT, true);
    homeView.setEnabled(HomeView.ActionType.PAN, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_WALLS, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_ROOMS, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_DIMENSION_LINES, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_LABELS, true);
    homeView.setEnabled(HomeView.ActionType.LOCK_BASE_PLAN, true);
    homeView.setEnabled(HomeView.ActionType.UNLOCK_BASE_PLAN, true);
    homeView.setEnabled(HomeView.ActionType.MODIFY_COMPASS, true);
    Level selectedLevel = this.home.getSelectedLevel();
    enableBackgroungImageActions(homeView, selectedLevel != null
        ? selectedLevel.getBackgroundImage()
        : this.home.getBackgroundImage());
    homeView.setEnabled(HomeView.ActionType.ADD_LEVEL, true);
    List<Level> levels = this.home.getLevels();
    boolean homeContainsOneSelectedLevel = levels.size() > 1 && selectedLevel != null;
    homeView.setEnabled(HomeView.ActionType.MODIFY_LEVEL, homeContainsOneSelectedLevel);
    homeView.setEnabled(HomeView.ActionType.DELETE_LEVEL, homeContainsOneSelectedLevel);
    homeView.setEnabled(HomeView.ActionType.ZOOM_IN, true);
    homeView.setEnabled(HomeView.ActionType.ZOOM_OUT, true);
    homeView.setEnabled(HomeView.ActionType.EXPORT_TO_SVG, true); 
    homeView.setEnabled(HomeView.ActionType.VIEW_FROM_TOP, true);
    homeView.setEnabled(HomeView.ActionType.VIEW_FROM_OBSERVER, true);
    homeView.setEnabled(HomeView.ActionType.MODIFY_OBSERVER, this.home.getCamera() == this.home.getObserverCamera());
    homeView.setEnabled(HomeView.ActionType.STORE_POINT_OF_VIEW, true);
    boolean emptyStoredCameras = home.getStoredCameras().isEmpty();
    homeView.setEnabled(HomeView.ActionType.DELETE_POINTS_OF_VIEW, !emptyStoredCameras);
    homeView.setEnabled(HomeView.ActionType.CREATE_PHOTOS_AT_POINTS_OF_VIEW, !emptyStoredCameras);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_ALL_LEVELS, levels.size() > 1);
    homeView.setEnabled(HomeView.ActionType.DISPLAY_SELECTED_LEVEL, levels.size() > 1);
    homeView.setEnabled(HomeView.ActionType.DETACH_3D_VIEW, true);
    homeView.setEnabled(HomeView.ActionType.ATTACH_3D_VIEW, true);
    homeView.setEnabled(HomeView.ActionType.VIEW_FROM_OBSERVER, true);
    homeView.setEnabled(HomeView.ActionType.MODIFY_3D_ATTRIBUTES, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_PHOTO, true);
    homeView.setEnabled(HomeView.ActionType.CREATE_VIDEO, true);
    homeView.setEnabled(HomeView.ActionType.EXPORT_TO_OBJ, true);
    homeView.setEnabled(HomeView.ActionType.HELP, true);
    homeView.setEnabled(HomeView.ActionType.ABOUT, true);
    homeView.setTransferEnabled(true);
  }

  /**
   * Returns the view associated with this controller.
   */
  public HomeView getView() {
    if (this.homeView == null) {
      this.homeView = this.viewFactory.createHomeView(this.home, this.preferences, this);
      enableDefaultActions(this.homeView);
      addListeners();
    }
    return this.homeView;
  }

  /**
   * Returns the content manager of this controller.
   */
  public ContentManager getContentManager() {
    return this.contentManager;
  }

  /**
   * Returns the furniture catalog controller managed by this controller.
   */
  public FurnitureCatalogController getFurnitureCatalogController() {
    // Create sub controller lazily only once it's needed
    if (this.furnitureCatalogController == null) {
      this.furnitureCatalogController = new FurnitureCatalogController(
          this.preferences.getFurnitureCatalog(), this.preferences, this.viewFactory, this.contentManager);
    }
    return this.furnitureCatalogController;
  }

  /**
   * Returns the furniture controller managed by this controller.
   */
  public FurnitureController getFurnitureController() {
    // Create sub controller lazily only once it's needed
    if (this.furnitureController == null) {
      this.furnitureController = new FurnitureController(
          this.home, this.preferences, this.viewFactory, this.contentManager, getUndoableEditSupport());
    }
    return this.furnitureController;
  }

  /**
   * Returns the controller of home plan.
   */
  public PlanController getPlanController() {
    // Create sub controller lazily only once it's needed
    if (this.planController == null) {
      this.planController = new PlanController(
          this.home, this.preferences, this.viewFactory, this.contentManager, getUndoableEditSupport());
    }
    return this.planController;
  }

  /**
   * Returns the controller of home 3D view.
   */
  public HomeController3D getHomeController3D() {
    // Create sub controller lazily only once it's needed
    if (this.homeController3D == null) {
      this.homeController3D = new HomeController3D(
          this.home, this.preferences, this.viewFactory, this.contentManager, getUndoableEditSupport());
    }
    return this.homeController3D;
  }

  /**
   * Returns the undoable edit support managed by this controller.
   */
  protected final UndoableEditSupport getUndoableEditSupport() {
    return this.undoSupport;
  }
  
  /**
   * Adds listeners that updates the enabled / disabled state of actions.
   */
  private void addListeners() {
    // Save preferences when they change
    this.preferences.getFurnitureCatalog().addFurnitureListener(
        new FurnitureCatalogChangeListener(this));
    this.preferences.getTexturesCatalog().addTexturesListener(
        new TexturesCatalogChangeListener(this));
    UserPreferencesPropertiesChangeListener listener = 
        new UserPreferencesPropertiesChangeListener(this);
    for (UserPreferences.Property property : UserPreferences.Property.values()) {
      this.preferences.addPropertyChangeListener(property, listener);
    }
      
    addCatalogSelectionListener();
    addHomeBackgroundImageListener();
    addNotUndoableModificationListeners();
    addHomeSelectionListener();
    addFurnitureSortListener();
    addUndoSupportListener();
    addHomeItemsListener();
    addLevelListeners();
    addStoredCamerasListener();
    addPlanControllerListeners();
    addLanguageListener();
  }

  /**
   * Super class of catalog listeners that writes preferences each time a piece of furniture or a texture
   * is deleted or added in furniture or textures catalog.
   */
  private abstract static class UserPreferencesChangeListener {
    // Stores the currently writing preferences 
    private static Set<UserPreferences> writingPreferences = new HashSet<UserPreferences>();
    
    public void writePreferences(final HomeController controller) {
      if (!writingPreferences.contains(controller.preferences)) {
        writingPreferences.add(controller.preferences);
        // Write preferences later once all catalog modifications are notified 
        controller.getView().invokeLater(new Runnable() {
            public void run() {
              try {
                controller.preferences.write();
                writingPreferences.remove(controller.preferences);
              } catch (RecorderException ex) {
                controller.getView().showError(controller.preferences.getLocalizedString(
                    HomeController.class, "savePreferencesError"));
              }
            }
          });
      }
    }
  }

  /**
   * Furniture catalog listener that writes preferences each time a piece of furniture 
   * is deleted or added in furniture catalog. This listener is bound to this controller 
   * with a weak reference to avoid strong link between catalog and this controller.  
   */
  private static class FurnitureCatalogChangeListener extends UserPreferencesChangeListener 
                                                      implements CollectionListener<CatalogPieceOfFurniture> {
    private WeakReference<HomeController> homeController;
    
    public FurnitureCatalogChangeListener(HomeController homeController) {
      this.homeController = new WeakReference<HomeController>(homeController);
    }
    
    public void collectionChanged(CollectionEvent<CatalogPieceOfFurniture> ev) {
      // If controller was garbage collected, remove this listener from catalog
      final HomeController controller = this.homeController.get();
      if (controller == null) {
        ((FurnitureCatalog)ev.getSource()).removeFurnitureListener(this);
      } else {
        writePreferences(controller);
      }
    }
  }

  /**
   * Textures catalog listener that writes preferences each time a texture 
   * is deleted or added in textures catalog. This listener is bound to this controller 
   * with a weak reference to avoid strong link between catalog and this controller.  
   */
  private static class TexturesCatalogChangeListener extends UserPreferencesChangeListener
                                                     implements CollectionListener<CatalogTexture> { 
    private WeakReference<HomeController> homeController;
    
    public TexturesCatalogChangeListener(HomeController homeController) {
      this.homeController = new WeakReference<HomeController>(homeController);
    }
    
    public void collectionChanged(CollectionEvent<CatalogTexture> ev) {
      // If controller was garbage collected, remove this listener from catalog
      final HomeController controller = this.homeController.get();
      if (controller == null) {
        ((TexturesCatalog)ev.getSource()).removeTexturesListener(this);
      } else {
        writePreferences(controller);
      }
    }
  }

  /**
   * Properties listener that writes preferences each time the value of one of its properties changes. 
   * This listener is bound to this controller with a weak reference to avoid strong link 
   * between catalog and this controller.  
   */
  private static class UserPreferencesPropertiesChangeListener extends UserPreferencesChangeListener
                                                               implements PropertyChangeListener { 
    private WeakReference<HomeController> homeController;
    
    public UserPreferencesPropertiesChangeListener(HomeController homeController) {
      this.homeController = new WeakReference<HomeController>(homeController);
    }
    
    public void propertyChange(PropertyChangeEvent ev) {
      // If controller was garbage collected, remove this listener from catalog
      final HomeController controller = this.homeController.get();
      if (controller == null) {
        ((UserPreferences)ev.getSource()).removePropertyChangeListener(
            UserPreferences.Property.valueOf(ev.getPropertyName()), this);
      } else {
        writePreferences(controller);
      }
    }
  }

  /**
   * Adds a selection listener to catalog that enables / disables Add Furniture action.
   */
  private void addCatalogSelectionListener() {
    getFurnitureCatalogController().addSelectionListener(new SelectionListener() {
          public void selectionChanged(SelectionEvent ev) {
            enableActionsBoundToSelection();
          }
        });
  }

  /**
   * Adds a property change listener to <code>preferences</code> to update
   * undo and redo presentation names when preferred language changes.
   */
  private void addLanguageListener() {
    this.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<HomeController> homeController;

    public LanguageChangeListener(HomeController homeController) {
      this.homeController = new WeakReference<HomeController>(homeController);
    }
    
    public void propertyChange(PropertyChangeEvent ev) {
      // If home pane was garbage collected, remove this listener from preferences
      HomeController homeController = this.homeController.get();
      if (homeController == null) {
        ((UserPreferences)ev.getSource()).removePropertyChangeListener(
            UserPreferences.Property.LANGUAGE, this);
      } else {
        // Update undo and redo name
        homeController.getView().setUndoRedoName(
            homeController.undoManager.canUndo() 
                ? homeController.undoManager.getUndoPresentationName()
                : null,
            homeController.undoManager.canRedo() 
                ? homeController.undoManager.getRedoPresentationName()
                : null);
      }
    }
  }
  
  /**
   *  Adds a selection listener to home that enables / disables actions on selection.
   */
  private void addHomeSelectionListener() {
    if (this.home != null) {
      this.home.addSelectionListener(new SelectionListener() {
        public void selectionChanged(SelectionEvent ev) {
          enableActionsBoundToSelection();
        }
      });
    }
  }

  /**
   *  Adds a property change listener to home that enables / disables sort order action.
   */
  private void addFurnitureSortListener() {
    if (this.home != null) {
      this.home.addPropertyChangeListener(Home.Property.FURNITURE_SORTED_PROPERTY, 
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            getView().setEnabled(HomeView.ActionType.SORT_HOME_FURNITURE_BY_DESCENDING_ORDER, 
                ev.getNewValue() != null);
          }
        });
    }
  }

  /**
   *  Adds a property change listener to home that enables / disables background image actions.
   */
  private void addHomeBackgroundImageListener() {
    if (this.home != null) {
      this.home.addPropertyChangeListener(Home.Property.BACKGROUND_IMAGE, 
          new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent ev) {
              enableBackgroungImageActions(getView(), (BackgroundImage)ev.getNewValue());
            }
          });
    }
  }

  /**
   * Enables background image actions.
   */
  private void enableBackgroungImageActions(HomeView homeView, BackgroundImage backgroundImage) {
    boolean homeHasBackgroundImage = backgroundImage != null;
    getView().setEnabled(HomeView.ActionType.IMPORT_BACKGROUND_IMAGE, !homeHasBackgroundImage);
    getView().setEnabled(HomeView.ActionType.MODIFY_BACKGROUND_IMAGE, homeHasBackgroundImage);
    getView().setEnabled(HomeView.ActionType.HIDE_BACKGROUND_IMAGE, 
        homeHasBackgroundImage && backgroundImage.isVisible());
    getView().setEnabled(HomeView.ActionType.SHOW_BACKGROUND_IMAGE, 
        homeHasBackgroundImage && !backgroundImage.isVisible());
    getView().setEnabled(HomeView.ActionType.DELETE_BACKGROUND_IMAGE, homeHasBackgroundImage);
  }

  /**
   * Adds listeners to track property changes that are not undoable.
   */
  private void addNotUndoableModificationListeners() {
    if (this.home != null) {
      final PropertyChangeListener notUndoableModificationListener = new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            notUndoableModifications = true;
            home.setModified(true);
          }
        };
      this.home.addPropertyChangeListener(Home.Property.STORED_CAMERAS, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.OBSERVER_CAMERA_ELEVATION_ADJUSTED, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.VIDEO_WIDTH, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.VIDEO_ASPECT_RATIO, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.VIDEO_FRAME_RATE, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.VIDEO_QUALITY, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.VIDEO_CAMERA_PATH, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.CEILING_LIGHT_COLOR, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.PHOTO_QUALITY, notUndoableModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.PHOTO_ASPECT_RATIO, notUndoableModificationListener);
      PropertyChangeListener photoSizeModificationListener = new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            if (home.getEnvironment().getPhotoAspectRatio() != AspectRatio.VIEW_3D_RATIO) {
              // Ignore photo size modification with 3D view aspect ratio since it can change for various reasons
              notUndoableModificationListener.propertyChange(ev);
            }
          }
        };
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.PHOTO_WIDTH, photoSizeModificationListener);
      this.home.getEnvironment().addPropertyChangeListener(HomeEnvironment.Property.PHOTO_HEIGHT, photoSizeModificationListener);
      PropertyChangeListener timeOrLensModificationListener = new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            if (ev.getPropertyName().equals(Camera.Property.TIME.name())
                || ev.getPropertyName().equals(Camera.Property.LENS.name())) {
              notUndoableModificationListener.propertyChange(ev);
            }
          }
        };
      this.home.getObserverCamera().addPropertyChangeListener(timeOrLensModificationListener);
      this.home.getTopCamera().addPropertyChangeListener(timeOrLensModificationListener);
    }
  }
  
  /**
   * Enables or disables action bound to selection. 
   * This method will be called when selection in plan or in catalog changes and when 
   * focused component or modification state in plan changes. 
   */
  protected void enableActionsBoundToSelection() {
    boolean modificationState = getPlanController().isModificationState();
    
    // Search if catalog selection contains at least one piece
    List<CatalogPieceOfFurniture> catalogSelectedItems = 
        getFurnitureCatalogController().getSelectedFurniture();    
    boolean catalogSelectionContainsFurniture = !catalogSelectedItems.isEmpty();
    boolean catalogSelectionContainsOneModifiablePiece = catalogSelectedItems.size() == 1
        && catalogSelectedItems.get(0).isModifiable();
    
    // Search if home selection contains at least one piece, one wall or one dimension line
    List<Selectable> selectedItems = this.home.getSelectedItems();
    boolean homeSelectionContainsDeletableItems = false;
    boolean homeSelectionContainsFurniture = false;
    boolean homeSelectionContainsDeletableFurniture = false;
    boolean homeSelectionContainsOneCopiableItemOrMore = false;
    boolean homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore = false;
    boolean homeSelectionContainsThreeMovablePiecesOfFurnitureOrMore = false;
    boolean homeSelectionContainsFurnitureGroup = false;
    boolean homeSelectionContainsWalls = false;
    boolean homeSelectionContainsRooms = false;
    boolean homeSelectionContainsOneWall = false;
    boolean homeSelectionContainsOneLabel = false;
    boolean homeSelectionContainsItemsWithText = false;
    boolean homeSelectionContainsCompass = false;
    FurnitureController furnitureController = getFurnitureController();
    if (!modificationState) {
      for (Selectable item : selectedItems) {
        if (getPlanController().isItemDeletable(item)) {
          homeSelectionContainsDeletableItems = true;
          break;
        }
      }
      List<HomePieceOfFurniture> selectedFurniture = Home.getFurnitureSubList(selectedItems);
      homeSelectionContainsFurniture = !selectedFurniture.isEmpty();
      for (HomePieceOfFurniture piece : selectedFurniture) {
        if (furnitureController.isPieceOfFurnitureDeletable(piece)) {
          homeSelectionContainsDeletableFurniture = true;
          break;
        }
      }
      for (HomePieceOfFurniture piece : selectedFurniture) {
        if (piece instanceof HomeFurnitureGroup) {
          homeSelectionContainsFurnitureGroup = true;
          break;
        }
      }
      int movablePiecesOfFurnitureCount = 0;
      for (HomePieceOfFurniture piece : selectedFurniture) {
        if (furnitureController.isPieceOfFurnitureMovable(piece)) {
          movablePiecesOfFurnitureCount++;
          if (movablePiecesOfFurnitureCount >= 2) {
            homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore = true;
          } 
          if (movablePiecesOfFurnitureCount >= 3) {
            homeSelectionContainsThreeMovablePiecesOfFurnitureOrMore = true;
            break;
          }
        }
      }
      List<Wall> selectedWalls = Home.getWallsSubList(selectedItems);
      homeSelectionContainsWalls = !selectedWalls.isEmpty();
      homeSelectionContainsOneWall = selectedWalls.size() == 1;
      homeSelectionContainsRooms = !Home.getRoomsSubList(selectedItems).isEmpty();
      boolean homeSelectionContainsDimensionLines = !Home.getDimensionLinesSubList(selectedItems).isEmpty();
      final List<Label> selectedLabels = Home.getLabelsSubList(selectedItems);
      boolean homeSelectionContainsLabels = !selectedLabels.isEmpty();
      homeSelectionContainsCompass = selectedItems.contains(this.home.getCompass());
      homeSelectionContainsOneLabel = selectedLabels.size() == 1;
      homeSelectionContainsOneCopiableItemOrMore = 
          homeSelectionContainsFurniture || homeSelectionContainsWalls 
          || homeSelectionContainsRooms || homeSelectionContainsDimensionLines
          || homeSelectionContainsLabels || homeSelectionContainsCompass; 
      homeSelectionContainsItemsWithText = 
          homeSelectionContainsFurniture || homeSelectionContainsRooms 
          || homeSelectionContainsDimensionLines || homeSelectionContainsLabels;
    }

    HomeView view = getView();
    if (this.focusedView == getFurnitureCatalogController().getView()) {
      view.setEnabled(HomeView.ActionType.COPY,
          !modificationState && catalogSelectionContainsFurniture);
      view.setEnabled(HomeView.ActionType.CUT, false);
      view.setEnabled(HomeView.ActionType.DELETE, false);
      for (CatalogPieceOfFurniture piece : catalogSelectedItems) {
        if (piece.isModifiable()) {
          // Only modifiable catalog furniture may be deleted
          view.setEnabled(HomeView.ActionType.DELETE, true);
          break;
        }
      }
    } else if (this.focusedView == furnitureController.getView()) {
      view.setEnabled(HomeView.ActionType.COPY, homeSelectionContainsFurniture);
      view.setEnabled(HomeView.ActionType.CUT, homeSelectionContainsDeletableFurniture);
      view.setEnabled(HomeView.ActionType.DELETE, homeSelectionContainsDeletableFurniture);
    } else if (this.focusedView == getPlanController().getView()) {
      view.setEnabled(HomeView.ActionType.COPY, homeSelectionContainsOneCopiableItemOrMore);
      view.setEnabled(HomeView.ActionType.CUT, homeSelectionContainsDeletableItems);
      view.setEnabled(HomeView.ActionType.DELETE, homeSelectionContainsDeletableItems);
    } else {
      view.setEnabled(HomeView.ActionType.COPY, false);
      view.setEnabled(HomeView.ActionType.CUT, false);
      view.setEnabled(HomeView.ActionType.DELETE, false);
    }

    view.setEnabled(HomeView.ActionType.ADD_HOME_FURNITURE, catalogSelectionContainsFurniture);
    // In creation mode all actions bound to selection are disabled
    view.setEnabled(HomeView.ActionType.DELETE_HOME_FURNITURE,
        homeSelectionContainsDeletableFurniture);
    view.setEnabled(HomeView.ActionType.DELETE_SELECTION,
        (catalogSelectionContainsFurniture
            && this.focusedView == getFurnitureCatalogController().getView())
        || (homeSelectionContainsDeletableItems 
            && (this.focusedView == furnitureController.getView()
                || this.focusedView == getPlanController().getView()
                || this.focusedView == getHomeController3D().getView())));
    view.setEnabled(HomeView.ActionType.MODIFY_FURNITURE,
        (catalogSelectionContainsOneModifiablePiece
             && this.focusedView == getFurnitureCatalogController().getView())
        || (homeSelectionContainsFurniture 
             && (this.focusedView == furnitureController.getView()
                 || this.focusedView == getPlanController().getView()
                 || this.focusedView == getHomeController3D().getView())));
    view.setEnabled(HomeView.ActionType.MODIFY_WALL,
        homeSelectionContainsWalls);
    view.setEnabled(HomeView.ActionType.REVERSE_WALL_DIRECTION,
        homeSelectionContainsWalls);
    view.setEnabled(HomeView.ActionType.SPLIT_WALL,
        homeSelectionContainsOneWall);
    view.setEnabled(HomeView.ActionType.MODIFY_ROOM,
        homeSelectionContainsRooms);
    view.setEnabled(HomeView.ActionType.MODIFY_LABEL,
        homeSelectionContainsOneLabel);
    view.setEnabled(HomeView.ActionType.TOGGLE_BOLD_STYLE, 
        homeSelectionContainsItemsWithText);
    view.setEnabled(HomeView.ActionType.TOGGLE_ITALIC_STYLE, 
        homeSelectionContainsItemsWithText);
    view.setEnabled(HomeView.ActionType.INCREASE_TEXT_SIZE, 
        homeSelectionContainsItemsWithText);
    view.setEnabled(HomeView.ActionType.DECREASE_TEXT_SIZE, 
        homeSelectionContainsItemsWithText);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_TOP,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_BOTTOM,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_LEFT,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_RIGHT,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_FRONT_SIDE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_BACK_SIDE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_LEFT_SIDE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_ON_RIGHT_SIDE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.ALIGN_FURNITURE_SIDE_BY_SIDE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.DISTRIBUTE_FURNITURE_HORIZONTALLY,
        homeSelectionContainsThreeMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.DISTRIBUTE_FURNITURE_VERTICALLY,
        homeSelectionContainsThreeMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.GROUP_FURNITURE,
        homeSelectionContainsTwoMovablePiecesOfFurnitureOrMore);
    view.setEnabled(HomeView.ActionType.UNGROUP_FURNITURE,
        homeSelectionContainsFurnitureGroup);
  }

  /**
   * Enables clipboard paste action if clipboard isn't empty.
   */
  public void enablePasteAction() {
    HomeView view = getView();
    if (this.focusedView == getFurnitureController().getView()
        || this.focusedView == getPlanController().getView()) {
      view.setEnabled(HomeView.ActionType.PASTE,
          !getPlanController().isModificationState() && !view.isClipboardEmpty());
    } else {
      view.setEnabled(HomeView.ActionType.PASTE, false);
    }
  }

  /**
   * Enables select all action if home isn't empty.
   */
  protected void enableSelectAllAction() {
    HomeView view = getView();
    boolean modificationState = getPlanController().isModificationState();
    if (this.focusedView == getFurnitureController().getView()) {
      view.setEnabled(HomeView.ActionType.SELECT_ALL,
          !modificationState 
          && this.home.getFurniture().size() > 0);
    } else if (this.focusedView == getPlanController().getView()
               || this.focusedView == getHomeController3D().getView()) {
      boolean homeContainsOneSelectableItemOrMore = !this.home.isEmpty()
          || this.home.getCompass().isVisible();
      view.setEnabled(HomeView.ActionType.SELECT_ALL,
          !modificationState && homeContainsOneSelectableItemOrMore);
    } else {
      view.setEnabled(HomeView.ActionType.SELECT_ALL, false);
    }
  }

  /**
   * Enables zoom actions depending on current scale.
   */
  private void enableZoomActions() {
    PlanController planController = getPlanController();
    float scale = planController.getScale();
    HomeView view = getView();
    view.setEnabled(HomeView.ActionType.ZOOM_IN, scale < planController.getMaximumScale());
    view.setEnabled(HomeView.ActionType.ZOOM_OUT, scale > planController.getMinimumScale());    
  }
  
  /**
   * Adds undoable edit listener to undo support that enables Undo action.
   */
  private void addUndoSupportListener() {
    getUndoableEditSupport().addUndoableEditListener(
      new UndoableEditListener () {
        public void undoableEditHappened(UndoableEditEvent ev) {
          HomeView view = getView();
          view.setEnabled(HomeView.ActionType.UNDO, 
              !getPlanController().isModificationState());
          view.setEnabled(HomeView.ActionType.REDO, false);
          view.setUndoRedoName(ev.getEdit().getUndoPresentationName(), null);
          saveUndoLevel++;
          home.setModified(true);
        }
      });
   home.addPropertyChangeListener(Home.Property.MODIFIED, new PropertyChangeListener() {
      public void propertyChange(PropertyChangeEvent ev) {
        if (!home.isModified()) {
          // Change undo level and modification flag if home is set as unmodified
          saveUndoLevel = 0;
          notUndoableModifications = false;
        }
      }
    });
  }

  /**
   * Adds a furniture listener to home that enables / disables actions on furniture list change.
   */
  @SuppressWarnings("unchecked")
  private void addHomeItemsListener() {
    CollectionListener homeItemsListener = 
        new CollectionListener() {
          public void collectionChanged(CollectionEvent ev) {
            if (ev.getType() == CollectionEvent.Type.ADD 
                || ev.getType() == CollectionEvent.Type.DELETE) {
              enableSelectAllAction();
            }
          }
        };
    this.home.addFurnitureListener((CollectionListener<HomePieceOfFurniture>)homeItemsListener);
    this.home.addWallsListener((CollectionListener<Wall>)homeItemsListener);
    this.home.addRoomsListener((CollectionListener<Room>)homeItemsListener);
    this.home.addDimensionLinesListener((CollectionListener<DimensionLine>)homeItemsListener);
    this.home.addLabelsListener((CollectionListener<Label>)homeItemsListener);
    this.home.getCompass().addPropertyChangeListener(new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          if (Compass.Property.VISIBLE.equals(ev.getPropertyName())) {
            enableSelectAllAction();
          }
        }
      });
    this.home.addPropertyChangeListener(Home.Property.CAMERA, new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          getView().setEnabled(HomeView.ActionType.MODIFY_OBSERVER, home.getCamera() == home.getObserverCamera());
        }
      });
  }

  /**
   * Adds a property change listener to home to
   * enable/disable authorized actions according to selected level.
   */
  private void addLevelListeners() {
    final PropertyChangeListener selectedLevelListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          // Keep in selection only items that are at this level
          List<Selectable> selectedItemsAtLevel = new ArrayList<Selectable>();
          Level selectedLevel = home.getSelectedLevel();
          for (Selectable item : home.getSelectedItems()) {
            if (!(item instanceof Elevatable)
                || ((Elevatable)item).isAtLevel(selectedLevel)) {
              selectedItemsAtLevel.add(item);
            }
          }
          home.setSelectedItems(selectedItemsAtLevel);
          enableBackgroungImageActions(getView(), selectedLevel == null 
              ? home.getBackgroundImage()
              : selectedLevel.getBackgroundImage());
          List<Level> levels = home.getLevels();
          boolean homeContainsOneSelectedLevel = levels.size() > 1 && selectedLevel != null;
          getView().setEnabled(HomeView.ActionType.MODIFY_LEVEL, homeContainsOneSelectedLevel);
          getView().setEnabled(HomeView.ActionType.DELETE_LEVEL, homeContainsOneSelectedLevel);
          getView().setEnabled(HomeView.ActionType.DISPLAY_ALL_LEVELS, levels.size() > 1);
          getView().setEnabled(HomeView.ActionType.DISPLAY_SELECTED_LEVEL, levels.size() > 1);
        }
      };
    this.home.addPropertyChangeListener(Home.Property.SELECTED_LEVEL, selectedLevelListener);
    final PropertyChangeListener backgroundImageChangeListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          if (Level.Property.BACKGROUND_IMAGE.name().equals(ev.getPropertyName())) {
            enableBackgroungImageActions(getView(), (BackgroundImage)ev.getNewValue());
          }
        }
      };
    for (Level level : home.getLevels()) {
      level.addPropertyChangeListener(backgroundImageChangeListener);
    }
    this.home.addLevelsListener(new CollectionListener<Level>() {
        public void collectionChanged(CollectionEvent<Level> ev) {
          switch (ev.getType()) {
            case ADD :
              home.setSelectedLevel(ev.getItem());
              ev.getItem().addPropertyChangeListener(backgroundImageChangeListener);
              break;
            case DELETE :
              selectedLevelListener.propertyChange(null);
              ev.getItem().removePropertyChangeListener(backgroundImageChangeListener);
              break;
          }
        }
      });
  }

  /**
   * Adds a property change listener to home to
   * enable/disable authorized actions according to stored cameras change.
   */
  private void addStoredCamerasListener() {
    this.home.addPropertyChangeListener(Home.Property.STORED_CAMERAS, new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          boolean emptyStoredCameras = home.getStoredCameras().isEmpty();
          getView().setEnabled(HomeView.ActionType.DELETE_POINTS_OF_VIEW, !emptyStoredCameras);
          getView().setEnabled(HomeView.ActionType.CREATE_PHOTOS_AT_POINTS_OF_VIEW, !emptyStoredCameras);
        }
      });
  }

  /**
   * Adds a property change listener to plan controller to 
   * enable/disable authorized actions according to its modification state and the plan scale.
   */
  private void addPlanControllerListeners() {
    getPlanController().addPropertyChangeListener(PlanController.Property.MODIFICATION_STATE, 
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            enableActionsBoundToSelection();
            enableSelectAllAction();
            HomeView view = getView();
            if (getPlanController().isModificationState()) {
              view.setEnabled(HomeView.ActionType.PASTE, false);
              view.setEnabled(HomeView.ActionType.UNDO, false);
              view.setEnabled(HomeView.ActionType.REDO, false);
            } else {
              enablePasteAction();
              view.setEnabled(HomeView.ActionType.UNDO, undoManager.canUndo());
              view.setEnabled(HomeView.ActionType.REDO, undoManager.canRedo());
            }
          }
        });
    getPlanController().addPropertyChangeListener(PlanController.Property.SCALE, 
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            enableZoomActions();
          }
        });
  }
  
  /**
   * Adds the selected furniture in catalog to home and selects it.  
   */
  public void addHomeFurniture() {
    // Use automatically selection mode  
    getPlanController().setMode(PlanController.Mode.SELECTION);
    List<CatalogPieceOfFurniture> selectedFurniture = 
      getFurnitureCatalogController().getSelectedFurniture();
    if (!selectedFurniture.isEmpty()) {
      List<HomePieceOfFurniture> newFurniture = 
          new ArrayList<HomePieceOfFurniture>();
      for (CatalogPieceOfFurniture piece : selectedFurniture) {
        HomePieceOfFurniture homePiece = getFurnitureController().createHomePieceOfFurniture(piece);
        // If magnetism is enabled, adjust piece size and elevation
        if (this.preferences.isMagnetismEnabled()) {
          if (homePiece.isResizable()) {
            homePiece.setWidth(this.preferences.getLengthUnit().getMagnetizedLength(homePiece.getWidth(), 0.1f));
            homePiece.setDepth(this.preferences.getLengthUnit().getMagnetizedLength(homePiece.getDepth(), 0.1f));
            homePiece.setHeight(this.preferences.getLengthUnit().getMagnetizedLength(homePiece.getHeight(), 0.1f));
          }
          homePiece.setElevation(this.preferences.getLengthUnit().getMagnetizedLength(homePiece.getElevation(), 0.1f));
        }
        newFurniture.add(homePiece);
      }
      // Add newFurniture to home with furnitureController
      getFurnitureController().addFurniture(newFurniture);
    }
  }
  
  /**
   * Modifies the selected furniture of the focused view.  
   */
  public void modifySelectedFurniture() {
    if (this.focusedView == getFurnitureCatalogController().getView()) {
      getFurnitureCatalogController().modifySelectedFurniture();
    } else if (this.focusedView == getFurnitureController().getView()
               || this.focusedView == getPlanController().getView()
               || this.focusedView == getHomeController3D().getView()) {
      getFurnitureController().modifySelectedFurniture();
    }    
  }
  
  /**
   * Imports a language library chosen by the user.  
   */
  public void importLanguageLibrary() {
    getView().invokeLater(new Runnable() {
        public void run() {
          final String languageLibraryName = getView().showImportLanguageLibraryDialog();
          if (languageLibraryName != null) {
            importLanguageLibrary(languageLibraryName);
          }
        }
      });
  }

  /**
   * Imports a given language library. 
   */
  public void importLanguageLibrary(String languageLibraryName) {
    try {
      if (!this.preferences.languageLibraryExists(languageLibraryName) 
          || getView().confirmReplaceLanguageLibrary(languageLibraryName)) {
        this.preferences.addLanguageLibrary(languageLibraryName);
      }
    } catch (RecorderException ex) {
      String message = this.preferences.getLocalizedString(HomeController.class, 
          "importLanguageLibraryError", languageLibraryName);
      getView().showError(message);
    }
  }

  /**
   * Imports furniture to the catalog or home depending on the focused view.  
   */
  public void importFurniture() {
    // Always use selection mode after an import furniture operation
    getPlanController().setMode(PlanController.Mode.SELECTION);
    if (this.focusedView == getFurnitureCatalogController().getView()) {
      getFurnitureCatalogController().importFurniture();
    } else {
      getFurnitureController().importFurniture();
    }    
  }

  /**
   * Imports a furniture library chosen by the user.  
   */
  public void importFurnitureLibrary() {
    getView().invokeLater(new Runnable() {
        public void run() {
          final String furnitureLibraryName = getView().showImportFurnitureLibraryDialog();
          if (furnitureLibraryName != null) {
            importFurnitureLibrary(furnitureLibraryName);
          }
        }
      });
  }

  /**
   * Imports a given furniture library. 
   */
  public void importFurnitureLibrary(String furnitureLibraryName) {
    try {
      if (!this.preferences.furnitureLibraryExists(furnitureLibraryName) 
          || getView().confirmReplaceFurnitureLibrary(furnitureLibraryName)) {
        this.preferences.addFurnitureLibrary(furnitureLibraryName);
      }
    } catch (RecorderException ex) {
      String message = this.preferences.getLocalizedString(HomeController.class, 
          "importFurnitureLibraryError", furnitureLibraryName);
      getView().showError(message);
    }
  }

  /**
   * Imports a texture to the texture catalog.  
   * @since 4.0
   */
  public void importTexture() {
    new ImportedTextureWizardController(this.preferences, 
        this.viewFactory, this.contentManager).displayView(getView());
  }

  /**
   * Imports a textures library chosen by the user.  
   */
  public void importTexturesLibrary() {
    getView().invokeLater(new Runnable() {
        public void run() {
          final String texturesLibraryName = getView().showImportTexturesLibraryDialog();
          if (texturesLibraryName != null) {
            importTexturesLibrary(texturesLibraryName);
          }
        }
      });
  }

  /**
   * Imports a given textures library. 
   */
  public void importTexturesLibrary(String texturesLibraryName) {
    try {
      if (!this.preferences.texturesLibraryExists(texturesLibraryName) 
          || getView().confirmReplaceTexturesLibrary(texturesLibraryName)) {
        this.preferences.addTexturesLibrary(texturesLibraryName);
      }
    } catch (RecorderException ex) {
      String message = this.preferences.getLocalizedString(HomeController.class, 
          "importTexturesLibraryError", texturesLibraryName);
      getView().showError(message);
    }
  }

  /**
   * Undoes last operation.
   */
  public void undo() {
    this.undoManager.undo();
    HomeView view = getView();
    boolean moreUndo = this.undoManager.canUndo();
    view.setEnabled(HomeView.ActionType.UNDO, moreUndo);
    view.setEnabled(HomeView.ActionType.REDO, true);
    if (moreUndo) {
      view.setUndoRedoName(this.undoManager.getUndoPresentationName(),
          this.undoManager.getRedoPresentationName());
    } else {
      view.setUndoRedoName(null, this.undoManager.getRedoPresentationName());
    }
    this.saveUndoLevel--;
    this.home.setModified(this.saveUndoLevel != 0 || this.notUndoableModifications);
  }
  
  /**
   * Redoes last undone operation.
   */
  public void redo() {
    this.undoManager.redo();
    HomeView view = getView();
    boolean moreRedo = this.undoManager.canRedo();
    view.setEnabled(HomeView.ActionType.UNDO, true);
    view.setEnabled(HomeView.ActionType.REDO, moreRedo);
    if (moreRedo) {
      view.setUndoRedoName(this.undoManager.getUndoPresentationName(),
          this.undoManager.getRedoPresentationName());
    } else {
      view.setUndoRedoName(this.undoManager.getUndoPresentationName(), null);
    }
    this.saveUndoLevel++;
    this.home.setModified(this.saveUndoLevel != 0 || this.notUndoableModifications);
  }

  /**
   * Deletes items and post a cut operation to undo support.
   */
  public void cut(List<? extends Selectable> items) {
    // Start a compound edit that deletes items and changes presentation name
    UndoableEditSupport undoSupport = getUndoableEditSupport();
    undoSupport.beginUpdate();
    getPlanController().deleteItems(items);
    // Add a undoable edit to change presentation name
    undoSupport.postEdit(new AbstractUndoableEdit() { 
        @Override
        public String getPresentationName() {
          return preferences.getLocalizedString(HomeController.class, "undoCutName");
        }      
      });
    // End compound edit
    undoSupport.endUpdate();
  }
  
  /**
   * Adds items to home and posts a paste operation to undo support.
   */
  public void paste(final List<? extends Selectable> items) {
    // Check if pasted items and currently selected items overlap 
    List<Selectable> selectedItems = this.home.getSelectedItems();
    float pastedItemsDelta = 0;
    if (items.size() == selectedItems.size()) {
      // The default delta used to be able to distinguish dropped items from previous selection
      pastedItemsDelta = 20; 
      for (Selectable pastedItem : items) {      
        // Search which item of selected items it may overlap
        float [][] pastedItemPoints = pastedItem.getPoints();
        boolean pastedItemOverlapSelectedItem = false;
        for (Selectable selectedItem : selectedItems) {
          if (Arrays.deepEquals(pastedItemPoints, selectedItem.getPoints())) {
            pastedItemOverlapSelectedItem = true;
            break;
          }
        }
        if (!pastedItemOverlapSelectedItem) {
          pastedItemsDelta = 0;
          break;
        }
      }
    }
    addPastedItems(items, pastedItemsDelta, pastedItemsDelta, false, "undoPasteName");
  }

  /**
   * Adds items to home, moves them of (dx, dy) 
   * and posts a drop operation to undo support.
   */
  public void drop(final List<? extends Selectable> items, float dx, float dy) {
    drop(items, null, dx, dy);
  }

  /**
   * Adds items to home, moves them of (dx, dy) 
   * and posts a drop operation to undo support.
   */
  public void drop(final List<? extends Selectable> items, View destinationView, float dx, float dy) {
    addPastedItems(items, dx, dy, destinationView == getPlanController().getView(), "undoDropName");
  }

  /**
   * Adds items to home.
   */
  private void addPastedItems(final List<? extends Selectable> items, 
                              float dx, float dy, final boolean isDropInPlanView, 
                              final String presentationNameKey) {
    if (items.size() > 1
        || (items.size() == 1
            && !(items.get(0) instanceof Compass))) {
      // Always use selection mode after a drop or a paste operation
      getPlanController().setMode(PlanController.Mode.SELECTION);
      // Start a compound edit that adds walls, furniture, rooms, dimension lines and labels to home
      UndoableEditSupport undoSupport = getUndoableEditSupport();
      undoSupport.beginUpdate();
      List<HomePieceOfFurniture> addedFurniture = Home.getFurnitureSubList(items);
      // If magnetism is enabled, adjust furniture size and elevation
      if (this.preferences.isMagnetismEnabled()) {
        for (HomePieceOfFurniture piece : addedFurniture) {
          if (piece.isResizable()) {
            piece.setWidth(this.preferences.getLengthUnit().getMagnetizedLength(piece.getWidth(), 0.1f));
            // Don't adjust depth of doors or windows otherwise they may be misplaced in a wall 
            if (!(piece instanceof HomeDoorOrWindow) || dx != 0 || dy != 0) {
              piece.setDepth(this.preferences.getLengthUnit().getMagnetizedLength(piece.getDepth(), 0.1f));
            }
            piece.setHeight(this.preferences.getLengthUnit().getMagnetizedLength(piece.getHeight(), 0.1f));
          }
          piece.setElevation(this.preferences.getLengthUnit().getMagnetizedLength(piece.getElevation(), 0.1f));
        }
      }
      getPlanController().moveItems(items, dx, dy);
      if (isDropInPlanView 
          && this.preferences.isMagnetismEnabled()
          && items.size() == 1
          && addedFurniture.size() == 1) {
        // Adjust piece when it's dropped in plan view  
        getPlanController().adjustMagnetizedPieceOfFurniture((HomePieceOfFurniture)items.get(0), dx, dy);
      } 
      getPlanController().addItems(items);
      undoSupport.postEdit(new AbstractUndoableEdit() {      
          @Override
          public String getPresentationName() {
            return preferences.getLocalizedString(HomeController.class, presentationNameKey);
          }      
        });
     
      // End compound edit
      undoSupport.endUpdate();
    }
  }

  /**
   * Adds imported models to home, moves them of (dx, dy) 
   * and post a drop operation to undo support.
   */
  public void dropFiles(final List<String> importableModels, float dx, float dy) {
    // Always use selection mode after a drop operation
    getPlanController().setMode(PlanController.Mode.SELECTION);
    // Add to home a listener to track imported furniture 
    final List<HomePieceOfFurniture> importedFurniture = 
        new ArrayList<HomePieceOfFurniture>(importableModels.size());
    CollectionListener<HomePieceOfFurniture> addedFurnitureListener = 
        new CollectionListener<HomePieceOfFurniture>() {
          public void collectionChanged(CollectionEvent<HomePieceOfFurniture> ev) {
            importedFurniture.add(ev.getItem());
          }
        };
    this.home.addFurnitureListener(addedFurnitureListener);
    
    // Start a compound edit that adds furniture to home
    UndoableEditSupport undoSupport = getUndoableEditSupport();
    undoSupport.beginUpdate();
    // Import furniture
    for (String model : importableModels) {
      getFurnitureController().importFurniture(model);
    }
    this.home.removeFurnitureListener(addedFurnitureListener);
    
    if (importedFurniture.size() > 0) {
      getPlanController().moveItems(importedFurniture, dx, dy);
      this.home.setSelectedItems(importedFurniture);
      
      // Add a undoable edit that will select the imported furniture at redo
      undoSupport.postEdit(new AbstractUndoableEdit() {      
          @Override
          public void redo() throws CannotRedoException {
            super.redo();
            home.setSelectedItems(importedFurniture);
          }
  
          @Override
          public String getPresentationName() {
            return preferences.getLocalizedString(HomeController.class, "undoDropName");
          }      
        });
    }
   
    // End compound edit
    undoSupport.endUpdate();
  }

  /**
   * Deletes the selection in the focused component.
   */
  public void delete() {
    if (this.focusedView == getFurnitureCatalogController().getView()) {
      if (getView().confirmDeleteCatalogSelection()) {
        getFurnitureCatalogController().deleteSelection();
      }
    } else if (this.focusedView == getFurnitureController().getView()) {
      getFurnitureController().deleteSelection();
    } else if (this.focusedView == getPlanController().getView()) {
      getPlanController().deleteSelection();
    }
  }
  
  /**
   * Updates actions when focused view changed.
   */
  public void focusedViewChanged(View focusedView) {
    this.focusedView = focusedView;
    enableActionsBoundToSelection();
    enablePasteAction();
    enableSelectAllAction();
  }
  
  /**
   * Selects everything in the focused component.
   */
  public void selectAll() {
    if (this.focusedView == getFurnitureController().getView()) {
      getFurnitureController().selectAll();
    } else if (this.focusedView == getPlanController().getView()
               || this.focusedView == getHomeController3D().getView()) {
      getPlanController().selectAll();
    }
  }

  /**
   * Creates a new home and adds it to application home list.
   */
  public void newHome() {
    Home home;
    if (this.application != null) {
      home = this.application.createHome();
    } else {
      home = new Home(this.preferences.getNewWallHeight());
    }
    this.application.addHome(home);
  }

  /**
   * Opens a home. This method displays an {@link HomeView#showOpenDialog() open dialog} 
   * in view, reads the home from the chosen name and adds it to application home list.
   */
  public void open() {
    getView().invokeLater(new Runnable() {
      public void run() {
        final String homeName = getView().showOpenDialog();
        if (homeName != null) {
          open(homeName);
        }
      }
    });
  }

  /**
   * Opens a given <code>homeName</code>home.
   */
  public void open(final String homeName) {
    // Check if requested home isn't already opened
    for (Home home : this.application.getHomes()) {
      if (homeName.equals(home.getName())) {
        String message = this.preferences.getLocalizedString(
            HomeController.class, "alreadyOpen", homeName);
        getView().showMessage(message);
        return;
      }
    }
    
    // Read home in a threaded task
    Callable<Void> openTask = new Callable<Void>() {
          public Void call() throws RecorderException {
            // Read home with application recorder
            Home openedHome = application.getHomeRecorder().readHome(homeName);
            openedHome.setName(homeName);
            addHomeToApplication(openedHome);
            return null;
          }
        };
    ThreadedTaskController.ExceptionHandler exceptionHandler = 
        new ThreadedTaskController.ExceptionHandler() {
          public void handleException(Exception ex) {
            if (!(ex instanceof InterruptedRecorderException)) {
              if (ex instanceof RecorderException) {
                String message = preferences.getLocalizedString(
                    HomeController.class, "openError", homeName);
                getView().showError(message);
              } else {
                ex.printStackTrace();
              }
            }
          }
        };
    new ThreadedTaskController(openTask, 
        this.preferences.getLocalizedString(HomeController.class, "openMessage"), exceptionHandler, 
        this.preferences, this.viewFactory).executeTask(getView());
  }
  
  /**
   * Adds the given home to application.
   */
  private void addHomeToApplication(final Home home) {
    getView().invokeLater(new Runnable() {
        public void run() {
          application.addHome(home);
        }
      });
  }
  
  /**
   * Updates user preferences <code>recentHomes</code> and write preferences. 
   */
  private void updateUserPreferencesRecentHomes(List<String> recentHomes) {
    if (this.application != null) {
      // Check every recent home exists
      for (Iterator<String> it = recentHomes.iterator(); it.hasNext(); ) {
        try {
          if (!this.application.getHomeRecorder().exists(it.next())) {
            it.remove();
          }
        } catch (RecorderException ex) {
          // If homeName can't be checked ignore it
        }
      }
      this.preferences.setRecentHomes(recentHomes);
    }
  }

  /**
   * Returns a list of displayable recent homes. 
   */
  public List<String> getRecentHomes() {
    if (this.application != null) {
      List<String> recentHomes = new ArrayList<String>();
      for (String homeName : this.preferences.getRecentHomes()) {
        try {
          if (this.application.getHomeRecorder().exists(homeName)) {
            recentHomes.add(homeName);
            if (recentHomes.size() == this.preferences.getRecentHomesMaxCount()) {
              break;
            }
          }
        } catch (RecorderException ex) {
          // If homeName can't be checked ignore it
        }
      }
      getView().setEnabled(HomeView.ActionType.DELETE_RECENT_HOMES, 
          !recentHomes.isEmpty());
      return Collections.unmodifiableList(recentHomes);
    } else {
      return new ArrayList<String>();
    }
  }
  
  /**
   * Returns the version of the application for display purpose.
   */
  public String getVersion() {
    if (this.application != null) {
      String applicationVersion = this.application.getVersion();
      try {
        String deploymentInformation = System.getProperty("com.eteks.sweethome3d.deploymentInformation");
        if (deploymentInformation != null) {
          applicationVersion += " " + deploymentInformation;
        }
      } catch (AccessControlException ex) {
        // Ignore com.eteks.sweethome3d.deploymentInformation property since it can't be read
      }
      return applicationVersion;
    } else {
      return "";
    }
  }
  
  /**
   * Deletes the list of recent homes in user preferences. 
   */
  public void deleteRecentHomes() {
    updateUserPreferencesRecentHomes(new ArrayList<String>());
    getView().setEnabled(HomeView.ActionType.DELETE_RECENT_HOMES, false);
  }
  
  /**
   * Manages home close operation. If the home managed by this controller is modified,
   * this method will {@link HomeView#confirmSave(String) confirm} 
   * in view whether home should be saved. Once home is actually saved,
   * home is removed from application homes list.
   */
  public void close() {
    close(null);
  }

  
  /**
   * Manages home close operation. If the home managed by this controller is modified,
   * this method will {@link HomeView#confirmSave(String) confirm} 
   * in view whether home should be saved. Once home is actually saved,
   * home is removed from application homes list and postCloseTask is called if
   * it's not <code>null</code>.
   */
  protected void close(final Runnable postCloseTask) {
    // Create a task that deletes home and run postCloseTask
    Runnable closeTask = new Runnable() {
        public void run() {
          home.setRecovered(false);
          application.deleteHome(home);
          if (postCloseTask != null) {
            postCloseTask.run();
          }
        }
      };
      
    if (this.home.isModified()  || this.home.isRecovered()) {
      switch (getView().confirmSave(this.home.getName())) {
        case SAVE   : save(HomeRecorder.Type.DEFAULT, closeTask); // Falls through
        case CANCEL : return;
      }  
    }
    closeTask.run();
  }
  
  /**
   * Saves the home managed by this controller. If home name doesn't exist, 
   * this method will act as {@link #saveAs() saveAs} method.
   */
  public void save() {
    save(HomeRecorder.Type.DEFAULT, null);
  }

  /**
   * Saves the home managed by this controller and executes <code>postSaveTask</code> 
   * if it's not <code>null</code>.
   */
  private void save(HomeRecorder.Type recorderType, Runnable postSaveTask) {
    if (this.home.getName() == null) {
      saveAs(recorderType, postSaveTask);
    } else {
      save(this.home.getName(), recorderType, postSaveTask);
    }
  }
  
  /**
   * Saves the home managed by this controller with a different name. 
   * This method displays a {@link HomeView#showSaveDialog(String) save dialog} in  view, 
   * and saves home with the chosen name if any. 
   */
  public void saveAs() {
    saveAs(HomeRecorder.Type.DEFAULT, null);
  }

  /**
   * Saves the home managed by this controller with a different name. 
   */
  private void saveAs(HomeRecorder.Type recorderType, Runnable postSaveTask) {
    String newName = getView().showSaveDialog(this.home.getName());
    if (newName != null) {
      save(newName, recorderType, postSaveTask);
    }
  }

  /**
   * Saves the home managed by this controller and compresses it. If home name doesn't exist, 
   * this method will prompt user to choose a home name.
   */
  public void saveAndCompress() {
    save(HomeRecorder.Type.COMPRESSED, null);
  }
  
  /**
   * Actually saves the home managed by this controller and executes <code>postSaveTask</code> 
   * if it's not <code>null</code>.
   */
  private void save(final String homeName, 
                    final HomeRecorder.Type recorderType, 
                    final Runnable postSaveTask) {
    // If home version is older than current version
    // or if home name is changed
    // or if user confirms to save a home created with a newer version
    if (this.home.getVersion() <= Home.CURRENT_VERSION
        || !homeName.equals(this.home.getName()) 
        || getView().confirmSaveNewerHome(homeName)) {
      final Home savedHome; 
      try {
        // Clone home to save it safely in a threaded task
        savedHome = this.home.clone();
      } catch (RuntimeException ex) {
        // If home data is corrupted some way and couldn't be cloned 
        // warn the user his home couldn't be saved
        getView().showError(preferences.getLocalizedString(
            HomeController.class, "saveError", homeName, ex));
        throw ex;
      }
      Callable<Void> saveTask = new Callable<Void>() {
            public Void call() throws RecorderException {
              // Write home with application recorder
              application.getHomeRecorder(recorderType).writeHome(savedHome, homeName);
              updateSavedHome(homeName, postSaveTask);
              return null;
            }
          };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                String cause = ex.toString();
                if (ex instanceof RecorderException) {
                  cause = "RecorderException";
                  String message = ex.getMessage();
                  if (message != null) {
                    cause += ": " + message;
                  }
                  if (ex.getCause() != null) {
                    cause += "<br>" + ex.getCause();
                  }
                }
                ex.printStackTrace();
                getView().showError(preferences.getLocalizedString(
                    HomeController.class, "saveError", homeName, cause));
              }
            }
          };
      new ThreadedTaskController(saveTask, 
          this.preferences.getLocalizedString(HomeController.class, "saveMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());
    }
  }
  
  /**
   * Updates the saved home and executes <code>postSaveTask</code> 
   * if it's not <code>null</code>.
   */
  private void updateSavedHome(final String homeName,
                               final Runnable postSaveTask) {
    getView().invokeLater(new Runnable() {
        public void run() {
          home.setName(homeName);
          home.setModified(false);
          home.setRecovered(false);
          // Update recent homes list
          List<String> recentHomes = new ArrayList<String>(preferences.getRecentHomes());
          int homeNameIndex = recentHomes.indexOf(homeName);
          if (homeNameIndex >= 0) {
            recentHomes.remove(homeNameIndex);
          }
          recentHomes.add(0, homeName);
          updateUserPreferencesRecentHomes(recentHomes);
          
          if (postSaveTask != null) {
            postSaveTask.run();
          }
        }
      });
  }

  /**
   * Controls the export of the furniture list of current home to a CSV file.
   * @since 4.0
   */
  public void exportToCSV() {
    final String csvName = getView().showExportToCSVDialog(this.home.getName());    
    if (csvName != null) {
      // Export furniture list in a threaded task
      Callable<Void> exportToCsvTask = new Callable<Void>() {
            public Void call() throws RecorderException {
              getView().exportToCSV(csvName);
              return null;
            }
          };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                if (ex instanceof RecorderException) {
                  String message = preferences.getLocalizedString(
                      HomeController.class, "exportToCSVError", csvName);
                  getView().showError(message);
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
      new ThreadedTaskController(exportToCsvTask, 
          this.preferences.getLocalizedString(HomeController.class, "exportToCSVMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());
    }
  }
  
  /**
   * Controls the export of the current home plan to a SVG file.
   */
  public void exportToSVG() {
    final String svgName = getView().showExportToSVGDialog(this.home.getName());    
    if (svgName != null) {
      // Export plan in a threaded task
      Callable<Void> exportToSvgTask = new Callable<Void>() {
            public Void call() throws RecorderException {
              getView().exportToSVG(svgName);
              return null;
            }
          };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                if (ex instanceof RecorderException) {
                  String message = preferences.getLocalizedString(
                      HomeController.class, "exportToSVGError", svgName);
                  getView().showError(message);
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
      new ThreadedTaskController(exportToSvgTask, 
          this.preferences.getLocalizedString(HomeController.class, "exportToSVGMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());
    }
  }
  
  /**
   * Controls the export of the 3D view of current home to an OBJ file.
   */
  public void exportToOBJ() {
    final String objName = getView().showExportToOBJDialog(this.home.getName());    
    if (objName != null) {
      // Export 3D view in a threaded task
      Callable<Void> exportToObjTask = new Callable<Void>() {
            public Void call() throws RecorderException {
              getView().exportToOBJ(objName);
              return null;
            }
          };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                if (ex instanceof RecorderException) {
                  String message = preferences.getLocalizedString(
                      HomeController.class, "exportToOBJError", objName);
                  getView().showError(message);
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
      new ThreadedTaskController(exportToObjTask, 
          this.preferences.getLocalizedString(HomeController.class, "exportToOBJMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());
    }
  }
  
  /**
   * Controls the creation of multiple photo-realistic images at the stored cameras locations.
   */
  public void createPhotos() {
    PhotosController photosController = new PhotosController(this.home, this.preferences, 
        getHomeController3D().getView(), this.viewFactory, this.contentManager);
    photosController.displayView(getView());
  }
  
  /**
   * Controls the creation of photo-realistic images.
   */
  public void createPhoto() {
    PhotoController photoController = new PhotoController(this.home, this.preferences, 
        getHomeController3D().getView(), this.viewFactory, this.contentManager);
    photoController.displayView(getView());
  }
  
  /**
   * Controls the creation of 3D videos.
   */
  public void createVideo() {
    getPlanController().setMode(PlanController.Mode.SELECTION);
    getHomeController3D().viewFromObserver();
    VideoController videoController = new VideoController(this.home, this.preferences, 
        this.viewFactory, this.contentManager);
    videoController.displayView(getView());
  }
  
  /**
   * Controls page setup.
   */
  public void setupPage() {
    new PageSetupController(this.home, this.preferences, 
        this.viewFactory, getUndoableEditSupport()).displayView(getView());
  }

  /**
   * Controls the print preview.
   */
  public void previewPrint() {
    new PrintPreviewController(this.home, this.preferences, 
        this, this.viewFactory).displayView(getView());
  }

  /**
   * Controls the print of this home.
   */
  public void print() {
    final Callable<Void> printTask = getView().showPrintDialog();    
    if (printTask != null) {
      // Print in a threaded task
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                if (ex instanceof RecorderException) {
                  String message = preferences.getLocalizedString(
                      HomeController.class, "printError", home.getName());
                  getView().showError(message);
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
      new ThreadedTaskController(printTask, 
          this.preferences.getLocalizedString(HomeController.class, "printMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());      
    }
  }

  /**
   * Controls the print of this home in a PDF file.
   */
  public void printToPDF() {
    final String pdfName = getView().showPrintToPDFDialog(this.home.getName());    
    if (pdfName != null) {
      // Print to PDF in a threaded task
      Callable<Void> printToPdfTask = new Callable<Void>() {
          public Void call() throws RecorderException {
            getView().printToPDF(pdfName);
            return null;
          }
        };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!(ex instanceof InterruptedRecorderException)) {
                if (ex instanceof RecorderException) {
                  String message = preferences.getLocalizedString(
                      HomeController.class, "printToPDFError", pdfName);
                  getView().showError(message);
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
      new ThreadedTaskController(printToPdfTask, 
          preferences.getLocalizedString(HomeController.class, "printToPDFMessage"), exceptionHandler, 
          this.preferences, this.viewFactory).executeTask(getView());
    }
  }

  /**
   * Controls application exit. If any home in application homes list is modified,
   * the user will be {@link HomeView#confirmExit() prompted} in view whether he wants
   * to discard his modifications ot not.  
   */
  public void exit() {
    for (Home home : this.application.getHomes()) {
      if (home.isModified() || home.isRecovered()) {
        if (getView().confirmExit()) {
          break;
        } else {
          return;
        }
      }
    }
    // Remove all homes from application
    for (Home home : this.application.getHomes()) {
      home.setRecovered(false);
      this.application.deleteHome(home);
    }
    // Let application decide what to do when there's no more home
  }

  /**
   * Edits preferences and changes them if user agrees.
   */
  public void editPreferences() {
    new UserPreferencesController(this.preferences, 
        this.viewFactory, this.contentManager, this).displayView(getView());
  }
  
  /**
   * Displays a tip message dialog depending on the given mode and 
   * sets the active mode of the plan controller. 
   */
  public void setMode(Mode mode) {
    if (getPlanController().getMode() != mode) {
      final String actionKey;
      if (mode == Mode.WALL_CREATION) {
        actionKey = HomeView.ActionType.CREATE_WALLS.name();
      } else if (mode == Mode.ROOM_CREATION) {
        actionKey = HomeView.ActionType.CREATE_ROOMS.name();
      } else if (mode == Mode.DIMENSION_LINE_CREATION) {
        actionKey = HomeView.ActionType.CREATE_DIMENSION_LINES.name();
      } else if (mode == Mode.LABEL_CREATION) {
        actionKey = HomeView.ActionType.CREATE_LABELS.name();
      } else {
        actionKey = null;
      }
      // Display the tip message dialog matching mode
      if (actionKey != null 
          && !this.preferences.isActionTipIgnored(actionKey)) {
        getView().invokeLater(new Runnable() {
            public void run() {
              // Show tip later to let the mode switch finish first
              if (getView().showActionTipMessage(actionKey)) {
                preferences.setActionTipIgnored(actionKey);
              }
            }
          });
      }
      getPlanController().setMode(mode);
    }
  }

  /**
   * Displays the wizard that helps to import home background image. 
   */
  public void importBackgroundImage() {
    new BackgroundImageWizardController(this.home, this.preferences, 
        this.viewFactory, this.contentManager, getUndoableEditSupport()).displayView(getView());
  }
  
  /**
   * Displays the wizard that helps to change home background image. 
   */
  public void modifyBackgroundImage() {
    importBackgroundImage();
  }
  
  /**
   * Hides the home background image. 
   */
  public void hideBackgroundImage() {     
    toggleBackgroundImageVisibility("undoHideBackgroundImageName");
  }
  
  /**
   * Shows the home background image. 
   */
  public void showBackgroundImage() {
    toggleBackgroundImageVisibility("undoShowBackgroundImageName");
  }
  
  /**
   * Toggles visibility of the background image and posts an undoable operation.
   */
  private void toggleBackgroundImageVisibility(final String presentationName) {
    final Level selectedLevel = this.home.getSelectedLevel();
    doToggleBackgroundImageVisibility(); 
    UndoableEdit undoableEdit = new AbstractUndoableEdit() {
        @Override
        public void undo() throws CannotUndoException {
          super.undo();
          home.setSelectedLevel(selectedLevel);
          doToggleBackgroundImageVisibility(); 
        }
        
        @Override
        public void redo() throws CannotRedoException {
          super.redo();
          home.setSelectedLevel(selectedLevel);
          doToggleBackgroundImageVisibility();
        }
        
        @Override
        public String getPresentationName() {
          return preferences.getLocalizedString(HomeController.class, presentationName);
        }
      };
    getUndoableEditSupport().postEdit(undoableEdit);
  }

  /**
   * Toggles visibility of the background image.
   */
  private void doToggleBackgroundImageVisibility() {
    BackgroundImage backgroundImage = this.home.getSelectedLevel() != null
        ? this.home.getSelectedLevel().getBackgroundImage()
        : this.home.getBackgroundImage();
    backgroundImage = new BackgroundImage(backgroundImage.getImage(),
        backgroundImage.getScaleDistance(), 
        backgroundImage.getScaleDistanceXStart(), backgroundImage.getScaleDistanceYStart(), 
        backgroundImage.getScaleDistanceXEnd(), backgroundImage.getScaleDistanceYEnd(),
        backgroundImage.getXOrigin(), backgroundImage.getYOrigin(), !backgroundImage.isVisible());
    if (this.home.getSelectedLevel() != null) {
      this.home.getSelectedLevel().setBackgroundImage(backgroundImage);
    } else {
      this.home.setBackgroundImage(backgroundImage);
    }
  }
  
  /**
   * Deletes home background image and posts and posts an undoable operation. 
   */
  public void deleteBackgroundImage() {
    final Level selectedLevel = this.home.getSelectedLevel();
    final BackgroundImage oldImage;
    if (selectedLevel != null) {
      oldImage = selectedLevel.getBackgroundImage();
      selectedLevel.setBackgroundImage(null);
    } else {
      oldImage = this.home.getBackgroundImage();
      this.home.setBackgroundImage(null);
    }
    UndoableEdit undoableEdit = new AbstractUndoableEdit() {
      @Override
      public void undo() throws CannotUndoException {
        super.undo();
        home.setSelectedLevel(selectedLevel);
        if (selectedLevel != null) {
          selectedLevel.setBackgroundImage(oldImage);
        } else {
          home.setBackgroundImage(oldImage);
        }
      }
      
      @Override
      public void redo() throws CannotRedoException {
        super.redo();
        home.setSelectedLevel(selectedLevel);
        if (selectedLevel != null) {
          selectedLevel.setBackgroundImage(null);
        } else {
          home.setBackgroundImage(null);
        }
      }
      
      @Override
      public String getPresentationName() {
        return preferences.getLocalizedString(HomeController.class, "undoDeleteBackgroundImageName");
      }
    };
    getUndoableEditSupport().postEdit(undoableEdit);
  }
  
  /**
   * Zooms out in plan.
   */
  public void zoomOut() {
    PlanController planController = getPlanController();
    float newScale = planController.getScale() / 1.5f;
    planController.setScale(newScale);
  }

  /**
   * Zooms in in plan.
   */
  public void zoomIn() {
    PlanController planController = getPlanController();
    float newScale = planController.getScale() * 1.5f;
    planController.setScale(newScale);
  }

  /**
   * Prompts a name for the current camera and stores it in home.
   */
  public void storeCamera() {
    String now = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(new Date());
    String name = getView().showStoreCameraDialog(now);
    if (name != null) {
      getHomeController3D().storeCamera(name);
    }
  }

  /**
   * Prompts stored cameras in home to be deleted and deletes the ones selected by the user.
   */
  public void deleteCameras() {
    List<Camera> deletedCameras = getView().showDeletedCamerasDialog();
    if (deletedCameras != null) {
      getHomeController3D().deleteCameras(deletedCameras);
    }
  }

  /**
   * Detaches the given <code>view</code> from home view.
   */
  public void detachView(View view) {
    if (view != null) {
      getView().detachView(view);
      this.notUndoableModifications = true;
      home.setModified(true);
    }
  }
      		
  /**
   * Attaches the given <code>view</code> to home view.
   */
  public void attachView(View view) {
    if (view != null) {
      getView().attachView(view);
      this.notUndoableModifications = true;
      home.setModified(true);
    }
  }
                
  /**
   * Displays help window.
   */
  public void help() {
    if (helpController == null) {
      helpController = new HelpController(this.preferences, this.viewFactory);
    }
    helpController.displayView();
  }

  /**
   * Displays about dialog.
   */
  public void about() {
    getView().showAboutDialog();
  }

  /**
   * Controls the change of value of a visual property in home.
   */
  public void setVisualProperty(String propertyName,
                                Object propertyValue) {
    this.home.setVisualProperty(propertyName, propertyValue);
  }

  /**
   * Checks if some application or libraries updates are available.
   * @since 4.0
   */
  public void checkUpdates(final boolean displayOnlyIfNewUpdates) {
    String updatesUrl = getPropertyValue("com.eteks.sweethome3d.updatesUrl", "updatesUrl");
    if (updatesUrl != null && updatesUrl.length() > 0) {
      final URL url;
      try {
        url = new URL(updatesUrl);
      } catch (MalformedURLException ex) {
        ex.printStackTrace();
        return;
      }
            
      final List<Library> libraries = this.preferences.getLibraries();
      final Long updatesMinimumDate = displayOnlyIfNewUpdates
          ? this.preferences.getUpdatesMinimumDate()
          : null;

      // Read updates from XML content in updatesUrl in a threaded task
      Callable<Void> checkUpdatesTask = new Callable<Void>() {
          public Void call() throws IOException, SAXException {
            final Map<Library, List<Update>> availableUpdates = readAvailableUpdates(url, libraries, updatesMinimumDate,
                displayOnlyIfNewUpdates ? 3000 : -1);
            getView().invokeLater(new Runnable () {
                public void run() {                  
                  if (availableUpdates.isEmpty()) {
                    if (!displayOnlyIfNewUpdates) {
                      getView().showMessage(preferences.getLocalizedString(HomeController.class, "noUpdateMessage"));
                    }
                  } else if (!getView().showUpdatesMessage(getUpdatesMessage(availableUpdates), !displayOnlyIfNewUpdates)) {
                    // Search the latest date among updates
                    long latestUpdateDate = Long.MIN_VALUE;
                    for (List<Update> libraryAvailableUpdates : availableUpdates.values()) {
                      for (Update update : libraryAvailableUpdates) {
                        latestUpdateDate = Math.max(latestUpdateDate, update.getDate().getTime());
                      }
                    }
                    preferences.setUpdatesMinimumDate(latestUpdateDate + 1);
                  }
                }
              });
            return null;
          }
        };
      ThreadedTaskController.ExceptionHandler exceptionHandler = 
          new ThreadedTaskController.ExceptionHandler() {
            public void handleException(Exception ex) {
              if (!displayOnlyIfNewUpdates && !(ex instanceof InterruptedIOException)) {
                if (ex instanceof IOException) {
                  getView().showError(preferences.getLocalizedString(HomeController.class, "checkUpdatesIOError", ex));
                } else if (ex instanceof SAXException) {
                  getView().showError(preferences.getLocalizedString(HomeController.class, "checkUpdatesXMLError", ex.getMessage()));
                } else {
                  ex.printStackTrace();
                }
              }
            }
          };
          
      ViewFactory dummyThreadedTaskViewFactory = new ViewFactoryAdapter() {
          @Override
          public ThreadedTaskView createThreadedTaskView(String taskMessage, UserPreferences preferences,
                                                         ThreadedTaskController controller) {
            // Return a dummy view that doesn't do anything
            return new ThreadedTaskView() {
              public void setTaskRunning(boolean taskRunning, View executingView) {
              }
              
              public void invokeLater(Runnable runnable) {
                getView().invokeLater(runnable);
              }
            };
          }
        };
      new ThreadedTaskController(checkUpdatesTask, 
          this.preferences.getLocalizedString(HomeController.class, "checkUpdatesMessage"), exceptionHandler, 
          this.preferences, displayOnlyIfNewUpdates 
            ? dummyThreadedTaskViewFactory
            : this.viewFactory).executeTask(getView());    
    }
  }

  /**
   * Returns the System property value of the given <code>propertyKey</code>, or the
   * the resource property value matching <code>resourceKey</code> or <code>null</code>
   * if none are defined.
   */
  private String getPropertyValue(String propertyKey, String resourceKey) {
    String propertyValue = System.getProperty(propertyKey);
    if (propertyValue != null) {
      return propertyValue;
    } else {
      try {
        return this.preferences.getLocalizedString(HomeController.class, resourceKey);
      } catch (IllegalArgumentException ex) {
        return null;
      }
    }
  }

  /**
   * Reads the available updates from the XML stream contained in the given <code>url</code>.
   * Caution : this method is called from a separate thread.
   */
  private Map<Library, List<Update>> readAvailableUpdates(URL url, List<Library> libraries, Long minDate, int timeout) throws IOException, SAXException {
    try {
      SAXParserFactory factory = SAXParserFactory.newInstance();
      factory.setValidating(false);
      SAXParser saxParser = factory.newSAXParser();
      UpdatesHandler updatesHandler = new UpdatesHandler(url);
      URLConnection connection = url.openConnection();
      if (timeout > 0) {
        connection.setConnectTimeout(timeout);
        connection.setReadTimeout(timeout);
      }
      saxParser.parse(connection.getInputStream(), updatesHandler);
      
      // Filter updates according to application version and libraries version
      Map<Library, List<Update>> availableUpdates = new LinkedHashMap<Library, List<Update>>();
      long now = System.currentTimeMillis();
      if (this.application != null) {
        String applicationId = this.application.getId();
        List<Update> applicationUpdates = getAvailableUpdates(updatesHandler.getUpdates(applicationId), 
            this.application.getVersion(), minDate, now);
        if (!applicationUpdates.isEmpty()) {
          availableUpdates.put(null, applicationUpdates);
        }
      }
      Set<String> updatedLibraryIds = new HashSet<String>();       
      for (Library library : libraries) {
        if (Thread.interrupted()) {
          throw new InterruptedIOException();
        }
        String libraryId = library.getId();
        if (libraryId != null 
            && !updatedLibraryIds.contains(libraryId)) { 
          List<Update> libraryUpdates = getAvailableUpdates(updatesHandler.getUpdates(libraryId), 
              library.getVersion(), minDate, now);
          if (!libraryUpdates.isEmpty()) {
            availableUpdates.put(library, libraryUpdates);
          }
          // Ignore older libraries with same ID
          updatedLibraryIds.add(libraryId);
        }
      }
      return availableUpdates;
    } catch (ParserConfigurationException ex) {
      throw new SAXException(ex);
    } catch (SAXException ex) {
      // If task was interrupted (see UpdatesHandler implementation), report the interruption 
      if (ex.getCause() instanceof InterruptedIOException) {
        throw (InterruptedIOException)ex.getCause();
      } else {
        throw ex;
      }
    }
  }
  
  /**
   * Returns the updates sublist which match the given <code>version</code>.
   * If no update has a date greater that <code>minDate</code>, an empty list is returned.
   * Caution : this method is called from a separate thread.
   */
  private List<Update> getAvailableUpdates(List<Update> updates, String version, Long minDate, long maxDate) {
    if (updates != null) {
      boolean recentUpdates = false;
      List<Update> availableUpdates = new ArrayList<Update>();      
      for (Update update : updates) {
        String minVersion = update.getMinVersion();
        String maxVersion = update.getMaxVersion();
        String operatingSystem = update.getOperatingSystem();
        if (OperatingSystem.compareVersions(version, update.getVersion()) < 0 
            && (minVersion == null || OperatingSystem.compareVersions(minVersion, version) <= 0) 
            && (maxVersion == null || OperatingSystem.compareVersions(version, maxVersion) < 0)
            && (operatingSystem == null || System.getProperty("os.name").matches(operatingSystem))) {
          Date date = update.getDate();
          if (date == null 
              || ((minDate == null || date.getTime() >= minDate) 
                  && date.getTime() < maxDate)) {
            availableUpdates.add(update);
            recentUpdates = true;
          }
        }
      }
      if (recentUpdates) {
        Collections.sort(availableUpdates, new Comparator<Update>() {
            public int compare(Update update1, Update update2) {
              return -OperatingSystem.compareVersions(update1.getVersion(), update2.getVersion());
            }
          });
        return availableUpdates;
      }
    }
    return Collections.emptyList();
  }

  /**
   * Returns the message for the given updates.
   */
  private String getUpdatesMessage(Map<Library, List<Update>> updates) {
    if (updates.isEmpty()) {
      return this.preferences.getLocalizedString(HomeController.class, "noUpdateMessage");
    } else {
      String message = "<html><head><style>" 
          + this.preferences.getLocalizedString(HomeController.class, "updatesMessageStyleSheet")
          + " .separator { margin: 0px;}</style></head><body>"
          + this.preferences.getLocalizedString(HomeController.class, "updatesMessageTitle");
      String applicationUpdateMessage = this.preferences.getLocalizedString(HomeController.class, "applicationUpdateMessage");
      String libraryUpdateMessage = this.preferences.getLocalizedString(HomeController.class, "libraryUpdateMessage");
      String sizeUpdateMessage = this.preferences.getLocalizedString(HomeController.class, "sizeUpdateMessage");
      String downloadUpdateMessage = this.preferences.getLocalizedString(HomeController.class, "downloadUpdateMessage");
      String updatesMessageSeparator = this.preferences.getLocalizedString(HomeController.class, "updatesMessageSeparator");
      for (Iterator<Map.Entry<Library, List<Update>>> it = updates.entrySet().iterator(); it.hasNext(); ) {
        Map.Entry<Library, List<Update>> updateEntry = it.next();
        Library library = updateEntry.getKey();
        if (library == null) {
          // Application itself
          if (this.application != null) {
            message += getApplicationOrLibraryUpdateMessage(updateEntry.getValue(), this.application.getName(),
                applicationUpdateMessage, sizeUpdateMessage, downloadUpdateMessage);
          }
        } else {
          String name = library.getName();
          if (name == null) {
            name = library.getDescription();
            if (name == null) {
              name = library.getLocation();
            }
          }
          message += getApplicationOrLibraryUpdateMessage(updateEntry.getValue(), name,
              libraryUpdateMessage, sizeUpdateMessage, downloadUpdateMessage);
        }
        if (it.hasNext()) {
          message += updatesMessageSeparator;
        }
      }
      
      message += "</body></html>";
      return message;
    }
  }

  /**
   * Returns the message for the update of the application or a library.
   */
  private String getApplicationOrLibraryUpdateMessage(List<Update> updates,
                                                      String applicationOrLibraryName,
                                                      String applicationOrLibraryUpdateMessage, 
                                                      String sizeUpdateMessage, 
                                                      String downloadUpdateMessage) {
    String message = "";
    boolean first = true;
    DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
    DecimalFormat megabyteSizeFormat = new DecimalFormat("#,##0.#");
    for (Update update : updates) {
      String size;
      if (update.getSize() != null) {
        // Format at MB format
        size = String.format(sizeUpdateMessage,
            megabyteSizeFormat.format(update.getSize() / (1024. * 1024.)));
      } else {
        size = "";
      }
      message += String.format(applicationOrLibraryUpdateMessage, 
          applicationOrLibraryName, update.getVersion(), dateFormat.format(update.getDate()), size);
      if (first) {
        first = false;
        URL downloadPage = update.getDownloadPage();
        if (downloadPage == null) {
          downloadPage = update.getDefaultDownloadPage();
        }
        if (downloadPage != null) {
          message += String.format(downloadUpdateMessage, downloadPage);
        }
      }
      String comment = update.getComment();
      if (comment == null) {
        comment = update.getDefaultComment();
      }
      if (comment != null) {
        message += "<p class='separator'/>";
        message += comment;
        message += "<p class='separator'/>";        
      }
    }
    return message;
  }

  /**
   * SAX handler used to parse updates XML files.
   * DTD used in updated files:<pre> 
   * &lt;!ELEMENT updates (update*)>
   * 
   * &lt;!ELEMENT update (downloadPage*, comment*)>
   * &lt;!ATTLIST update id CDATA #REQUIRED>
   * &lt;!ATTLIST update version CDATA #REQUIRED>
   * &lt;!ATTLIST update operatingSystem CDATA #IMPLIED> 
   * &lt;!ATTLIST update date CDATA #REQUIRED>
   * &lt;!ATTLIST update minVersion CDATA #IMPLIED>
   * &lt;!ATTLIST update maxVersion CDATA #IMPLIED> 
   * &lt;!ATTLIST update size CDATA #IMPLIED> 
   * &lt;!ATTLIST update inherits CDATA #IMPLIED>
   * 
   * &lt;!ELEMENT downloadPage EMPTY>
   * &lt;!ATTLIST downloadPage url CDATA #REQUIRED> 
   * &lt;!ATTLIST downloadPage lang CDATA #IMPLIED> 
   * 
   * &lt;!ELEMENT comment (#PCDATA)>
   * &lt;!ATTLIST comment lang CDATA #IMPLIED> 
   * </pre>
   * with <code>updates</code> as root element, 
   * <code>operatingSystem</code> an optional regular expression for the target OS,
   * <code>inherits</code> the id of an other <code>update</code> element with the same version,
   * <code>date</code> using <code>yyyy-MM-ddThh:mm:ss<code> or <code>yyyy-MM-dd</code> format 
   * at GMT and <code>comment</code> element possibly containing XHTML.
   */
  private class UpdatesHandler extends DefaultHandler {
    private final URL                       baseUrl;
    private final StringBuilder             comment = new StringBuilder();
    private final SimpleDateFormat          dateTimeFormat;
    private final SimpleDateFormat          dateFormat;
    private final Map<String, List<Update>> updates = new HashMap<String, List<Update>>();
    private Update                          update;
    private boolean                         inComment;
    private boolean                         inUpdate;
    private String                          language;

    public UpdatesHandler(URL baseUrl) {
      this.baseUrl = baseUrl;
      TimeZone gmtTimeZone = TimeZone.getTimeZone("GMT");
      this.dateTimeFormat = new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss");
      this.dateTimeFormat.setTimeZone(gmtTimeZone);
      this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
      this.dateFormat.setTimeZone(gmtTimeZone);
    }
    
    /**
     * Returns the update matching the given <code>id</code>.
     */
    private List<Update> getUpdates(String id) {
      return this.updates.get(id);
    }
    
    /**
     * Throws a <code>SAXException</code> exception initialized with a <code>InterruptedRecorderException</code> 
     * cause if current thread is interrupted. The interrupted status of the current thread 
     * is cleared when an exception is thrown.
     */
    private void checkCurrentThreadIsntInterrupted() throws SAXException {
      if (Thread.interrupted()) {
        throw new SAXException(new InterruptedIOException());
      }
    }
    
    @Override
    public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
      checkCurrentThreadIsntInterrupted();
      if (this.inComment) {
        // Reproduce comment content
        this.comment.append("<" + name);
        for (int i = 0; i < attributes.getLength(); i++) {
          this.comment.append(" " + attributes.getQName(i) + "=\"" + attributes.getValue(i) + "\"");
        }
        this.comment.append(">");
      } else if (this.inUpdate && "comment".equals(name)) {
        this.comment.setLength(0);
        this.language = attributes.getValue("lang");
        if (this.language == null || preferences.getLanguage().equals(this.language)) {
          this.inComment = true;
        }
      } else if (this.inUpdate && "downloadPage".equals(name)) {
        String url = attributes.getValue("url");
        if (url != null) {
          try {
            String language = attributes.getValue("lang");
            if (language == null) {
              this.update.setDefaultDownloadPage(new URL(this.baseUrl, url));
            } else if (preferences.getLanguage().equals(language)) {
              this.update.setDownloadPage(new URL(this.baseUrl, url));
            }
          } catch (MalformedURLException ex) {
            // Ignore bad URLs
          }
        }
      } else if (!this.inUpdate && "update".equals(name)) {
        String id = attributes.getValue("id");
        String version = attributes.getValue("version");
        if (id != null
            && version != null) {
          this.update = new Update(id, version);
          
          String inheritedUpdate = attributes.getValue("inherits");
          // If update inherits from an other update, search the update with the same id and version
          if (inheritedUpdate != null) {
            List<Update> updates = this.updates.get(inheritedUpdate);
            if (updates != null) {
              for (Update update : updates) {
                if (version.equals(update.getVersion())) {
                  this.update = update.clone();
                  this.update.setId(id);
                  break;
                }
              }
            }
          }
  
          String dateAttibute = attributes.getValue("date");
          if (dateAttibute != null) {
            try {
              this.update.setDate(this.dateTimeFormat.parse(dateAttibute));
            } catch (ParseException ex) {
              try {
                this.update.setDate(this.dateFormat.parse(dateAttibute));
              } catch (ParseException ex1) {
              }
            }
          }
          
          String minVersion = attributes.getValue("minVersion");
          if (minVersion != null) {
            this.update.setMinVersion(minVersion);
          }

          String maxVersion = attributes.getValue("maxVersion");
          if (maxVersion != null) {
            this.update.setMaxVersion(maxVersion);
          }
          
          String size = attributes.getValue("size");
          if (size != null) {
            try {
              this.update.setSize(new Long (size));
            } catch (NumberFormatException ex) { 
              // Ignore malformed number
            }
          }
          
          String operatingSystem = attributes.getValue("operatingSystem");
          if (operatingSystem != null) {
            this.update.setOperatingSystem(operatingSystem);
          }
          
          List<Update> updates = this.updates.get(id);
          if (updates == null) {
            updates = new ArrayList<Update>();
            this.updates.put(id, updates);
          }
          updates.add(this.update);
          this.inUpdate = true;
        }
      }
    }
    
    @Override
    public void characters(char [] ch, int start, int length) throws SAXException {
      checkCurrentThreadIsntInterrupted();
      if (this.inComment) {
        // Reproduce comment content
        this.comment.append(ch, start, length);
      }
    }
    
    @Override
    public void endElement(String uri, String localName, String name) throws SAXException {
      if (this.inComment) {
        if ("comment".equals(name)) {
          String comment = this.comment.toString().trim().replace('\n', ' ');
          if (comment.length() == 0) {
            comment = null;
          }
          if (this.language == null) {
            this.update.setDefaultComment(comment);
          } else {
            this.update.setComment(comment);
          }
          this.inComment = false;
        } else {
          // Reproduce comment content
          this.comment.append("</" + name + ">");
        }
      } else if (this.inUpdate && "update".equals(name)) {
        this.inUpdate = false;
      }
    }
  }
  
  /**
   * Update info.
   */
  private static class Update implements Cloneable {
    private String id;
    private final String version;
    private Date   date;
    private String minVersion;
    private String maxVersion;
    private Long   size;
    private String operatingSystem;
    private URL    defaultDownloadPage;
    private URL    downloadPage;
    private String defaultComment;
    private String comment;

    public Update(String id, String version) {
      this.id = id;
      this.version = version;
    }

    public String getId() {
      return this.id;
    }
    
    public void setId(String id) {
      this.id = id;
    }

    public String getVersion() {
      return this.version;
    }    

    public Date getDate() {
      return this.date;
    }

    public void setDate(Date date) {
      this.date = date;
    }

    public String getMinVersion() {
      return this.minVersion;
    }

    public void setMinVersion(String minVersion) {
      this.minVersion = minVersion;
    }

    public String getMaxVersion() {
      return this.maxVersion;
    }

    public void setMaxVersion(String maxVersion) {
      this.maxVersion = maxVersion;
    }

    public Long getSize() {
      return this.size;
    }
    
    public void setSize(Long size) {
      this.size = size;
    }
    
    public String getOperatingSystem() {
      return this.operatingSystem;
    }
    
    public void setOperatingSystem(String system) {
      this.operatingSystem = system;
    }

    public URL getDefaultDownloadPage() {
      return this.defaultDownloadPage;
    }

    public void setDefaultDownloadPage(URL defaultDownloadPage) {
      this.defaultDownloadPage = defaultDownloadPage;
    }

    public URL getDownloadPage() {
      return this.downloadPage;
    }

    public void setDownloadPage(URL downloadPage) {
      this.downloadPage = downloadPage;
    }

    public String getDefaultComment() {
      return this.defaultComment;
    }

    public void setDefaultComment(String defaultComment) {
      this.defaultComment = defaultComment;
    }

    public String getComment() {
      return this.comment;
    }

    public void setComment(String comment) {
      this.comment = comment;
    }
    
    @Override
    protected Update clone() {
      try {
        return (Update)super.clone();
      } catch (CloneNotSupportedException ex) {
        throw new InternalError();
      }
    }
  }
}
