/*
* Copyright 2003-2010 Tufts University  Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 * 
 * http://www.osedu.org/licenses/ECL-2.0
 * 
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

package tufts.vue;

import tufts.Util;
import tufts.DocDump;

import static tufts.vue.Resource.*;

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.lang.ref.*;
import java.net.URL;
import java.net.URI;
import java.net.URLConnection;
import java.io.*;
import java.awt.Image;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Transparency;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import javax.swing.ImageIcon;
import javax.imageio.*;
import javax.imageio.metadata.*;
import javax.imageio.event.*;
import javax.imageio.stream.*;

import javax.xml.xpath.*;
import org.w3c.dom.NodeList;

/**
 *
 * Handle the loading of images in background threads, making callbacks to deliver
 * results to multiple listeners that can be added at any time during the image fetch,
 * and caching (memory and disk) with a URI key, using a HashMap with SoftReference's
 * for the BufferedImage's so if we run low on memory they just drop out of the cache.
 *
 * @version $Revision: 1.85 $ / $Date: 2010-02-03 19:17:40 $ / $Author: mike $
 * @author Scott Fraize
 */
public class Images
{
    private static final org.apache.log4j.Logger Log = org.apache.log4j.Logger.getLogger(Images.class);

    private static final boolean ALLOW_HIGH_QUALITY_ICONS = true;

    private static volatile int LOW_MEMORY_COUNT = 0;

    // Setting DELAYED_ICONS to true allows maps with lots of images paint something for
    // each image much faster the first time they're loaded under plentiful memory
    // conditions, but cause horrible thrashing under low memory conditions, and it
    // would take even more complexity than we've already got to fix that, so for now
    // we're just going with immediately created icons.
    static final boolean DELAYED_ICONS = false;

    public static void setLowMemory(Object cause) {
        synchronized (Images.class) {
            if (LOW_MEMORY_COUNT == 0) {
                Log.info(Util.TERM_PURPLE + "entering low-memory conditions, cause=" + Util.tags(cause) + Util.TERM_CLEAR);
            } else {
                if (DEBUG.Enabled) Log.debug(Util.TERM_PURPLE + "setLowMemory " + LOW_MEMORY_COUNT + ": "
                                             + Util.TERM_CLEAR 
                                             + Util.tags(cause));
            }
            final boolean first = (LOW_MEMORY_COUNT == 0);
            LOW_MEMORY_COUNT++;
            ProcessingPool.shrinkIfPossible(first);
        }
        // we do this out side of the sync just in case, as
        // below will obtain it's own sync
        //TaskQueue.flushCachingRequests(); // needs testing

        // TODO: either allow the above flushing, or skip the tasks when popped off queue and force
        // callbacks to listeners with an error.  At this point, we're getting into issues of
        // coherency with ImageRep loading states, and it's getting so complex that this is the
        // point to stop and wait for a day when we can do a greenfield impl w/out all the
        // backward-compat Images API's, and just keeps ImageRef's themselves in the cache,
        // possibly even with their own prev/next task-queue pointers for fast jumping & shuffling.
        
    }

    public static int lowMemoryCount() {
        return LOW_MEMORY_COUNT;
    }

    public static boolean lowMemoryConditions() {
        return LOW_MEMORY_COUNT > 0;
    }
    
    public static VueAction ClearCacheAction = new VueAction("Empty Image Cache") {
            public void act() { RawCache.clear(); }
        };
    
    
    /**
     * Calls to Images.getImage must pass in a Listener to get results.
     * The first argument to all the callbacks is the original object
     * passed in to getImage as the imageSRC.
     */
    public interface Listener {

        /** If image is already cached, this will NOT be called -- is only called from an image loading thread.
         * If sourceSize is non-null, this is an icon, and that represents the full-size */
        void gotImageSize(Object imageSrc, int w, int h, long byteSize, int[] sourceSize);
        /** If byte-tracking is enabled on the input source, this will be called periodically during loading */
        void gotImageProgress(Object imageSrc, long bytesSoFar, float percentSoFar);
        /** Will be called immediately in same thread if image cached, later in different thread if not. */
        //void gotImage(Object imageSrc, Image image, int w, int h);
        void gotImage(Object imageSrc, Handle handle);
        /** If there is an exception or problem loading the image, this will be called */
        void gotImageError(Object imageSrc, String msg);

//         /** intended to ultimately replace all the below calls */
//         void gotImageData(Object key, Progress p);
        
    }


    private static final Object     LOAD_CACHE = "CACHE";
    private static final Object    LOAD_NORMAL = "PAINT";
    private static final Object LOAD_IMMEDIATE = "PRINT";

    /**
     * Fetch the given image with meta-data.  If it's cached, listener.gotImage is called back immediately
     * in the current thread.  If not, the image is fetched asynchronously, and the
     * callbacks are made later from a special image loading thread.
     *
     * @param imageSRC - anything that might be converted to an image: a Resource, File, URL, InputStream, etc.
     *
     * @param listener - the Images.Listener to callback.  If null, the result
     * of the call would be only to ensure the given image is cached.
     *
     * @return the Image if immediately available, null if the listener will be called back.
     **/
    
    public static Handle getImageHandle(Object imageSRC, Images.Listener listener)
    {
        return getImage(imageSRC, listener, LOAD_NORMAL);
    }
    
    public static Handle getImageImmediately(Object imageSRC, Images.Listener listener)
    {
        return getImage(imageSRC, listener, LOAD_IMMEDIATE);
    }
    
    public static Handle cacheImage(Object imageSRC, Images.Listener listener)
    {
        return getImage(imageSRC, listener, LOAD_CACHE);
    }

    public static Image getImage(Object imageSRC, Images.Listener listener)
    {
        return handleToImage(getImage(imageSRC, listener, LOAD_NORMAL));
    }

    
    /**
     * Fetch the given image.  If it's cached, listener.gotImage is called back immediately
     * in the current thread.  If not, the image is fetched asynchronously, and the
     * callbacks are made later from a special image loading thread.
     *
     * @param imageSRC - anything that might be converted to an image: a Resource, File, URL, InputStream, etc.
     *
     * @param listener - the Images.Listener to callback.  If null, the result
     * of the call would be only to ensure the given image is cached.
     *
     * @param immediate - if the requested content is not already loading,
     * it will be immediately loaded in the current thread.
     *
     * @return the Image if immediately available, null if the listener will be called back.
     **/
    private static Handle getImage(Object imageSRC, Images.Listener listener, Object when)
    {
        try {
            return getCachedOrCallbackOnLoad(imageSRC, listener, when);
        } catch (Throwable t) {
            if (DEBUG.IMAGE) tufts.Util.printStackTrace(t);
            if (listener != null)
                listener.gotImageError(imageSRC, t.toString());
        }
        return null;
    }
    
    
//     /** SYNCHRONOUSLY retrive an image from the given data source: e.g., a Resource, File, URL, InputStream, etc */
//     public static Image getImage(Object imageData)
//     {
//         try {
//             return getCachedOrCallbackOnLoad(imageData, null);
//         } catch (Throwable t) {
//             Log.error("getImage " + imageData, t);
//             return null;
//         }
//     }

    public static void loadDiskCache()
    {
        File dir = getCacheDirectory();
        if (dir == null)
            return;

        Log.debug("listing disk cache...");
        File[] files = dir.listFiles();
        Log.debug("listing disk cache: done; entries=" + files.length);
        
        synchronized (RawCache) {
            for (int i = 0; i < files.length; i++) {
                File file = files[i];
                String name = file.getName();
                if (name.charAt(0) == '.')
                    continue;
                //out("found cache file " + file);
                URI key = null;
                try {
                    key = cacheFileNameToKey(name);
                    if (DEBUG.IMAGE && DEBUG.META) out("made cache key: " + key);
                    if (key != null) {
                        RawCache.put(key, new CacheEntry(file));
                        //RefCache.put(key, file);
                   }
                } catch (Throwable t) {
                    Log.error("failed to load cache with [" + name + "]; key=" + key);
                }
            }
        }
    }
    
    /** @return the cache file for the given resource, or null if none exists */
    public static File findCacheFile(Resource r) {

        final ImageSource imageSRC = ImageSource.create(r);

        if (imageSRC.key != null) {
            final Object entry = RawCache.get(imageSRC.key);
            if (entry instanceof CacheEntry) {
                return ((CacheEntry)entry).file;
            } else {
                Log.warn("Cache is loading, no cache file yet for " + r);
            }
        }
        Log.warn("Failed to find cache file for " + r);
        return null;
    }
    

    // todo: really, ImageCacheEntry v.s. Loader cache entries, tho they don't
    // currently have a common super-class.
    private static class CacheEntry {
        private final Reference<Image> ref;
        private final Map<String,?> data;
        private final File file;
        // Loader loader;
        // todo: add loader here so we can always have CacheEntry's in the cache, and
        // so file is information always available, tho that could be extracted from the
        // Loader ImageSource? No -- that could be any file, not just a disk-cache entry.
        // This new complexity arising out of having icons in the cache -- we may just
        // want a separate icon cache.

        /** image should only be null for startup init with existing cache files */
        CacheEntry(Image image, File cacheFile, Map<String,?> props)
        {
            if (image == null && cacheFile == null)
                throw new IllegalArgumentException("CacheEntry: at least one of image or file must be non null");
            if (image != null)
                this.ref = new SoftReference(image);
            else
                this.ref = null;
            this.file = cacheFile;
            this.data = props;
            if (DEBUG.IMAGE) out("new " + this);
        }

        CacheEntry(Image image, File cacheFile) {
            this(image, cacheFile, Collections.EMPTY_MAP);
        }
        
        CacheEntry(Handle handle, File cacheFile) {
            this(handle.image, cacheFile, handle.data);
        }
        
        // for startup disk entries: note that props can't change (is final), so this
        // entry must be replaced once the image is loaded.
        CacheEntry(File cacheFile) {
            this(null, cacheFile, Collections.EMPTY_MAP); 
        }
        

        boolean isPreloadedDiskEntry() {
            return ref == null;
        }

        Image getCachedImage() {

            // if don't even have a ref, this was for an init-time persistent cache file
            if (ref == null)
                return null;

            Image image = ref.get();
            // will be null if was cleared
            if (image == null) {
                if (DEBUG.Enabled && file != null) out("GC'd: " + file);
                return null;
            } else
                return image;
        }

        Handle getHandle() {
            //return Handle.create(getCachedImage(), data);
            Image i = getCachedImage();
            if (i != null)
                return new Handle(i, data);
            else
                return null;
        }

        File getFile() {
            return file;
        }

        void clear() {
            if (ref != null)
                ref.clear();
        }

        public String toString() {
            return "CacheEntry[" + tag(getCachedImage()) + "; file=" + file + "]";
        }
    }

    private static final CacheMap RawCache = new CacheMap();
    //private static final NewCache RefCache = new NewCache();
    //private static final NewCache<URI,ImageRef> RefCache = new NewCache();
    
//     private static class NewCache {

//         private final Map<URI,ImageRef> map = new ConcurrentHashMap();
        
//         // can ultimately refactor access code to make all transactions explicit
//         // so don't need to be synchronized here and can rely on ConcurrentHashMap
//         // for simple ops, but leaving synchronized for now as our existing
//         // cache transactions may depend on it.
    
//         synchronized ImageRef get(URI key) {
//             return map.get(key);
//         }
        
//         synchronized boolean containsKey(URI key) {
//             return map.containsKey(key);
//         }
        
//         synchronized ImageRef put(URI key, ImageRef value) {
//             return map.put(key, value);
//         }
        
//         synchronized ImageRef put(URI key, File file) {
//             //return map.put(key, value);
//             //return map.put(key, ImageRef.create(file));
//             return null;
//         }
        
//         synchronized ImageRef remove(URI key) {
//             return map.remove(key);
//         }
        
//         synchronized void clear() {
//             throw new UnsupportedOperationException("unimplemented");
//         }
//     }
        //===================================================================================================
        // This would all be much simpler if the cache contained ImageRef's.  They could contain
        // whatever reps are available, and be the place where the full size was always recorded
        // (possibly in the size of it's full-rep, left in an UNLOADED state, but with size
        // recorded.  In that case, the _full rep may be able to be made final).  The only
        // high-level extra complexity would be that ImageRef's would then need to keep a list of
        // listeners (LWImage's), which would also want to be flushed when LWImage's leave the
        // model (and re-add if they re-join the model).  Also tho, w/out extra work, any update to
        // an ImageRef (e.g., full-rep-has-arrived), would trigger repaints to all LWImage's, even
        // if their happy displaying just an icon image.  That's probably a good trade-off to make
        // tho -- it's not often that you have a map with the same image repeated anyway (well, may
        // presentations when slide icons are turned on), and it's extra repaints that will only
        // happen once -- when the full rep fully arrives.
        //
        // Also, either all existing API's would need to change to work w/ImageRef's, or we'd need
        // to keep things backward compat.  That probably wouldn't be that much of an issue.
        // Okay, only two places use the image api: PreviewPane & ResourceIcon.  And only PreviewPane
        // makes any use of the tracking the original update source (in case we've switched to a new
        // preview after results of an old preview finally arrived).
        //
        // The big change is that the cache would now contain entries for multiple reps (full + icons).
        // Maybe this isn't an issue at all tho?  Currently, this lets full reps and icon reps
        // exist separately in the cache, and for them to be GC's separately.  If the cache contained
        // ImageRef's, nobody would actually request icon reps -- ImageRef is the only thing that does
        // that.  Normal requestors only request the original rep -- it's just sometimes they'll get
        // the icon rep instead.
        //===================================================================================================
        

    /*
     * Not all HashMap methods covered: only safe to use
     * the ones explicity implemented here.
     */
    private static class CacheMap extends HashMap {

        public synchronized Object get(Object key) {
            return super.get(key);
        }
        
        public synchronized boolean containsKey(Object key) {
            return super.containsKey(key);
        }
        
        public synchronized Object put(Object key, Object value) {
            return super.put(key, value);
        }

        public synchronized Object remove(Object key) {
            return super.remove(key);
        }
        

        // for now, only clears memory cache
        public synchronized void clear() {
            final Iterator i = values().iterator();
            while (i.hasNext()) {
                Object entry = i.next();

                // may be a Loader: todo: may want to kill thread if it is Especially:
                // if we go off line, Loaders created immediately after that (or during)
                // tend to hang forever.  Loaders created once the OS knows we're
                // offline will usually fail immediately with "no route to host", but
                // even after going back online, and other images load, the originally
                // hung Loader's won't die...  So at least an image-cache should kill
                // them.
                
                if (entry instanceof CacheEntry) {
                    CacheEntry ce = (CacheEntry) entry;
                    ce.clear();
                    if (ce.getFile() == null)
                        i.remove();
                } else {
                    // Interrupt may not be good enough: if blocked on non-async IO
                    // (non-channel IO, e.g., "regular"), this can have no
                    // effect.  Turns out using stop doesn't help even in this
                    // case.
                    if (entry instanceof LoadThread) {
                        Log.info("STOPPING THREAD ENTRY " + entry);
                        ((LoadThread)entry).stop();
                    } else { //if (entry instanceof Loader) {
                        Log.warn("LEAVING TO RUN OUT TASK ENTRY " + entry);
                        // if it was a Future, we could attempt to de-queue it
                    }
                    //((LoadThread)entry).stop();
                    //((Loader)entry).interrupt();
                }
            }

            //super.clear();
        }
        
    }

    /**
     * flush any cache BufferedImages we have for the give file: future requests
     * will force the image data to be reloaded from the file (useful if we know
     * the file has changed on disk).
     */
    public static void flushCache(File file) {
        final URI key = makeKey(file);

        flushEntry(key, "image");
        
        final URI iconKey = ImageSource.makeIconKey(key, 128);
        // TODO: sync icon key 128 size with size in ImageRef, or better yet, search
        // cache for all cache keys of any size (tho we only have one size for now)
        
        if (flushEntry(iconKey, "ic128")) {
            File iconFile = new File(getCacheDirectory(), keyToCacheFileName(iconKey));
            Log.info("looking for cache file " + iconFile);
            if (iconFile.exists()) {
                Log.info("   deleting cache file " + iconFile);
                iconFile.delete();
            }
        }

    }

    private static boolean flushEntry(Object key, String debug) {
        final Object entry = RawCache.remove(key);
        if (entry != null) {
            Log.info(Util.TERM_RED + "flushed cache " + debug + " entry: " + key + "; " + entry + Util.TERM_CLEAR);
        } else {
            Log.info("failed to find cache entry for key: " + Util.tags(key));
        }
        return entry != null;
    }

//     private static URI makeKey(URL u) {
//         try {

//             if ("file".equals(u.getProtocol())) {

//                 return Resource.makeURI(u);
                
//             } else {
                
//                 return new URI(u.getProtocol(),
//                                u.getUserInfo(),
//                                u.getHost(),
//                                u.getPort(),
//                                //u.getAuthority(),
//                                u.getPath(),
//                                u.getQuery(),
//                                u.getRef()).normalize();
                
//             }
            
//         } catch (Throwable t) {
//             Util.printStackTrace(t, "can't make URI cache key from URL " + u);
//         }
//         return null;
//     }

    static URI makeKey(File file) {
        try {
            return file.toURI().normalize();
        } catch (Throwable t) {
            Util.printStackTrace(t, "can't make URI cache key from file " + file);
        }
        return null;
    }

    static String keyToCacheFileName(URI key)
    //throws java.io.UnsupportedEncodingException
    {
        try {
            //return key.toASCIIString();
            return java.net.URLEncoder.encode(key.toString(), "UTF-8");
        } catch (UnsupportedEncodingException e) {
            Log.error("transforming key to cache-file name: " + key, e);
        }
        return null;
    }
    
    private static URI cacheFileNameToKey(String name) 
    {
        try {
            return new URI(java.net.URLDecoder.decode(name, "UTF-8"));
            //return new URL(java.net.URLDecoder.decode(name, "UTF-8"));
        } catch (Throwable t) {
            if (DEBUG.Enabled) tufts.Util.printStackTrace(t);
            return null;
        }
    }

    /**
     * Using a relay system, as opposed to say a list of listeners maintained by the
     * Loader, allows the image loading code to not care if there is a single listener
     * or multiple listeners, which is handy in the case where the result is cached and
     * we don't even create a loader: we just callback the listener immediately in the
     * same thread.  But when a Loader is created, it can create ListenerRelay's to
     * relay results down the chain, starting with it's special LoaderRelayer to relay
     * partial results to listeners added in the middle of an image fetch, and again,
     * the image loading code doesn't need to know about this: it just has a single
     * listener.
     *
     * Performance-wise, there is rarely ever more than a single relayer object
     * created (covering two listeners for the same image load).
     *
     * This is also a handy place for diagnostics.
     */
    private static class ListenerRelay implements Listener {
        protected final Listener head;
        protected Listener tail;
        
        ListenerRelay(Listener l0, Listener l1) {
            // if head is null, nobody is listening at the start, but listeners may be added later to tail
            this.head = l0;
            this.tail = l1;
        }
        ListenerRelay(Listener l0) {
            this(l0, null);
        }

//         public void gotImageUpdate(Object key, Progress p) {
//             if (head != null) relayUpdate(head, key, p);
//             if (tail != null) relayUpdate(tail, key, p);
//         }
//         protected final void relayUpdate(Listener l, Object key, Progress p) {
//             try {
//                 if (DEBUG.IMAGE && l instanceof ListenerRelay == false)
//                     out("relay UPDATE of " + key + " to " + tag(l));
//                 l.gotImageUpdate(key, p);
//             } catch (Throwable t) {
//                 Log.error("relaying UPDATE " + key + " to " + Util.tags(l), t);
//             }
//         }
        
        public void gotImageSize(Object src, int w, int h, long bytes, int[] sourceSize) {
            relaySize(head, src, w, h, bytes, sourceSize);
            relaySize(tail, src, w, h, bytes, sourceSize);
        }
        public void gotImageProgress(Object src, long bytes, float pct) {
            relayProgress(head, src, bytes, pct);
            relayProgress(tail, src, bytes, pct);
        }
        public void gotImage(Object src, Handle handle) {
            relayImage(head, src, handle);
            relayImage(tail, src, handle);
        }
        public void gotImageError(Object src, String msg) {
            relayError(head, src, msg);
            relayError(tail, src, msg);
        }

        protected final void relaySize(Listener l, Object src, int w, int h, long bytes, int[] sourceSize) {
            if (l != null) {
                try {
                    if (DEBUG.IMAGE && l instanceof ListenerRelay == false)
                        out(Util.TERM_CYAN + "relay SIZE " + w + "x" + h + " ss=" + Util.tags(sourceSize) + " to " + tag(l) + Util.TERM_CLEAR);
                    l.gotImageSize(src, w, h, bytes, sourceSize);
                } catch (Throwable t) {
                    Log.error("relaying size to " + Util.tags(l), t);
                }
            }
        }
        protected final void relayProgress(Listener l, Object src, long bytes, float pct) {
            if (l != null) {
                try {
                    if (DEBUG.IMAGE && DEBUG.META && l instanceof ListenerRelay == false)
                        out(String.format("relay PROGRESS %.2f %5d to %s", pct, bytes, tag(l)));
                    l.gotImageProgress(src, bytes, pct);
                } catch (Throwable t) {
                    Log.error("relaying progress to " + Util.tags(l), t);
                }
            }
        }
        protected final void relayImage(Listener l, Object src, Handle handle) {
            if (l != null) {
                try {
                    if (DEBUG.IMAGE && l instanceof ListenerRelay == false)
                        out(Util.TERM_CYAN + "relay IMAGE to " + tag(l) + Util.TERM_CLEAR);
                    l.gotImage(src, handle);
                } catch (Throwable t) {
                    Log.error("relaying image to " + Util.tags(l), t);
                }
            }
        }
        protected final void relayError(Listener l, Object src, String msg) {
            if (l != null) {
                try {
                    if (DEBUG.IMAGE && l instanceof ListenerRelay == false)
                        out("relay ERROR to " + tag(head) + "; is=" + src);
                    l.gotImageError(src, msg);
                } catch (Throwable t) {
                    Log.error("relaying error to " + Util.tags(l), t);
                }
            }
        }

        boolean hasListener(Listener l) {
            if (head == l || tail == l)
                return true;
            else if (tail instanceof ListenerRelay)
                return ((ListenerRelay)tail).hasListener(l);
            else
                return false;
        }

        protected boolean addListener(Listener newListener) {
            
            synchronized (this) {
                if (hasListener(newListener)) {
                    if (DEBUG.IMAGE) out(getClass().getSimpleName() +
                                         " redundant listener: " + tag(newListener));
                    return false;
                }
                if (tail == null)  {
                    this.tail = newListener;
                } else {
                    this.tail = new ListenerRelay(tail, newListener);
                }
                return true;
            }

            // Note: WE CAN DEADLOCK if deliverPartialResults is in the sync.  E.g. --
            // trying moving around raw images as they're loading / icon generating.
            
            // TODO: this really should be in the sync, as well as all the above
            // Listener API methods -- normally we'll be in the AWT thread, and if a
            // loader modifies the partial results while this call is being made, they
            // may be incoherent, and not all partial data may delivered.  The lock is
            // inherently dangerous tho, in that anything may generally happen during
            // client code callbacks, including calls back into this API.
            
            //deliverPartialResults(newListener);
        }
    }

    /**
     * Track what's been delivered, to send to listeners that are added when
     * partial results have already been delivered.
     */
    
    // note: would be simpler to have a single ImageResult/ImageProgress object that
    // slowly accumulates it's results, and is reported to a single gotImageData call,
    // with an added event type argument (size/progress/image/icon/error) An ImageRep
    // might be tempting to serve this purpose, but that doesn't really make sense: the
    // ImageRep doesn't need to know things like byteSize or bytesSoFar.  This Progress
    // class is an an attempt to that, tho we'd need to change all the Listener API
    // impls to make use of it -- cleanup for another day.

    /*public*/ static class Progress {

//         public static final String SIZE = "IMG_SIZE";
//         public static final String SOURCE_SIZE = "IMG_SOURCE_SIZE";
//         public static final String PROGRESS = "IMG_BYTE_PROGRESS";
//         public static final String IMAGE = "IMG_RENDERABLE";
//         public static final String ERROR = "IMG_ERROR";

        protected final ImageSource imageSRC;
        
        protected Handle handle;
        protected int width = -1;
        protected int height = -1;
        protected int[] sourceSize;
        protected long byteSize;
        protected long bytesSoFar;
        protected String errorMsg;

        private Progress(ImageSource is) {
            imageSRC = is;
        }
        
        public ImageSource source() { return imageSRC; }
        public Image image() { return handle == null ? null : handle.image; }
        public int width() { return width; }
        public int height() { return height; }
        public long byteSize() { return byteSize; }
        public long bytesSoFar() { return bytesSoFar; }
        public String errorMessage() { return errorMsg; }
    }
    
    private static final class CachingRelayer extends Progress implements Listener
    {
        final ListenerRelay chain;
        
        CachingRelayer(ImageSource is, Listener firstListener) {
            //super(firstListener, null);
            super(is);
            chain = new ListenerRelay(firstListener);
        }

//         public void gotImageData(Object key, Progress p) {
//             chain.gotImageUpdate(key, p);
//         }
        
        public synchronized void gotImageSize(Object imageSrc, int w, int h, long byteSize, int[] sourceSize) {
            this.width = w;
            this.height = h;
            this.byteSize = byteSize;
            if (sourceSize != null)
                this.sourceSize = sourceSize;
            chain.gotImageSize(imageSrc, w, h, byteSize, sourceSize);
        }

        public synchronized void gotImageProgress(Object imageSrc, long bytesSoFar, float _p) {
            this.bytesSoFar = bytesSoFar;
            // incoming percent is empty -- we fill it here
            // Note that the underlying raw stream may send us byte progress reports before
            // the image size is known.  This is a bug -- the byteSize is known
            // before this and should be recored here.  The # of bytes read before
            // the image size is known is usually negligable tho.  For now we just don't report
            // the progress until we have a byteSize.
            if (byteSize > 0)
                chain.gotImageProgress(imageSrc, bytesSoFar, percentProgress());
        }
        
        public synchronized void gotImage(Object imageSrc, Handle handle) {
            //this.image = image;
            this.handle = handle;
            chain.gotImage(imageSrc, handle);

        }
        
        public synchronized void gotImageError(Object imageSrc, String msg) {
            this.errorMsg = msg;
            chain.gotImageError(imageSrc, msg);

        }

        private float percentProgress() {
//             // note: bytes can arrive from the raw stream before the im
//             final float x = (float) bytesSoFar / (float) byteSize;
//             Log.debug("bytesSoFar " + bytesSoFar + " / bytes " + byteSize + " = " + x);
            if (byteSize > 0)
                return (float) bytesSoFar / (float) byteSize;
            else
                return 0;
        }

        void addListener(Listener newListener)
        {
            if (chain.addListener(newListener))
                deliverPartialResults(newListener);
        }

        void flushForGC() {
            //chain.imageSRC = null; // is now final
            //image = null; // this is the most important
            // chain = null; // is final
            handle = null;
        }
        
        /**
         * Deliver any results we've already got.  It's possible for this to happen
         * even after we have all our results, if the image completed between the
         * time we found the Loader in the cache, and the time the requestor was
         * added as a listener.
         */
        private void deliverPartialResults(Listener l)
        {
            if (DEBUG.IMAGE) out("DELIVERING PARTIAL RESULTS TO: " + tag(l));
            
            if (width > 0)
                chain.relaySize(l, imageSRC.original, width, height, byteSize, sourceSize);

            if (bytesSoFar > 0)
                chain.relayProgress(l, imageSRC.original, bytesSoFar, percentProgress());

            if (handle != null)
                chain.relayImage(l, imageSRC.original, handle);

            if (errorMsg != null) {
                if (handle != null)
                    Log.warn("had both image and error: " + errorMsg + "; for " + l);
                chain.relayError(l, imageSRC.original, errorMsg);
            }


            if (DEBUG.IMAGE) out(" DELIVERED PARTIAL RESULTS TO: " + tag(l));
            
        }

    }
    
//     private static Image getCachedOrCallbackOnLoad(Object imageSource, Images.Listener listener)
//         throws java.io.IOException, java.lang.InterruptedException
//     {
//         return getCachedOrCallbackOnLoad(imageSource, listener, false);
//     }

    /**
     * @return Image if cached or listener is null, otherwise makes callbacks to the listener from
     * a new thread.  Note: specifying both a listener, and immediate=true is untested.
     */
    private static Handle getCachedOrCallbackOnLoad
        (final Object imageSource,
         final Images.Listener listener,
         final Object when)
        throws java.io.IOException, java.lang.InterruptedException
    {
        if (imageSource instanceof Image) {
            if (DEBUG.Enabled) Log.info("image source was an instance of Image", new Throwable(Util.tags(imageSource)));
            final Image image = (Image) imageSource;
            final int w, h;
            if (image instanceof BufferedImage) {
                BufferedImage bi = (BufferedImage) imageSource;
                w = bi.getWidth();
                h = bi.getHeight();
            } else {
                w = image.getWidth(null);
                h = image.getHeight(null);
            }

            final Handle handle = new Handle(image);
            
            if (listener != null)
                listener.gotImage(imageSource, // same as image
                                  handle);

            return handle;
        }
        
        final ImageSource imageSRC = ImageSource.create(imageSource);

        //if (DEBUG.IMAGE) System.out.println("-------------------------------------------------------");

        //Log.debug("fetching image source " + imageSRC + " for " + tag(listener));
        if (DEBUG.IMAGE||DEBUG.WORK) Log.debug("fetching for "
                                     + Util.tags(when)
                                     + " to listener " + Util.tag(listener) + "; " + imageSRC);

        final Object cacheEntry = getCachedOrKickLoad(imageSRC, listener, when);

        // Possibly: if nothing was found in the cache and an ICON for the
        // given source exists in the cache, return that.  Either that
        // or change all Images callers to request an ImageRef, which
        // will handle that for us.
        
        if (cacheEntry == LOADER_JUST_STARTED) {
            // if this is the START of a new load, the caller has already been attached
            // as a listener, and the results will be coming via callback.
            return null;
        }
            
        Handle handle = null;
        
        if (cacheEntry instanceof Loader) {

            // Another request has already put a Loader into the cache -- have
            // the listener observe the existing loader.
            
            final Loader loader = (Loader) cacheEntry;

            final Image loaderImage = loader.getLoaderImage();

            if (loaderImage != null) {
                // This can happen if a loader has completed, but hasn't left the cache yet.  We
                // can just return the image immediately w/out delivering partial-result callbacks
                // (or in this case, it would be full-result callbacks).  We're ignoring any sync
                // issues on the call to loader.getImage(), because even if we see null when it's
                // really there, the results will still be delivered by our fully synced partial
                // results delivery, and we don't need any more sync issues to test.
                return new Handle(loaderImage);
            }

            if (when == LOAD_NORMAL)
                loader.raisePriority();
            
            // TODO: if this is a "regular priority" request, and we find a loader in the cache
            // that has NOT already been started, then the task queue(s) may need modification.
            // If the existing task is in the caching queue, it needs to be moved (to the
            // front) of the regular loading queue.  If the existing task is in the loading
            // queue, it needs to be moved (LIFO style) to the front of the loading queue.

            if (listener != null) {
                //if (DEBUG.IMAGE) out("Adding us as listener to existing Loader");
                loader.addListener(listener);
                if (when != LOAD_IMMEDIATE) {
                    // normal case: called has been added as a listener to an existing
                    // load -- just return.
                    return null;
                }
            }

            // We had no listener, so we can only run synchronous & wait on existing loader thread
            // to die: We can't have a cache-lock when we do this.
                
            // Note: this can be dangerous -- multiple non-listener requests for the same
            // loading content will quickly have all image processing threads hung waiting on
            // the same load -- do we still need this?  Generally speaking, image requests
            // w/out listeners are not safe.  For now, we'll only allow this if this was an
            // immediate requrest.

            // Note: our print code (ImageRef rendering in a print-quality DrawContext) makes
            // use of non-listener requests regularly to ensure full-resolution image data is
            // loaded before rendering, but each request is going to come in via a single
            // thread, so we shouldn't have to worry about contention issues -- however,
            // multiple print jobs running at the same time in different threads could cause a
            // problem.  Actually, no -- if an image ALREADY has an active loader, and then we
            // get a print request, we will need to take advantage of the join below.

            if (when != LOAD_IMMEDIATE) {
                // No listener, no cache entry, no result.  Caller simply
                // gets null and doesn't know why, even tho the image
                // may now be loading.
                Log.warn("no listener for non-cached content: caller in the dark as to the presence of image content: " + imageSRC);
                return null;
            }

            Log.info("Joining " + tag(loader) + "...");
            loader.join();
            Log.info("Join of " + tag(loader) + " completed, cache has filled.");
                
            // Note we get here only in one rare case: there was an entry in the
            // cache that was already loading on another thread, and somebody new
            // requested the image that did NOT have a listener, so we joined the
            // existing thread and waited for it to finish (with no listener, we
            // have to run synchronous).
                
            // So now that we've waited, we should be guaranteed to have a full
            // Image result in the cache at this point.
                
            // Note: theoretically, the GC could have cleared our SoftReference
            // betwen loading the cache and now.  We can't lock the cache while
            // we're waiting on the join tho.
                
            //                 cachedImage = ((CacheEntry)RawCache.get(imageSRC.key)).getCachedImage();
            //                 if (cachedImage == null)
            //                     Log.warn("Zealous GC: image tossed immediately " + imageSRC);
                
            handle = ((CacheEntry)RawCache.get(imageSRC.key)).getHandle();
            if (handle == null)
                Log.warn("Zealous GC: image tossed immediately " + imageSRC);
        }
        else if (cacheEntry instanceof Handle) {
            handle = (Handle) cacheEntry;
        }
        else if (cacheEntry instanceof Image) {
            Log.warn("DEPRECATED USE OF CACHE ENTRY -- USE HANDLE; " + imageSRC, new Throwable("HERE"));
            //cachedImage = (Image) cacheEntry;
            handle = new Handle((Image) cacheEntry);
        } else {
            if (DEBUG.IMAGE)
                Log.warn("unhandled fetch result: " + Util.tags(cacheEntry), new Throwable("HERE"));
            else
                Log.warn("unhandled fetch result: " + Util.tags(cacheEntry));
            // if we have no listener, it's reasonable for the getCachedOrKickLoad to return null,
            // tho wouldn't it be better just to return one anyway, and allow quiet async caching?
            // later requests could still hook up to the loader
        }

        if (handle != null && listener != null) {
            // THE IMAGE WAS IN THE CACHE: immediately callback the listener with the result
//             if (handle.data.size() > 0)
//                 listener.gotImageUpdate(imageSRC.original, handle.data);
            listener.gotImage(imageSRC.original, handle);
        }
//         if (cachedImage != null) {
//             if (listener != null) {
//                 // THE IMAGE WAS IN THE CACHE: immediately callback the listener with the result
//                 listener.gotImage(imageSRC.original,
//                                   cachedImage,
//                                   null);

//             }
//             //return cachedImage;
//         }

        return handle;
        //return cachedImage;
        
// [no longer needed: request can be immediate]        
//         // We had no image, and no Loader was started: this should
//         // only happen if there was no listener for the Loader, tho we
//         // allow the sync load to go ahead just in case.  (Could get
//         // here due to an over-zealous GC).

//         if (listener != null)
//             Log.warn("had a listener, but no Loader created: backup synchronous loading for " + imageSRC);

//         if (DEBUG.IMAGE) out("synchronous load of " + imageSRC);
        
//         // load the image and don't return until we have it
//         return loadImageAndCache(imageSRC, listener);
    }
    

    private static final Object LOADER_JUST_STARTED = "<image-loader-created>";
    
    
    /**
     * Although ThreadPoolExecutors have an API to allow resizing, the resize does not
     * appear to take effect until all tasks are completed and the pool has has run to
     * idle.  This class wraps an ExecutorService (a ThreadPoolExecutor), and allows it
     * to be immediately shut down and all of it's tasks transferred to a new, smaller
     * pool at any time.  This needs to happen ASAP if we start getting
     * OutOfMemoryError's.
     */
    private static class ImmediatelyReducablePool implements Runnable {
        
        private static final org.apache.log4j.Logger Log = org.apache.log4j.Logger.getLogger(ImmediatelyReducablePool.class);

        private Thread _shutdownThread;
        private ThreadPoolExecutor _pool;
        private ExecutorService _poolInShutdown;
        //private List<Runnable> _deferredTasks;
        private int _nextSmallerPoolSize;
            
        ImmediatelyReducablePool(int startSize) {
            if (startSize < 1)
                startSize = 1;
            _pool = createThreadPool(startSize);
            if (startSize > 1) {
                //_nextSmallerPoolSize = startSize / 2;
                // for now, any EOM will ramp us straight down to a single processing thread
                _nextSmallerPoolSize = 1;
            } else {
                _nextSmallerPoolSize = 0;
            }
        }
        
        public void run() {
            Log.info("reducer started");
            ExecutorService oldPool = null;
            for (;;) {
                synchronized (this) {
                    if (oldPool == _poolInShutdown)
                        Log.error("should never happen: re-waiting on the same pool " + oldPool);
                    oldPool = _poolInShutdown;
                }
                //---------------------------------
                waitForTermination(oldPool);
                //---------------------------------
                synchronized (this) {
                    _poolInShutdown = null;
                    createAndLoadNewPool(_nextSmallerPoolSize);
                    Log.info(Util.TERM_YELLOW + "pool resized to " + _nextSmallerPoolSize + Util.TERM_CLEAR);
                    if (_nextSmallerPoolSize <= 1) {
                        _nextSmallerPoolSize = 0;
                        _shutdownThread = null; // allow GC
                        //_deferredTasks = null; // allow GC
                        break; // no more resizes possible
                    } else {
                        reduceNextPoolSize();
                        Log.info("next pool size: " + _nextSmallerPoolSize);
                        try {
                            Log.info("resizer sleeping...");
                            wait();
                            Log.info("resizer woke");
                        } catch (InterruptedException e) {
                            Log.error("interrupted " + this, e);
                        }
                    }
                }
            }
            Log.info("resizer terminating, pool at size = 1, no further shrinkages possible");
        }

        private void reduceNextPoolSize() {
            if (_nextSmallerPoolSize > 1)
                _nextSmallerPoolSize /= 2;
            else
                _nextSmallerPoolSize = 1;
        }

        private synchronized void forkAndWaitForTermination(ExecutorService pool) {
            _poolInShutdown = pool;
            if (_shutdownThread == null) {
                // if we never run out of memory, we'll never need to start this thread
                _shutdownThread = new Thread(this, "PoolReducer");
                _shutdownThread.start();
            } else {
                notify();
            }
        }
        
        private void waitForTermination(final ExecutorService poolInShutdown) {
            Log.info("awaitTermination...");
            try {
                if (poolInShutdown.awaitTermination(60L, TimeUnit.SECONDS)) {
                    Log.info("pool terminated gracefully: " + poolInShutdown);
                } else {
                    Log.warn("pool shutdown timed out: " + poolInShutdown);
                }
            } catch (InterruptedException e) {
                Log.error("awaiting termination", e);
            }
        }
        
        private synchronized void createAndLoadNewPool(int size) {
            if (size < 1) {
                Log.error("createAndLoadNewPool, bad size " + size);
                size = 1;
            }
            _pool = createThreadPool(size);

//             loadTasks(_deferredTasks);
//             _deferredTasks = null;
        }

        private synchronized void loadTasks(Collection<Runnable> tasks) {

            Log.info("RESUBMIT: " + Util.tags(tasks));
            for (Runnable r : tasks) {
                //_pool.submit(r); // java 1.6 impl
                _pool.execute(r); // java 1.5 impl
            }
        }

        private synchronized void addTask(Task r) {
            if (_pool == null) {
                Log.info("deferred " + r);
                //_deferredTasks.add(r);
                TaskQueue.queueTask(r);
            } else {
                // _pool.submit(r); // java 1.6 impl -- (note: creates FutureTask's internally)
                _pool.execute(r); // java 1.5 impl
            }
        }

        /** re-load the queue as the priority mechanism has changed -- any IconTasks need to be sorted to the front */
        private synchronized void resortQueue() {
            Log.info("skipping queue resort: self-managed");
//             final BlockingQueue q = _pool.getQueue();

//             if (q.size() > 1) {
//                 Log.info("resorting queue " + Util.tags(q));
//                 final Collection<Runnable> qlist = new ArrayList(q.size());
//                 q.drainTo(qlist);
//                 loadTasks(qlist);
//             } else {
//                 Log.info("queue doesn't need re-sorting: " + Util.tags(q));
//             }
        }
        
        public synchronized void shrinkIfPossible(boolean firstLowMemory)
        {
            if (_nextSmallerPoolSize < 1) {
                if (DELAYED_ICONS && firstLowMemory)
                    resortQueue();
                return;
            }

            if (_pool != null) {
                // if EOM's stack up, we may try and shrink while
                // already waiting for a shrink, and _pool will be null
        
//                 // We no longer need to drain the queue, as we self-manage it now
//                 final List<Runnable> dequeued;
//                 if (false) {
//                     dequeued = new ArrayList();
//                     TaskQueue.drainTo(dequeued);
//                     Log.debug("shutdown...");
//                     _pool.shutdown();
//                 } else {
//                     Log.debug("shutdownNow...");
//                     dequeued = _pool.shutdownNow();
//                 }
//                 Log.info("back from shutdown, drained=" + Util.tags(dequeued));
//                 if (DEBUG.Enabled) Util.dump(dequeued);
//                 if (_deferredTasks == null) {
//                     _deferredTasks = new ArrayList(dequeued);
//                 } else {
//                     _deferredTasks.addAll(dequeued);
//                 }

                Log.debug("shutdownNow...");
                _pool.shutdownNow();
                final ExecutorService oldPool = _pool;
                _pool = null;
                forkAndWaitForTermination(oldPool);

                // note: will not need to re-sort queue: is automatically resorted when
                // _deferredTasks is re-loaded
                
            } else {

                reduceNextPoolSize();

                if (DELAYED_ICONS && firstLowMemory)
                    resortQueue();
                
                Log.warn("stacked OutOfMemoryError conditions: next pool size reduced to " + _nextSmallerPoolSize);
            }
        }
    }

    private static final Runnable NOOP = new Runnable() {
            volatile long count = 0;
            public void run() {
                if (DEBUG.Enabled) {
                    Log.debug(Util.TERM_PURPLE + "NOOP RUNS " + count + Util.TERM_CLEAR);
                    count++;
                }
            }
            @Override public String toString() { return "NOOP"; }
        };


    private static final String PRI_HIGH = "HIGH";
    private static final String PRI_NORM = "NORM";
    private static final String PRI_LOW = "LAST";

    // ThreadPoolExectuor impl requires BlockingQueue methods:
    //          offer, remove, isEmpty, poll, poll(time,timeUnit), take, drainTo(Collection), iterator,
    //          remainingCapacity, size
    // it does NOT need (so, it needs just about everything)
    //          add, contains
    // Primarily, we need to override TAKE/poll and OFFER to feed ThreadPoolExectuor properly
    
    private static final class TaskQueue<E> extends SynchronousQueue<E>
    {
        // An implementaiton that maximally adressed performance would record the canvas object
        // drawn to (e.g., MapViewer object: the full-screen v.s. standard map instances, etc) for
        // each desired representation, and an API call for the application to report the current
        // priority canvas (e.g., when a MapViewer gets application focus).  Or perhaps an API call
        // to report when the focal has changed or narrowed, so we should only then pay attention
        // re-prioritizing due to repeated paint requests.  In any case, Within the priority canvas
        // items, the most recently desired reps would take priority (e.g., the most recently
        // requested content to be drawn to the full-screen viewer when in presentation mode has
        // priority). As ImageRep's won't re-poll the cache (call getImage) if their alreadly
        // loading (and that would be messy to enforce) this would require coordination with the
        // ImageRef's desired reps each time they're drawn.  This would probably be most simply
        // done by changing ImageRef's to singleton instances that are stored in the cache.  All
        // that is to prevent excess queue thrashing (near full rotations on each paint), tho in
        // practice that would only actually happen when there are lots of high-res full-scale
        // images trying to draw at once, which is fundamentally limited by the pixel resoution of
        // the screen.
        
        // For icon tasks, runs as FIFO (would be better as a JDK1.6 ArrayDeque, as this never needs shuffling)
        private final LinkedList<E> q1 = new LinkedList();
        // For normal priorities, runs as LIFO, and requires re-shuffling:
        private final LinkedList<E> q2 = new LinkedList();
        // For low priorities, runs as FIFO (also better as ArrayDeque)
        private final LinkedList<E> q3 = new LinkedList();
        
        // The total queue can be seen as three regions arranged linearly in order of priority,
        // left to right, to be fed to the TPE (ThreadPoolExecutor):
        //
        // TPE <- [FIFO priorities (icon generation)] <= [LIFO paint requests] <= [FIFO cache requests]

        private static void debug(String s) {
            Log.info(Util.TERM_GREEN + s + Util.TERM_CLEAR);
        }

        private void dump() {
            Log.debug("TaskQueue:");
            Util.dump(q1);
            Util.dump(q2);
            Util.dump(q3);
        }

// Actually, we leave this as the SynchronousQueue default, which is to always report as empty.
//         @Override public synchronized boolean isEmpty() {
//             return q0.size() + q1.size() + q2.size() < 1;
//         }
        
        @Override public E take() throws InterruptedException {
            E o = popNext();
            if (o != null) {
                if (DEBUG.IMAGE) debug("take provides " + o);
                return o;
            } else {
                // There are currently no outstanding tasks.
                if (DEBUG.IMAGE) debug("take is waiting on offer...");
                // SynchronousQueue super.take() will put this thread to sleep, and it won't wake
                // until another thread makes a call to super.offer(Runnable).  The codepath for
                // that call is via ThreadPoolExecutor.execute(Runnable), which will call into our
                // BlockingQueue to offer new tasks.  We don't just call offer ourselves when a new
                // image request comes in -- we want the TPE to do that.
                return super.take();
            }
        }
        
        @Override public boolean offer(E e)
        {
            // offer(E) This is called by ThreadPoolExecutor in execute() -- we run through that so
            // we can take advantage of it's thread startup/restart code.
        
            // All we do is add it to our self-managed queue -- we don't immediately feed it to
            // the TPE, as we may have other more important tasks outstanding.
            
            queueTask(e);
            
            // Ideally, this would offer up a PEEK, and if accepted, THEN pop the peek.  We always
            // want to return true in any case -- we're unbounded.  queueTask could also return the task
            // queued in the special case that it's the only item in the queue, which is a common condition.
            // Doing it that way would at least leave the total real queue-size check inside the same sync.

            // CRUCIAL: super.offer(E) is what signals the blocking run thread in
            // ThreadPoolExecutor to wake and look for more tasks.  We do not transfer a task to be
            // executed -- it's just a wake-up signal to call us back for another take().  The NOOP
            // task we hand it will in fact run, just quickly and with no result.

            /*final boolean wasAccepted = */ super.offer((E)NOOP);

            // Note that we DO NOT want to use put() or some other superclass impl that would let
            // us block here until the offer succeeds, as by the time the TPE is ready for another
            // take(), our self-managed queue may well have something more important to offer up.
            
            return true;
        }
        
        @Override public E poll() {
            // This will be called by SyncQueue to DRAIN us if the TPE is shutting down.
            // We never want or need to be drained, so we always return null.
            debug(getClass() + ".poll() returning null");
            return null;
//             final E o = getNext();
//             /*if (DEBUG.IMAGE)*/ debug("local poll " + o);
//             if (o != null) {
//                 return o;
//             } else {
//                 /*if (DEBUG.IMAGE)*/ debug("super poll " + o);
//                 return super.poll();
//             }
        }
        
        private void queueTask(E task) {
            if (task instanceof Task) {
                // typing in this class is currently a bit hacked-up
                final Object pri = ((Task)task).getPriority();
                if (DEBUG.IMAGE) debug("QUEUE(" + pri + "):  " + task);
                queueTaskAtPriority(task, pri);
            } else {
                Log.error("cannot queue: " + Util.tags(task));
            }
        }
        
        synchronized void queueTaskAtPriority(E task, Object pri) {
                
            if (pri == PRI_HIGH) {
                q1.addLast(task);
            } else if (pri == PRI_NORM) {
                q2.addFirst(task); // push front for LIFO
            } else { //if (pri == PRI_LOW)
                q3.addLast(task);

                // If this is an ICON we're wanting to pre-cache, they should take
                // priority over any full-reps.  Right now, we don't have a bit for
                // that, but could hack it in by recognizing the "i128.png" extension.
                // To handle this properly, there should be a another FIFO queue
                // inserted before the current LAST priorities (pre-caching of full-reps)
                // for the pre-caching of the ICON reps.
                
                // We could just do a q3.addFirst if this is an icon-key, tho that would
                // change those to LIFO order.  Note that this is ONLY important when
                // opening multiple maps at once tho, which users pretty much are never
                // going to do (can only do that from the command line).  Actually, no
                // this could happen if there are a bunch of images on a slide that are
                // not on a map, so they never get that first paint request that will
                // request the icon.  Not common, but it could happen. In these funny
                // cases, if we don't handle this, the full-rep could be cached first.
                // It really just means that the full-rep will paint in place of the
                // icon rep until it loads.  Chances are, pre-caching will have loaded
                // both anyway by the time users would see any of this tho.
            }
            
            // typing in this class is currently a bit hacked-up:
            ((Task)task).setPriority(pri);
        }

        /** @return the next highest priority processing task */
        private synchronized E popNext() {

            // Ideally, we would mark Tasks as RUNNING right here -- as soon as it's de-queued.
                
            if (q1.size() > 0)
                return q1.removeFirst(); // pop front (was inserted FIFO)
            else if (q2.size() > 0)
                return q2.removeFirst(); // pop front (was inserted LIFO)
            else 
                return q3.poll(); // pop front (was inserted FIFO), but return null if empty
        }

        void raiseTaskPriority(Task task) {
            //debug("RAISE-PRIORITY: " + task);
            final Object pri = task.getPriority();
            if (pri == PRI_LOW) {
                jumpQueue(q3, task, PRI_NORM);
            } else if (pri == PRI_NORM) {
                jumpQueue(q2, task, PRI_NORM);
            }
        }
        
        // this needs testing: could be dangerous: even tho we're only
        // flushing caching requests, the Loader's can still be in
        // the RawCache, and we never want the possibility of a task
        // being left orphaned there, as future requests for that image
        // will think it's queued to be loaded, when that will never
        // happen.
//         synchronized void flushCachingRequests() {
//                 // note: these task will still be in the RawCache, so
//                 // they may later be found there and attempt to be re-prioritized...
//                 if (q3.size() > 0) {
//                     if (DEBUG.Enabled) Log.debug("flusing caching queue " + Util.tags(q3));
//                     q3.clear();
//                 }
//         }

        synchronized void jumpQueue(List src, Task task, Object newPri) {
                if (DEBUG.Enabled) {
                    if (DEBUG.IMAGE) dump();
                    debug("jumpQueue to " + newPri + " for: " + task);
                }
                if (!src.remove(task)) {
                    //if (!lowMemoryConditions() || task.isRunning()) { // really: is running, or has run
                    if (true) { 
                        debug("queue did not contain: " + task 
                              + "\n\t      was not in queue: " + Util.tags(src)
                              + "\n\trequested new priority: " + newPri
                              //+ "\n\tThis task will not be re-queued."
                              );
                        if (DEBUG.Enabled) dump();
                        //return; // allow re-queue for now: worst case, it re-runs
                    }
                    // if we're low-memory, this can happen as a result
                    // of flushing cache requests -- we allow the re-queuing
                    //if (DEBUG.Enabled) debug("re-queue loMem orphan: " + task);
                    
                }
                queueTaskAtPriority((E)task, newPri);
                if (DEBUG.IMAGE) dump();
            }

        // Ideally, these caching tasks would only ever consume a single thread (low CPU
        // usage, especially during a presentation), and if we transition to a
        // low-memory state, all outstanding pre-cache requests should be flushed.

        // We should at least be able to demote outstanding pre-caches if the content
        // is no longer needed -- e.g., you're fast-paging through a presentation in
        // low-memory conditions, and you only need previews until you settle on where
        // you want to be.  The way to handle this is actually to promote more
        // recent duplicate requests.
    }
    
    
    private static final TaskQueue<Runnable> TaskQueue = new TaskQueue();
    
    private static final int ImageThreadPriority;

    private static final ThreadFactory ImageThreadFactory = new ThreadFactory() {
            private int count = 0;
            public Thread newThread(Runnable r) {
                final Thread it = new Thread(r);
                try {
                    it.setPriority(ImageThreadPriority);
                } catch (Throwable t) {
                    Log.warn("failed to set image thread priority to " + ImageThreadPriority, t);
                }
                count++;
                if (DEBUG.Enabled)
                    it.setName("IPX-" + count);
                else
                    it.setName("imageProcessor-" + count);
                if (DEBUG.Enabled) Log.debug("created thread: " + it);
                return it;
            }
        };

    private static final ImmediatelyReducablePool ProcessingPool;

    static {
        final int cores = Runtime.getRuntime().availableProcessors() - 1;
        final int useCores;

        if (DEBUG.SINGLE_THREAD) {
            if (cores == 1)
                useCores = 2;
            else
                useCores = 1;
        } else {
            useCores = cores;
        }

        // rough test: on a 2-core laptop, our use-case came in at 1min v.s. 1:30min w/all cores in use
        // (all icons being generated)

        int priority = 1; // should be lowest

        try {
            priority = Thread.MIN_PRIORITY + Thread.NORM_PRIORITY / 2; 
            // in case NORM_PRIORITY access fails -- there was some case of this happening in Applets?
        } catch (Throwable t) {}

        ImageThreadPriority = priority; // for thread factory
        
        ProcessingPool = new ImmediatelyReducablePool(useCores);
    }

    private static ThreadPoolExecutor createThreadPool(int nThreads) {

        final ThreadPoolExecutor pool;

        pool = new PriorityThreadPool(nThreads);
        
        Log.info("created thread pool: " + Util.tags(pool) + "; maxSize=" + pool.getMaximumPoolSize());
        
        return pool;
    }
    
    private static final class PriorityThreadPool extends ThreadPoolExecutor {

        private int lowMemoryRepaints;
        
        PriorityThreadPool(int nThreads) {
            super(nThreads, nThreads,
                  0L, TimeUnit.MILLISECONDS,
                  (BlockingQueue<Runnable>) TaskQueue,
                  ImageThreadFactory);
        }

//         /** This method was added to AbstractExecutorService in Java 1.6 -- is not available in java 1.5 */
//         @Override protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
//             if (runnable instanceof RunnableFuture) {
//                 if (DEBUG.Enabled) Log.debug("resubmit " + runnable);
//                 return (RunnableFuture) runnable; // for re-submits
//             } else {
//                 if (DEBUG.IMAGE) Log.debug("newTaskFor runnable " + Util.tags(runnable));
//                 return new PriorityTask((Loader)runnable);
//             }
//         }

//         /** This method was added to AbstractExecutorService in Java 1.6 -- is not available in java 1.5 */
//         @Override protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
//             throw new UnsupportedOperationException("newTaskFor Callable; " + callable);
//         }

        @Override public Future<?> submit(Runnable task) {
            throw new Error("use execute; " + task);
        }
        @Override public <T> Future<T> submit(Runnable task, T result) {
            throw new Error("use execute; " + task);
        }
        @Override public <T> Future<T> submit(Callable<T> task) {
            throw new Error("use execute; " + task);
        }

//         private boolean first = true;
//         @Override public void execute(Runnable runnable) {
//             if (runnable instanceof Loader) {
//                 if (DEBUG.Enabled) Log.debug("new-task " + runnable);
//                 if (first) {
//                     //super.execute(new PriorityTask((Loader)runnable));
//                     super.execute(runnable);
//                     first = false;
//                 } else {
//                     //TaskQueue.addFirst(new PriorityTask((Loader)runnable));
//                     TaskQueue.addFirst(runnable);
//                     // if caching task, do addLast...
//                 }
//             } else {
//                 if (DEBUG.Enabled) Log.debug("resubmit " + runnable);
//                 if (first) {
//                     super.execute(runnable);
//                     //first = false;
//                 } else {
//                     TaskQueue.addFirst(runnable);
//                 }
//             }
//         }
        
        @Override public void execute(Runnable runnable) {
            if (DEBUG.IMAGE||DEBUG.WORK) Log.debug("submit " + runnable);
            super.execute(runnable);
            
//             if (runnable instanceof Loader) {
//                 if (DEBUG.Enabled) Log.debug("new-task " + runnable);
//                 super.execute(new PriorityTask((Loader)runnable));
//             } else {
//                 if (DEBUG.Enabled) Log.debug("resubmit " + runnable);
//                 super.execute(runnable);
//             }
        }
        
        @Override protected void afterExecute(Runnable r, Throwable t) {

            if (r == NOOP) return;
            
            // note: don't toString the task -- members flushed for GC, so you'll get NPE
            //if (DEBUG.IMAGE) Log.debug("AFTER-EXECUTE " + Util.tags(r) + "; ex=" + t);
            
            // Only allow a few of these, otherwise we can get continuous looping failures if
            // memory becomes full enough.  This is mainly needed for recovery from the first
            // low-memory failure.
            //if (DEBUG.Enabled) Util.dump(Cache);
            if (lowMemoryRepaints < 3 && lowMemoryConditions() && !isShutdown() && getQueue().isEmpty()) {
                java.awt.Component v = VUE.getActiveViewer();
                if (v != null) {
                    Log.info("LOW-MEMORY-REPAINT " + lowMemoryRepaints);
                    v.repaint();
                    lowMemoryRepaints++;
                }
            }
        }
    }

    // what was the problem with making the Loader a FutureTask itself?
    static abstract class Loader implements Runnable
    {
        CachingRelayer relay;
        ImageSource imageSRC;
        volatile boolean started;

        Loader(ImageSource _imageSRC, Listener firstRelay) 
        {
            imageSRC = _imageSRC;
            relay = new CachingRelayer(imageSRC, firstRelay);
        }

        void setPriority(Object key) {}
        Object getPriority() { return "-n/a"; }
        void raisePriority() {}


        final Image getLoaderImage() {
            return relay.image();
        }

        public void join() throws InterruptedException {
            if (DEBUG.Enabled) Log.debug(this + " has been joined...");
            Thread.currentThread().join();
        }

        public final void run() {
            started = true;
            try {
                runToResult();
            } catch (OutOfMemoryError eom) {
                setLowMemory(eom);
                Log.error("Uncaught EOM running " + this, eom);
            } catch (Throwable t) {
                Log.error("Uncaught Exception running " + this, t);
            }
        }

        // Mark as having been completed / potentially assist garbage collector
        void dispose() {
            // assist GC -- may help it run slightly faster:
            imageSRC = null;
            relay.flushForGC();
            relay = null;
        }

        final boolean isRunning() {
            return started;
        }

        /** @return an Image that's already been put into the cache */
        private final Handle runToResult() {
            if (DEBUG.IMAGE) debugMark(">>>>>");
            final Handle handle = produceResult();
            if (DEBUG.IMAGE) {
                debugMark("<<<<<");
                TaskQueue.dump();
            }
            dispose();
            return handle;
        }

        private void debugMark(String s) {
            Log.debug(Util.TERM_YELLOW
                      + s + "---"
                      + getClass().getSimpleName()
                      + "-"
                      + getPriority()
                      + "---"
                      + imageSRC.debugName()
                      + "-----------------------------------------------------------------------------"
                      + Util.TERM_CLEAR
                      );
        }

        /** @return an Image that's already been put into the cache */
        Handle produceResult() {
            if (DEBUG.IMAGE || DEBUG.THREAD) out("loadAndCache kick: " + imageSRC);
            Handle h = loadImageAndCache(imageSRC, relay);
            if (DEBUG.IMAGE || DEBUG.THREAD) out("loadAndCache return: " + h);
            return h;
        }
        
        public void addListener(Listener newListener) {
            if (DEBUG.IMAGE) out(this + " addListener " + tag(newListener));
            relay.addListener(newListener);
        }

        //public int getPriority() { return 5; }
        

        @Override public String toString() {
            try {
                return String.format("%s[%s%s:%s ->%s]",
                                     getClass().getSimpleName(),
                                     isRunning() ? "<RUNNING> " : "",
                                     getPriority(),
                                     imageSRC == null ? "null-imageSRC" : imageSRC.debugName(),
                                     relay == null ? "null-relay" : relay.chain.head);
            } catch (Throwable t) {
                return getClass().getSimpleName() + "[" + t + "]";
            }
        }

    }

    private static class Task extends Loader
    {
        static Loader create(ImageSource imageSRC, Listener listener, Object when)
        {
            final Loader loader;
            
            if (imageSRC.key == null) {
                if (DEBUG.IMAGE||DEBUG.WORK) 
                    Util.printStackTrace("attempting to load cache w/null key: " + imageSRC);
                else
                    Log.warn("attempting to load cache w/null key: " + imageSRC);
            }
        
            // It's okay if listener is null: a CachingRelayer will still be created, in
            // the Loader, and listeners can be added later.

            // note: if imageSRC is a file, and it doesn't exist, we could fail-fast
            // (perhaps if immediate is requested?), rather than wait for the error to
            // be reported via Listener.gotImageError, tho that's the standard API for
            // now.

            // ISSUE: if we use a Deque to deal with priorities (regular requests LIFO'd up front,
            // caching requests at end), we still have the problem of ensuring that IconTasks take
            // priority over everything else.
        
            if (imageSRC.mayBlockIndefinitely()) {
                // run in a completely separate thread outside the thread pool:
                loader = new LoadThread(imageSRC, listener);
            } else if (imageSRC.isImageSourceForIcon()) {
                // highest-priority tasks:
                loader = new IconTask(imageSRC, listener);
            } else if (when == LOAD_CACHE) {
                loader = new Task(imageSRC, listener, PRI_LOW);
                //             if (DEBUG.Enabled && listener != null) {
                //                 Log.warn("cache tasks don't normally want listeners: " + loader);
                //             }
            } else {
                // regular task:
                loader = new Task(imageSRC, listener, PRI_NORM);
            }

            return loader;
        }
        
        Object priority;
        
        private Task(ImageSource is, Listener relay, Object pri) {
            super(is, relay);
            priority = pri;
            if (DEBUG.IMAGE && relay == null)
                Log.debug(this + "; nobody currently listening: image may be quietly cached: " + imageSRC);
            
        }
        
        void raisePriority() {
            if (!isRunning())
                TaskQueue.raiseTaskPriority(this);
        }

        synchronized void setPriority(Object key) {
            priority = key;
        }
        
        synchronized Object getPriority() {
            return priority;
        }
    }
    
    /** a task to generate an icon */
    private static final class IconTask extends Task {
        IconTask(ImageSource is, Listener relay) {
            super(is, relay, PRI_HIGH);
            if (DEBUG.Enabled && relay == null)
                Log.debug(this + "; nobody currently listening: image may be quietly cached: " + imageSRC);
        }
        @Override Handle produceResult() {
            //if (DEBUG.IMAGE || DEBUG.THREAD) out("ICON-TASK for " + imageSRC + " kicked off");

            // At this point, there's a cache entry containing this IconTask, so no
            // other icon requestor in another thread will attempt to create an icon --
            // it will simply be chained up to the results of this Loader waiting for
            // callback.
            return createAndCacheIcon(relay, imageSRC);
        }
    }

    
    /** a marker class */
    private static final class ImgThread extends Thread {
        ImgThread(Runnable r, String name) {
            super(r, name);
        }
    }

    
    /**
     * A thread for loading a single image.  Images.Listener results are delievered
     * from this thread (unless the image was already cached).
     */
    private static final class LoadThread extends Loader {
        private static int LoaderCount = 0;
        private final Thread thread;

        /**
         * @param src must be any valid src *except* a Resource
         * @param resource - if this is tied to a resource to update with meta-data after loading
         */
        LoadThread(ImageSource imageSRC, Listener firstRelay) {
            super(imageSRC, firstRelay);
            thread = new ImgThread(this, String.format("ImgLoader-%02d", LoaderCount++));
            thread.setDaemon(true);
            thread.setPriority(Thread.MIN_PRIORITY);
        }

        public void join() throws InterruptedException {
            thread.join();
        }
        public void start() {
            //if (DEBUG.IMAGE) Log.debug("=======================================================");
            thread.start();
        }        
        public void stop() {
            thread.stop();
        }        
    }
    

    /**
     * This method has side effects to the cache.  At the end of this call, we know
     * there is *something* in the cache for the given imageSRC.key -- either we found
     * the image already there, or we found a Loader thread already started there, or we
     * put a new Loader into the cache and kicked it off, unless it was an immediate request,
     * (an anusual but supported case) in which case first the Loader will have been put in the
     * cache, and then the final result before we return.  
     *
     * This method also deals with cache cleanup: if an entry is found to be
     * empty (it has no disk file, and it's image has been GC'd), the entry
     * is removed.
     *
     * @return If an Image, we had the image in the cache immediately availble.  If a
     * Loader thread object, the image is loading, and we should become and additional
     * listener (if there is a listener given), or wait for the Loader to die to get
     * it's results.  If the special value IMAGE_LOADER_STARTED is returned, there is
     * nothing to do be done for now -- just wait for the callbacks to the listener if
     * one was provided.
     */
    private static Object getCachedOrKickLoad(ImageSource imageSRC, Images.Listener listener, Object when)
    {
        Loader loader = null;

        //-------------------------------------------------------
        // OBTAIN CACHE LOCK
        //-------------------------------------------------------
        
        synchronized (RawCache) {

            final Object entry = getCacheContentsWithAutoFlush(imageSRC);

            // if anything in the Cache, immediately return it.  Could
            // be the desired image, or an existing loader.

            if (entry != null) {

                // TODO: if this is a "regular priority" request, and we find a loader in the cache
                // that has NOT already been started, then the task queue(s) may need modification.
                // If the existing task is in the caching queue, it needs to be moved (to the
                // front) of the regular loading queue.  If the existing task is in the loading
                // queue, it needs to be moved (LIFO style) to the front of the loading queue.

                return entry;
                // cache lock released

            } else {
                
                // Nothing was in the cache.  Create a task for loading
                // the image, which will then normally be run in another thread.
                // the task immediately in the current thread and return the
                // result.
                
                loader = Task.create(imageSRC, listener, when);
                
                // Note that even if we've been requested to run synchronously (no listener or
                // "immediate" is true) , we still want a Loader created that can have listeners
                // added later, and have it marked in the cache, in case subsequent asynchronous
                // requests come in for this image, or even future immediate requests, which then
                // won't be honored: they'll get a callback when the previous immediate load
                // finishes.
                
                markCacheAsLoading(imageSRC.key, loader);
            }
        }
        
        //-------------------------------------------------------
        // CACHE LOCK IS RELEASED
        //-------------------------------------------------------

        if (when == LOAD_IMMEDIATE) {
            // It's crucial that this NOT be run in a Cache-lock, or
            // every other image thread will soon hang until this is
            // done, including the AWT thread if anything requests
            // image data.

            // runToResult should always return an image that's been loaded into the cache
            return loader.runToResult();
            
        } else {
            kickTask(loader);
            return LOADER_JUST_STARTED;
        }
    }

//     private static Loader createTask(ImageSource imageSRC, Listener listener, Object when)
//     {
//         final Loader loader;
        
//         if (imageSRC.key == null) {
//             if (DEBUG.Enabled) 
//                 Util.printStackTrace("attempting to load cache w/null key: " + imageSRC);
//             else
//                 Log.warn("attempting to load cache w/null key: " + imageSRC);
//         }
        
//         // It's okay if listener is null: a CachingRelayer will still be created, in
//         // the Loader, and listeners can be added later.

//         // note: if imageSRC is a file, and it doesn't exist, we could fail-fast
//         // (perhaps if immediate is requested?), rather than wait for the error to
//         // be reported via Listener.gotImageError, tho that's the standard API for
//         // now.

//         // ISSUE: if we use a Deque to deal with priorities (regular requests LIFO'd up front,
//         // caching requests at end), we still have the problem of ensuring that IconTasks take
//         // priority over everything else.
        
//         if (imageSRC.mayBlockIndefinitely()) {
//             // run in a completely separate thread outside the thread pool:
//             loader = new LoadThread(imageSRC, listener);
//         } else if (imageSRC.isImageSourceForIcon()) {
//             // highest-priority tasks:
//             loader = new IconTask(imageSRC, listener);
//         } else if (when == LOAD_CACHE) {
//             loader = new CacheTask(imageSRC, listener);
// //             if (DEBUG.Enabled && listener != null) {
// //                 Log.warn("cache tasks don't normally want listeners: " + loader);
// //             }
//         } else {
//             // regular task:
//             loader = new LoadTask(imageSRC, listener);
//         }

//         return loader;
//     }
    

    private static void markCacheAsLoading(URI key, Loader loader) {
        final Object old = RawCache.put(key, loader); // todo: under what conditiions is this normal?
        if (old != null && old instanceof CacheEntry && !((CacheEntry)old).isPreloadedDiskEntry()) {
            Log.warn("blew away existing cache content:\n\told: " + Util.tags(old) + "\n\tfor: " + Util.tags(loader));
        }
    }

    private static void kickTask(Loader loader)
    {
        if (loader instanceof LoadThread) {
            // could submit to a special pool or some future fancy non-blocking NIO multi-stream handler
            ((LoadThread)loader).start();
        } else {
            ProcessingPool.addTask((Task)loader);
        }
    }

    /**
     * Get the cache contents for the given source (either an Image or
     * a Loader), and update the cache entry if needed. This should
     * only be called from within a Cache lock.  Will return null if
     * no cache entry was found, or one was found but it's contents
     * had been garbage collected.  In the latter case, the empty
     * entry will be flushed from the cache.
     */
    private static Object getCacheContentsWithAutoFlush(ImageSource imageSRC)
    {
        if (imageSRC.key == null)
            return null;
        
        final Object entry = RawCache.get(imageSRC.key);

        if (entry == null)
            return null;
        
        if (DEBUG.IMAGE) out("found cache entry for key " + tag(imageSRC.key) + ": " + entry);
                
        if (entry instanceof Loader) {
            if (DEBUG.IMAGE) out("Image is loading into the cache via already existing Loader...");

            // For ideal LIFO handling of image requests, we should at this point
            // find out if the Loader is already running in the thread pool or not.  If
            // it's NOT running, we should advance it's priority to the front of
            // the queue since it's been requested again.  Unless this request
            // is for low-priority PRE-CACHING, in which case we'd need not
            // make any change -- the image is already loading with a high priority.
            
            return entry;
        }

        // Entry is not a Loader, so it must be a regular CacheEntry We still may not
        // have an image tho: it may have be been garbage collected, or the entry may
        // actually be for a file on disk.

        final CacheEntry ce = (CacheEntry) entry;
        final Image cachedImage = ce.getCachedImage();
                     	
        // if we have the image, we're done (it was loaded this runtime, and not GC'd)
        // if not, either it was GC'd, or it's a cache file entry from the persistent
        // cache -- in either case, there is a file on disk -- mark it in the imageSRC,
        // and the loader will notice it and use it.
                    
        boolean emptyEntry = true;

        if (cachedImage != null) {
            emptyEntry = false;
        } else if (ce.getFile() != null) {
            if (ce.file.canRead()) {
                imageSRC.setCacheFile(ce.file); // Note: imageSRC side effect
                emptyEntry = false;
            } else
                Log.warn("cache file no longer available: " + ce.file);
        }

        if (emptyEntry) {
            // there is a cache entry with no image OR file: this could only
            // happen if the disk cache is not operating, and the memory
            // image was garbage collected: we need to remove this entry
            // from the cache completely and start from scratch:
            if (DEBUG.IMAGE) out("REMOVING FROM CACHE: " + imageSRC);
            RawCache.remove(imageSRC.key);
        }

        if (cachedImage == null)
            return null;
        else
            return new Handle(cachedImage, ce.data);
        
        //return cachedImage;

        // If cachedImage is null at this point, there was an entry in the cache, but it was of no use:
            
        // That happens in the following cases:
        // (1) We had the image, but is was GC'd -- we're going back to the cache file
        // (2) We had the image, but is was GC'd -- original was on disk: go back to that
        // (3) We had the image, but is was GC'd, and disk cache not working: reload original from network
        // (4) We never had the image, but it is in disk cache: go get it
        // (5) unlikely case case of zealous GC: reload original

        // Note that original image sources that were on disk are NOT moved
        // to the disk cache, and CacheEntry.file should always be null for those.
    }

    private static final String NO_READABLE_FOUND = "No Readable";


    /** @return true if there's a cache entry for the given key.  The entry may be in any state:
     * an unloaded pre-registered disk cache entry, a loaded cache entry, or a loader in progress.
     */
    public static boolean hasCacheEntry(URI cacheKey)
    {
        return cacheKey != null && RawCache.get(cacheKey) != null;
    }
    
    private static Handle createAndCacheIcon(Listener listener, ImageSource iconSource)
    {
        final Image hardImage;
        boolean badReadable = false;

        if (iconSource.readable instanceof Image) {
            hardImage = (Image) iconSource.readable;
            // lose hard reference to the original image so it can be GC'd,
            // and be sure do this before we're start creating the icon, so if we
            // EOM or error it's already been cleared.  THIS IS CRUCIAL --
            // if we don't do this, recovering from OutOfMemoryError's when
            // generating icons is virtually impossible.
            iconSource.readable = null;

            //========================================================================================
            // TODO: OUTSTANDING PROBLEM:
            // If we hit EOM creating this icon, the icon-source readable is cleared, and
            // thus the icon ImageRep goes permanently bad -- it can't reconsititute.
            // Yet the icon ImageSource (can) has a ref to the full ImageRep, which it
            // could reconstitute, and then generate the image from, but then
            // the Icon ImageRep would need to listen first for the full callbacks,
            // then the the icon callbacks.  We could just try NOT clearing it here,
            // and let the ImageRep clear it only if it gets the image, but then we
            // can run lower on memory by leaving this hard-ref around...  Need to run tests.
            //
            // The best fix for this probably includes going all the way to keeping singleton
            // ImageRef's in the cache, perhaps only allow icons to be obtained through their
            // ImageRef.
            //========================================================================================
            
        } else if (iconSource.readable instanceof ImageRep) {
            hardImage = ((ImageRep) iconSource.readable).image();
        } else {
            badReadable = true;
            hardImage = null;
        }


        if (badReadable)
            throw new Error("iconSource had no image content in readable: " + iconSource);

        if (hardImage == null) {
            Log.warn("hard image GC'd before icon creation, forcing low-memory conditions (" + iconSource + ")");

            setLowMemory("GC-wanted-data");

            // REMOVE THE FAILED LOADER FROM THE CACHE
            RawCache.remove(iconSource.key); // todo: generally handle caching in our caller based on return value?
            
            // todo: nobody to catch this error and deliver gotImageError!
            //throw new OutOfMemoryError("full-rep was GC'd: forcing low memory conditions");
            if (listener != null) {
                listener.gotImageError(iconSource, OUT_OF_MEMORY);
                return null;
            }
        }
        
        final Dimension originalSize = new Dimension(hardImage.getWidth(null), hardImage.getHeight(null));
        
        final Handle iconHandle =
            createIcon(hardImage, iconSource.iconSize);

        if (listener != null) {
            // note that we could init the handle with data containing he size of the source image,
            // but that info is only useful when we do NOT already have the full rep loaded,
            // and we only get here if we just created an icon from the full rep, so it's okay to leave
            // the image data blank in the delivered handle.  That data shouldn't even need to go into
            // the cache this runtime?  It should probably go there anyway just to be safe.
            listener.gotImage(iconSource, iconHandle);
        }

        File cacheFile = null;
        try {
            cacheFile = makePermanentCacheFile(iconSource.key);
        } catch (Throwable t) {
            Log.error("creating cache file for " + iconSource, t);
        }
        // if for any reason the disk cache has failed, we can still create the CacheEntry with a null file
        RawCache.put(iconSource.key, new CacheEntry(iconHandle, cacheFile));
        
        // TODO: Make sure size data is in icon cache entry to be consistent with state of cache on re-init.
        // Some code is actually sensitive to this.  Oh -- wait -- maybe the problem is that it is NOT
        // in after it's been loaded to the disk cache?
        
        if (cacheFile != null && iconHandle.image != null)
            cacheIconToDisk(iconSource.key, (RenderedImage) iconHandle.image, cacheFile, originalSize);
        return iconHandle;
    }

    private static boolean cacheIconToDisk(URI iconKey, RenderedImage image, File cacheFile, Dimension originalSize)
    {
        final Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("png");

        ImageWriter writer = null;

        int count = 1;
        while (writers.hasNext()) {
            ImageWriter i = writers.next();
            if (writer == null)
                writer = i;
            if (count > 1 || (DEBUG.IMAGE && DEBUG.IO)) {
                Log.debug("FOUND WRITER #" + count + "for PNG: " + Util.tags(i));
            }
            count++;
        }
        
        try {
            //File cacheFile = makePermanentCacheFile(iconKey);
            if (DEBUG.IMAGE||DEBUG.IO) Log.debug("writing " + cacheFile);
            if (writer != null) {
                writeWithComments(writer, image, cacheFile, originalSize);
            } else {
                Log.warn("unable to find writer for PNG; " + cacheFile);
                ImageIO.write(image, "png", cacheFile);
            }
            if (DEBUG.IMAGE||DEBUG.IO) Log.debug("  wrote " + cacheFile);
            return true;
        } catch (Throwable t) {
            Log.error("writing icon cache file " + iconKey, t);
            return false;
        }
    }

    /** see com.sun.imageio.plugins.png.PNGMetadata */
    private static final String PNG_META_DATA = "javax_imageio_png_1.0";
    private static final String GENERIC_META_DATA = IIOMetadataFormatImpl.standardMetadataFormatName;
    
    //private static final String VUE_META_DATA = GENERIC_META_DATA;
    
    private static final String VUE_IMAGE_META_KEY = "VueData";
    private static final String VUE_IMAGE_META_VALUE_PREFIX = "@(#)VUE-DATA/";
    

    private static void writeWithComments(ImageWriter writer, RenderedImage image, File file, Dimension originalSize)
        throws IOException
    {
        if (DEBUG.Enabled||DEBUG.IMAGE||DEBUG.IO) Log.debug("WRITER WRITE " + writer + "; " + file);
        
        //ImageWriteParam param = writer.getDefaultWriteParam();
        //Log.debug("DEFAULT WRITE PARAM: " + Util.tags(param));

        final ImageTypeSpecifier image_type = new ImageTypeSpecifier(image);
        final IIOMetadata meta_data = writer.getDefaultImageMetadata(image_type, null);

//         Log.debug("GOT META-DATA: " + Util.tags(meta_data));
        if (DEBUG.IMAGE && DEBUG.IO) {
            Log.debug(" FORMAT-NAMES: " + Arrays.asList(meta_data.getMetadataFormatNames()));
            Log.debug("NATIVE-FORMAT: " + meta_data.getNativeMetadataFormatName());
        }

        final org.w3c.dom.Node metaTree = meta_data.getAsTree(GENERIC_META_DATA);

        addData(metaTree, "cacheFile", file.getName());
        addData(metaTree, "sourceSize", String.format("%d,%d", originalSize.width, originalSize.height));

        meta_data.mergeTree(GENERIC_META_DATA, metaTree);

        if (DEBUG.IO) {
            //Log.debug("TREE: " + Util.tags(tree));
            //Log.debug("ATTRIBS: " + Util.tags(tree.getAttributes()));
            //Log.debug("CHILDREN: " + Util.tags(tree.getChildNodes()));
            //DocDump.dump(meta_data.getAsTree(GENERIC_META_DATA));
            DocDump.dump(meta_data.getAsTree(PNG_META_DATA));
        }
        
        final IIOImage io_image = new IIOImage(image, Collections.EMPTY_LIST, meta_data);

        final ImageOutputStream output = ImageIO.createImageOutputStream(file);

        writer.setOutput(output);
        writer.write(io_image);
        output.close();
    }

    private static void addData
        (final org.w3c.dom.Node tree,
         final String key,
         final String value)
        throws IOException
    {
        // note: we add a trailing newline for /usr/bin/what
        final String comment = String.format("%s%s: %s\n", VUE_IMAGE_META_VALUE_PREFIX, key, value); 
        final String keyword = VUE_IMAGE_META_KEY + "/" + key;

        // note: the keyword data is currently redundant (ignored by us when loading icons we've written out),
        // but its presence is required by the ImageIO meta-data API.
        
        final IIOMetadataNode text;
        final IIOMetadataNode textRoot;

        if (DEBUG.IO||DEBUG.IMAGE) Log.debug("addData; " + keyword + "=" + comment);

        if (false) {
            // use PNG-specific meta-data -- simpler, but can only be used with PNG's
            text = new IIOMetadataNode("tEXtEntry"); // PNG name
            text.setAttribute("keyword", keyword);
            text.setAttribute("value", comment);
            
            textRoot = new IIOMetadataNode("tEXt"); // PNG name
        } else {
            
            //-----------------------------------------------------------------------------
            // The most generic method -- we should be able to use this with image
            // formats beyond PNG, tho that's untested.  The meta-data format in the tree
            // (root node) should be IOMetadataFormatImpl.standardMetadataFormatName
            // ("javax_imageio_1.0").
            // -----------------------------------------------------------------------------
            
            text = new IIOMetadataNode("TextEntry"); // generic name
            text.setAttribute("keyword", keyword);
            text.setAttribute("value", comment);
            // required, but ignored in PNGMetadata:
            text.setAttribute("encoding", "ISO-8859-1");
            // required, but ignored in PNGMetadata as long as 1st 255 chars all ascii:
            text.setAttribute("language", ""); // apparently safest: empty means undefined
            text.setAttribute("compression", "none");
            
            textRoot = new IIOMetadataNode("Text"); // generic name
        }
        
        textRoot.appendChild(text);

        tree.appendChild(textRoot);
    }

    private static Map<String,Object> extractVueImageMetaData(IIOMetadata metaData) {
        //final org.w3c.dom.Node metaTree
        final IIOMetadataNode metaTree = (IIOMetadataNode) metaData.getAsTree(GENERIC_META_DATA);
        
        if (DEBUG.IMAGE && DEBUG.IO) {
            Log.debug(" FORMAT-NAMES: " + Arrays.asList(metaData.getMetadataFormatNames()));
            Log.debug("GOT META-DATA: " + Util.tags(metaData) + " for format " + GENERIC_META_DATA);
            DocDump.dump(metaTree);
            //DocDump.dump(data.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName));
            //DocDump.dump(data.getAsTree(PNG_FORMAT_NAME));
        }

        return extractVueImageMetaData(metaTree);
    }

    private static Map<String,Object> extractVueImageMetaData(IIOMetadataNode metaTree)
    {
        Map result = Collections.EMPTY_MAP;
        for (org.w3c.dom.Node n : Util.iterable(metaTree.getElementsByTagName("TextEntry"))) {
            //Log.debug("FOUND TEXT NODE " + Util.tags(n) + " " + Util.tags(n.getAttributes()));
            if (DEBUG.IO && DEBUG.DATA) Log.debug("FOUND TEXT NODE " + Util.tags(n));
            //Log.debug("FOUND TEXT " + n.getTextContent());
            for (org.w3c.dom.Node a : Util.iterable(n.getAttributes())) {
                if (DEBUG.IO && DEBUG.DATA) Log.debug("FOUND TEXT ATTRIB " + Util.tags(a));
                String text = a.getNodeValue();
                // we don't bother to check for the keyword=value attribute -- it's all in the value anyway
                if (text != null && text.startsWith(VUE_IMAGE_META_VALUE_PREFIX)) {
                    final String s, key, value;
                    s = text.substring(VUE_IMAGE_META_VALUE_PREFIX.length());
                    key = s.substring(0, s.indexOf(':'));
                    value = s.substring(key.length()+2, s.length()-1);
                    if (DEBUG.IO || DEBUG.DATA) Log.debug(String.format("VUE-DATA: key=%s value=%s", key, Util.tags(value)));

                    if ("cacheFile".equals(key)) {
                        // skip this one -- is not currently needed, and clutters up debug bigtime
                        continue;
                    }
                    
                    if (result == Collections.EMPTY_MAP)
                        result = new HashMap(4);
                    result.put(key, value);
                }
            }

        }
        return result;
    }
    
// Our test w/NODESET works in XML-INGEST w/NY-TIMES RSS feed, but not here...
//                     XPath xpath = XPathFactory.newInstance().newXPath();
//                     //String expression = "javax_imageio_1.0" + "/Text/TextEntry/VueMetaData";
//                     //String expression = "/javax_imageio_1.0" + "/Text";
//                     //String expression = "/javax_imageio_1.0" + "/Text/TextEntry";
//                     String expression = "/Text";
//                     //String expression = "/Text/TextEntry";

//                     Log.debug("Extracting " + Util.tags(expression) + " from " + Util.tags(metaTree));
                    
//                     // First, obtain the element as a node.
                    
//                     NodeList nodeSet = (NodeList) xpath.evaluate(expression,
//                     //Object nodeSet = xpath.evaluate(expression,
//                                                     metaTree,
//                                                     XPathConstants.NODESET); // NODE will only do 1 node
//                         //Log.debug("   Node: " + nodeValue);
                    
//                     Log.debug("NodeList: " + Util.tags(nodeSet) + "; size=" + nodeSet.getLength());
//                     //Log.debug("NodeResult: " + Util.tags(nodeSet));
                    
                    
                    
    
    public static Object getCacheLock() {
        return RawCache;
    }

    static class ImageException extends Exception { ImageException(String s) { super(s); }}
    static class DataException extends ImageException { DataException(String s) { super(s); }}

    public static final String OUT_OF_MEMORY = "Out of memory";

    
    /** An wrapper for readAndCreateImage that deals with exceptions, and puts successful results in the cache */
    // note: we don't actually need to know the second arg is a ListenerRelay -- it could just be a
    // Listener, tho it never is -- this is for clarity -- remember that the callbacks may be
    // happening to a chain of pending listeners, not just one.
    private static Handle loadImageAndCache(final ImageSource imageSRC, final Listener relay)
    {
        Handle imageData = null;

        if (imageSRC.resource != null)
            imageSRC.resource.getProperties().holdChanges();

        try {
            imageData = readImageInAvailableMemory(imageSRC, relay);
        } catch (Throwable t) {
            
            if (DEBUG.IMAGE) Util.printStackTrace(t);

            RawCache.remove(imageSRC.key);
            
            if (relay != null) {
                String msg;
                boolean dumpTrace = false;
                if (t instanceof java.io.FileNotFoundException) {
                    msg = "Not Found: " + t.getMessage();
                }
                else if (t instanceof java.net.UnknownHostException) {
                    msg = "Unknown Host: " + t.getMessage();
                }
                else if (t instanceof java.lang.IllegalArgumentException && t.getMessage().startsWith("LUT has improper length")) {
                    // known java bug: many small PNG images fail to read (effects thumbshots)
                    msg = null; // don't bother to report an error
                }
                else if (t instanceof OutOfMemoryError) {
                    setLowMemory(t);
                    msg = OUT_OF_MEMORY;
                }
                else if (t instanceof ImageException) {
                    msg = (t.getMessage() == NO_READABLE_FOUND ? null : t.getMessage());
                }
                else if (t instanceof ThreadDeath) {
                    msg = "interrupted";
                }
                else if (t.getMessage() != null && t.getMessage().length() > 0) {
                    msg = t.getMessage();
                    dumpTrace = true;
                }
                else {
                    msg = t.toString();
                    dumpTrace = true;
                }

                // this is the one place we deliver caught exceptions
                // during image loading:
                relay.gotImageError(imageSRC.original, msg);

                if (dumpTrace)
                    Log.warn(imageSRC + ":", t);
                else
                    Log.warn(imageSRC + ": " + t);
            }

            if (imageSRC.resource != null)
                imageSRC.resource.getProperties().releaseChanges();
        }

        if (relay != null && imageData != null) {
            relay.gotImage(imageSRC.original, imageData);
        }

        //-----------------------------------------------------------------------------
        // If we were to auto-generate icons in Images, this would be the place to do it.
        //-----------------------------------------------------------------------------
        
        // TODO opt: if this item was loaded from the disk cache, we're needlessly
        // replacing the existing CacheEntry with a new one, instead of
        // updating the old with the new in-memory image buffer.

        if (imageSRC.useCacheFile()) {
            if (imageData != null) {
                File permanentCacheFile = null;
                if (imageSRC.hasCacheFile())
                    permanentCacheFile = ensurePermanentCacheFile(imageSRC.getCacheFile());
                
                if (DEBUG.IMAGE) out("getting cache lock for storing result; " + imageSRC);
                RawCache.put(imageSRC.key, new CacheEntry(imageData, permanentCacheFile));
                
                // If the cache file has moved from tmp to permanent, we'd need to do this to keep imageSRC
                // current, tho at the moment this is a bit overkill as we should no longer need imageSRC
                // at this point, but just in case it wants to update something such as a Resource with
                // a reference to the right cache file, we do this here.
                if (permanentCacheFile != null)
                    imageSRC.setCacheFile(permanentCacheFile);
                
//                 if (imageSRC.resource != null && permanentCacheFile != null)
//                     imageSRC.resource.setCacheFile(permanentCacheFile);
            }
        } else {
            if (imageData.image != null)
                RawCache.put(imageSRC.key, new CacheEntry(imageData, null));
            //RawCache.put(imageSRC.key, new CacheEntry(image, null));
        }

        return imageData;
        
    }

    private static Handle readImageInAvailableMemory(ImageSource imageSRC, Listener listener)
        throws ImageException
    {
        //Image image = null;
        Handle imageData = null;
        
        do {
            try {
                imageData = readAndCreateImage(imageSRC, listener);
            } catch (OutOfMemoryError eom) {
                setLowMemory(eom);
                Log.warn(Util.TERM_YELLOW + "out of memory reading " + imageSRC.readable + ": " + eom + Util.TERM_CLEAR);
                if (Thread.currentThread() instanceof ImgThread) {
                    Log.info("reader sleeping & retrying...");
                    try {
                        // Todo: sleep until all other image load threads have finished, or,
                        // ideally, until there aren't any running (those still running are
                        // blocked).  May actually just be easiest to to abort and re-submit this
                        // to entirely new execution queue, or could just stick it on the end of
                        // the existing queue, tho if this is a network IO thread, we still want to
                        // run it later in it's own thread, so it could just be a task the
                        // re-creates a new LoadThread
                        Thread.currentThread().sleep(1000 * 45);
                    } catch (InterruptedException e) {
                        Log.warn("reader interrupted");
                    }
                    Log.info(Util.TERM_GREEN + "reader re-awakened, retrying... " + imageSRC.readable + Util.TERM_CLEAR);
                } else { // if instanceof POOLTHREAD

                    // we must be running in the thread-pool accessing local disk
                    // (or, e.g., we could be in a print job thread processing immediate requests)

                    if (DEBUG.Enabled) Log.info("EOM; consider re-kicking a LoadTask for " + imageSRC);
                    //e.g., somehing like: kickLoad(imageSRC, listener);
                    
                    throw eom;
               }
            } catch (Throwable t) {
                throw new ImageException("reader failed: " + t.toString());
            }
        } while (imageData == null);

        if (DEBUG.IMAGE) out("readAndCreateImage got " + Util.tags(imageData));

        return imageData;
    }
    

    // todo: this probably wants to move to a resource impl class
    private static void setDateValue(Resource r, String name, long value) {
        if (value > 0)
            r.setProperty(name, new java.util.Date(value).toString());
        // todo: set raw value for compares, but allow prop displayer to convert it?
        // or put a raw Date object in there?
    }

    // todo: this probably wants to move to a resource impl class
    private static void setResourceMetaData(Resource r, java.net.URLConnection uc) {

//         r.getProperties().holdChanges();
//         try {
        
            long len = uc.getContentLength();
            //r.setProperty("url.contentLength", len);
            r.setProperty(CONTENT_SIZE, len); 
            r.setByteSize(len); // todo: update later from cache file size for correctness
            String ct = uc.getContentType();
            //r.setProperty("url.contentType", ct);
            r.setProperty(CONTENT_TYPE, ct);
            if (DEBUG.Enabled && ct != null && !ct.toLowerCase().startsWith("image")) {
                Log.warn("NON-IMAGE CONTENT TYPE [" + ct + "]; " + r);
            }
            setDateValue(r, "URL.expires", uc.getExpiration());
            //setDateValue(r, "url.date", uc.getDate());
            setDateValue(r, CONTENT_ASOF, uc.getDate()); // should probably ignore this an generate ourselves
            //setDateValue(r, "url.lastModified", uc.getLastModified());
            setDateValue(r, CONTENT_MODIFIED, uc.getLastModified());
            
//         } catch (Throwable t) {
//             Util.printStackTrace(t);
//         } finally {
//             r.getProperties().releaseChanges();
//         }
                      
    }
    
    // todo: this probably wants to move to a resource impl class
    private static void setResourceMetaData(Resource r, File f) {
//         r.getProperties().holdChanges();
//         try {
        
            //r.setProperty("file.size", f.length());
            r.setProperty(CONTENT_SIZE, f.length());
            //setDateValue(r, "file.lastModified", f.lastModified());
            setDateValue(r, CONTENT_MODIFIED, f.lastModified());

            //r.setProperty(CONTENT_TYPE, java.net.URLConnection.guessContentTypeFromName(f.getName()));
            // todo: also URLConnection.guessContentTypeFromStream (could use in FileBackedImageInputStream)
                
//         } finally {
//             r.getProperties().releaseChanges();
//         }
            
    }

    private static File makePermanentCacheFile(URI key)
        throws java.io.UnsupportedEncodingException
    {
        return makeCacheFile(key, false);
    }
        
    private static File makeTmpCacheFile(URI key)
        throws java.io.UnsupportedEncodingException
    {
        return makeCacheFile(key, true);
    }
    
    /** @param temporary -- for temporary cache files that have yet to complete (e.g., not all data has arrived)  */
    private static File makeCacheFile(URI key, boolean temporary)
        throws java.io.UnsupportedEncodingException
    {
        final String cacheName;
        if (temporary)
            cacheName = "." + keyToCacheFileName(key);
        else
            cacheName = keyToCacheFileName(key);
        
        File cacheDir = getCacheDirectory();
        File file = null;
        if (cacheDir != null) {
            file = new File(getCacheDirectory(), cacheName);
            try {
                if (!file.createNewFile()) {
                    if (DEBUG.IO) Log.debug("cache file already exists: " + file);
                }
            } catch (java.io.IOException e) {
                String msg = "can't create cache file: " + file;
                if (DEBUG.Enabled)
                    Util.printStackTrace(e, msg);
                else
                    Log.warn(msg, e);
                return null;
            }
            if (!file.canWrite()) {
                Log.warn("can't write cache file: " + file);
                return null;
            }
            if (DEBUG.IMAGE) out("got cache file " + file);
        }
        return file;
    }

    private static File ensurePermanentCacheFile(File file)
    {
        try {
            // chop off the initial "." to make permanent
            // If doesn't start with a dot, this is one of our existing cache
            // files: nothing to do.
            String tmpName = file.getName();
            String permanentName;
            if (tmpName.charAt(0) == '.') {
                permanentName = tmpName.substring(1);
            } else {
                // it's already permanent: we must have loaded this from our cache originally
                return file;
            }
            File permanentFile = new File(file.getParentFile(), permanentName);
            if (file.renameTo(permanentFile)) {
                Log.debug("new perm cache file: " + permanentFile);
                return permanentFile;
            }
        } catch (Throwable t) {
            tufts.Util.printStackTrace(t, "Unable to create permanent cache file from tmp " + file);
        }
        return null;
    }
    

    private static File CacheDir;
    private static File getCacheDirectory()
    {
        if (CacheDir == null) {
            File dir = VueUtil.getDefaultUserFolder();
            CacheDir = new File(dir, "cache");
            if (!CacheDir.exists()) {
                Log.debug("creating cache directory: " + CacheDir);
                if (!CacheDir.mkdir())
                    Log.warn("couldn't create cache directory " + CacheDir);
            } else if (!CacheDir.isDirectory()) {
                Log.warn("couldn't create cache directory (is a file) " + CacheDir);
                return CacheDir = null;
            }
            Log.debug("Got cache directory: " + CacheDir);
        }
        return CacheDir;
    }
//     private static final class ImageProps extends HashMap<String,?> {
//         ImageProps() {
//             super(6);
//         }
//     }

    public static final class Handle {
        public final Image image;
        final Map<String,?> data; // note: shouldn't expose this globally as modifiable
        Handle(Image i, Map<String,?> data) {
            if (i == null)
                throw new NullPointerException("null image");
            if (data == null)
                throw new NullPointerException("null data");
            this.image = i;
            this.data = data;
        }
        Handle(Image i) {
            this(i, Collections.EMPTY_MAP);
        }

        private Handle() {
            image = null;
            data = Collections.EMPTY_MAP;
        }

        public static Handle emptyInstance() {
            return new Handle();
        }
        
        @Override public String toString() {
            return Util.tags(image) + data;
        }
    }

    private static Image handleToImage(Handle h) {
        return h == null ? null : h.image;
    }

    //private static Handle NULL_HANDLE = new Handle();

    /**
     * @param imageSRC - see ImageSource ("anything" that we can get an image data stream from)
     * @param listener - an Images.Listener: if non-null, will be issued a callback for when the size is first obtained
     * @return the loaded image, or null if none found
     */
    private static boolean FirstFailure = true;
    private static Handle readAndCreateImage(ImageSource imageSRC, Images.Listener listener)
        throws java.io.IOException, ImageException
    {
        if (DEBUG.IMAGE) out("trying: " + imageSRC);

        InputStream urlStream = null; // if we create one, we need to keep this ref to close it later
        File tmpCacheFile = null; // if we create a tmp cache file, it will be put here

        int dataSize = -1;
        
        if (DEBUG.IMAGE && imageSRC.resource != null) {
            imageSRC.resource.setDebugProperty("image.read", imageSRC.readable);
        }

        if (imageSRC.hasCacheFile()) {
            // just point us at the cache file: ImageIO will create the input stream
            imageSRC.readable = imageSRC.getCacheFile();
            if (DEBUG.IMAGE && imageSRC.resource != null) {
                imageSRC.resource.setDebugProperty("image.cache", imageSRC.getCacheFile());
                out("reading cache file: " + imageSRC.getCacheFile());
            }

            // note: can get away with this because imageSRC.resource will
            // be null if this is for a preview icon, so don't need to worry
            // about getting wrong size todo: a hack anyway -- include
            // in clean-up of meta-data setting

            if (imageSRC.resource != null) {
                imageSRC.resource.setProperty(CONTENT_SIZE, imageSRC.getCacheFile().length());
                // java has no creation date for Files!  Well, last modified good enough...
                setDateValue(imageSRC.resource, CONTENT_ASOF, imageSRC.getCacheFile().lastModified());
            }
        
        } else if (imageSRC.readable instanceof java.net.URL) {

            final URL url = (URL) imageSRC.readable;

            int tries = 0;
            boolean success = false;

            final boolean debug = DEBUG.IMAGE || DEBUG.IO;

            do {

                final URLConnection conn = UrlAuthentication.getAuthenticatedConnection(url);
                urlStream = conn.getInputStream();

                if (imageSRC.resource != null) {
                    dataSize = conn.getContentLength();
                    try {
                        setResourceMetaData(imageSRC.resource, conn);
                    } catch (Throwable t) {
                        // Don't fail if a problem with meta data: still give
                        // a chance for the content to work...
                        Util.printStackTrace(t, "URLConnection Meta Data Failure");
                        //imageSRC.resource.setProperty("MetaDataFailure", t.toString());
                    }
                }
                
                
                if (!imageSRC.useCacheFile()) {
                    
                    imageSRC.readable = urlStream;
                    success = true;
                    
                } else {

                    tmpCacheFile = makeTmpCacheFile(imageSRC.key);
                    imageSRC.setCacheFile(tmpCacheFile);  // will be made permanent if no errors
                    
                    if (imageSRC.hasCacheFile()) {
                        try {
                            imageSRC.readable = new FileBackedImageInputStream(urlStream, tmpCacheFile, listener);
                            success = true;
                        } catch (Images.DataException e) {
                            Log.warn(imageSRC + ": " + e);
                            if (++tries > 1) {
                                final String msg = "Try #" + tries + ": " + e;
//                                 if (DEBUG.Enabled)
//                                     Util.printStackTrace(msg);
//                                 else
                                Log.warn(msg);
                                throw e;
                            } else {
                                Log.info("second try for " + imageSRC);
                                urlStream.close();
                            }
                            // try the reconnect one more time
                        }
                    } else {
                        // unable to create cache file: read directly from the stream
                        Log.warn("Failed to create cache file " + tmpCacheFile);
                        imageSRC.readable = urlStream;
                        success = true;
                    }
                }
                
            } while (!success && tries < 2);

        } else if (imageSRC.readable instanceof java.io.File) {
            if (DEBUG.IMAGE) Log.debug("Loading local file " + imageSRC.readable);
            if (imageSRC.resource != null)
                setResourceMetaData(imageSRC.resource, (File) imageSRC.readable);
        }

        if (imageSRC.resource != null) { // in case any held changes
            //if (DEBUG.DR) imageSRC.resource.setDebugProperty("readsrc", Util.tags(imageSRC.readable));
            imageSRC.resource.getProperties().releaseChanges();
        }

        final ImageInputStream inputStream;

        if (imageSRC.readable instanceof ImageInputStream) {
            inputStream = (ImageInputStream) imageSRC.readable;
        } else if (imageSRC.readable != null) {
            //if (DEBUG.IMAGE) out("ImageIO converting " + tag(imageSRC.readable) + " to InputStream...");
            inputStream = ImageIO.createImageInputStream(imageSRC.readable);
        } else {
            throw new ImageException(NO_READABLE_FOUND);
            //Log.warn("not readable: " + imageSRC);
        }

        if (DEBUG.IMAGE) out("Got ImageInputStream " + inputStream);

        if (inputStream == null)
            throw new ImageException("Can't Access [" + imageSRC.readable + "]"); // e,g., local file permission denied

        ImageReader reader = getDecoder(inputStream, imageSRC);

        if (reader == null) {
            badStream: {
                if (FirstFailure) {
                    // This FirstFailure code was an attempt to deal with what is now handled
                    // via DataException, but it's not a bad idea to keep it around.
                    FirstFailure = false;
                    Log.warn("No reader found: first failure, rescanning for codecs: " + imageSRC);
                    // TODO: okay, problem appears to be with the URLConnection / stream? Is only
                    // getting us tiny amount of bytes the first time...
                    if (DEBUG.Enabled) tufts.Util.printStackTrace("first failure: " + imageSRC);
                    ImageIO.scanForPlugins();
                    reader = getDecoder(inputStream, imageSRC);
                    if (reader != null)
                        break badStream;
                }
                if (DEBUG.IMAGE) out("NO IMAGE READER FOUND FOR " + imageSRC);
                throw new ImageException("Unreadable Image Stream");
            }
        }

        if (DEBUG.IMAGE) out("Chosen ImageReader for stream: " + reader + "; format=" + reader.getFormatName());
        
        //reader.addIIOReadProgressListener(new ReadListener());
        //out("added progress listener");

        final boolean allowSeekBack = false;
        final boolean ignoreMetadata;

        if (imageSRC.isDiskCacheEntry()) {
            ignoreMetadata = false;
        } else {
            // Note that basic meta-data is still present on the resulting image when we
            // say to ignore it, tho extra meta-data we've added won't be there.  This
            // may just be because it's creating it from scratch for the newly built
            // image instead of reading it from the on-disk image -- not sure.
            ignoreMetadata = true;
        }
        
        reader.setInput(inputStream, allowSeekBack, ignoreMetadata);
        if (DEBUG.IMAGE) out("Input for reader set to " + inputStream);
        if (DEBUG.IMAGE) out("Getting size...");
        int w = reader.getWidth(0);
        int h = reader.getHeight(0);
        if (DEBUG.IMAGE) out("ImageReader got size " + w + "x" + h);

        if (w == 0 || h == 0)
            throw new ImageException("invalid size: width=" + w + "; height=" + h);

        if (imageSRC.resource != null) {
            if (DEBUG.IMAGE || DEBUG.THREAD || DEBUG.RESOURCE)
                out("setting resource image.* meta-data for " + imageSRC.resource);
            
            imageSRC.resource.getProperties().holdChanges();
            imageSRC.resource.setProperty(Resource.IMAGE_WIDTH,  Integer.toString(w));
            imageSRC.resource.setProperty(Resource.IMAGE_HEIGHT, Integer.toString(h));
            imageSRC.resource.setProperty(Resource.IMAGE_FORMAT, reader.getFormatName());
            imageSRC.resource.setCached(true);

            // Note: If MetaDataPane is not carefully coded, this call
            // can lead to a DEADLOCK against the AWT thread (e.g.,
            // entering PropertyMap.removeListener)
            imageSRC.resource.getProperties().releaseChanges();
        }

        if (listener != null) {
            if (DEBUG.IMAGE) out("Sending size to " + tag(listener));
            listener.gotImageSize(imageSRC.original, w, h, dataSize, null);
        }

        // FYI, if fetch meta-data, will need to trap exceptions here, as if there are
        // any problems or inconsistencies with it, we'll get an exception, even if the
        // image is totally readable.
        //out("meta-data: " + reader.getImageMetadata(0));

        //-----------------------------------------------------------------------------
        // Now read the image, creating the BufferedImage (or otherwise)
        // 
        // Todo performance: using Toolkit.getImage on MacOSX gets us OSXImages, instead
        // of the BufferedImages which we get from ImageIO, which are presumably
        // non-writeable, and may perform better / be cached at the OS level.  This of
        // course would only work for the original java image types: GIF, JPG, and PNG.
        //-----------------------------------------------------------------------------

        if (DEBUG.IMAGE || DEBUG.IO) out("reading " + imageSRC + "; " + reader + "...");
        
        Image image = null;
        Map<String,Object> imageData = Collections.EMPTY_MAP;
        Throwable exception = null;

        try {
            image = reader.read(0);
            if (DEBUG.Enabled) out("    got " + imageSRC + ".");
            //testImageInspect(reader, image, imageSRC);

            //========================================================================================
            //========================================================================================
            //========================================================================================
            // IF LISTENER IS NON-NULL, then fetch full-size, only to make a callback
            // analogue to gotImageSize.  Tho would be better to stuff this info into
            // the cache, AS, OH, CRAP, WE NEED TO STUFF THIS INFO INTO THE CACHE....
            // Subsequent callers, after load, are still going to need the full pixel
            // size.
            //========================================================================================
            //========================================================================================
            //========================================================================================

            if (listener != null && !ignoreMetadata) {
                // look for meta-data we may have written with the image:
                
                // Note that arbitrary image meta-data created by god knows what app may
                // at any time be considered "bad" according to java, and cause an
                // exception to be thrown here.  E.g., reading a random JPG:
                // "javax.imageio.IIOException: ICC APP2 encountered without prior
                // JFIF!"  thrown from com.sun.imageio.plugins.jpeg.JPEGMetadata.

                // If we successfully wrote the meta-data ourselves, we should be pretty
                // darn safe tho.

                try {
                    // note: the meta-data should have been cached by the reader -- it's
                    // normally stored & read first
                    final IIOMetadata data = reader.getImageMetadata(0);

                    imageData = extractVueImageMetaData(data);

                    String sourceSize = (String) imageData.get("sourceSize");

                    if (sourceSize != null) {
                        if (DEBUG.IO) Log.debug("FOUND SOURCE SIZE[" + sourceSize + "]");
                        String[] dim = sourceSize.split(",");
                        int sw = Integer.parseInt(dim[0]);
                        int sh = Integer.parseInt(dim[1]);
                        if (DEBUG.IO || DEBUG.IMAGE) Log.debug(String.format("parsed meta-data source size: %dx%d", sw, sh));
                        //imageData.put("sourceWidth", w);
                        //imageData.put("sourceHeight", h);
                        //listener.gotImageUpdate(Progress.SOURCE_SIZE, null); // NEED TO PASS IMAGE WIDTH/HEIGHT
                        int[] srcSize = new int[2];
                        srcSize[0] = sw;
                        srcSize[1] = sh;
                        imageData.put("sourcePixels", srcSize);
                        // we could deliver this, but we'll eventually get it with the image
                        //listener.gotImageSize(imageSRC.original, w, h, dataSize, srcSize);
                    } else {
                        Log.warn("failed to find sourceSize in meta-data: " + imageData + " for " + imageSRC);
                    }

                } catch (Throwable t) {
                    Log.error("loading meta-data for " + imageSRC, t);
                }
            }
            
        } catch (Throwable t) {
            exception = t;
        } finally {
            reader.reset();
            inputStream.close();
            if (urlStream != null)
                urlStream.close();
        }


        if (exception instanceof OutOfMemoryError) {
            setLowMemory(exception);
            throw (Error) exception;
        } else if (exception != null) {
            throw new ImageException("reader.read(0) failure: " + exception);
        }

        return new Handle(image, imageData);
    }


    private void testImageInspect(ImageReader reader, Image image, ImageSource imageSRC) {
        try {
            int thumbs = reader.getNumThumbnails(0);
            if (thumbs > 0)
                Log.info("thumbs: " + thumbs + "; " + imageSRC);
        } catch (Throwable t) {
            Log.debug("getNumThumbnails", t);
        }
        if (DEBUG.WORK && image != null) {
            String[] tryProps = new String[] { "name", "title", "description", "comment" };
            for (int i = 0; i < tryProps.length; i++) {
                Object p = image.getProperty(tryProps[i], null);
                if (p != null && p != java.awt.Image.UndefinedProperty)
                    System.err.println("FOUND PROPERTY " + tryProps[i] + "=" + p);
            }
        }
        
    }

    static Handle createIcon(Image source, int maxSide) {

        final Handle icon;

//         if (USE_SCALED_INSTANCE) {
//             return producePlatformIcon(source, maxSide);
//         } else {
//             return produceDrawnIcon(source, maxSide);
//         }
        if (DEBUG.Enabled) Log.debug("image pre-scaled: " + Util.tags(source) + " -> toMaxSide " + maxSide);
        icon = produceDrawnIcon(source, maxSide);
        if (DEBUG.Enabled) Log.debug("icon post-scaled: " + icon);

        return icon;
    }
    
    public static java.awt.Dimension fitInto(int maxSide, int srcW, int srcH) {
        int width, height;

        if (srcW > srcH) {
            width = maxSide;
            height = srcH * maxSide / srcW;
            // todo: ensure precision in above, and round to nearest EVEN value (for memory alignment)
        } else {
            height = maxSide;
            width = srcW * maxSide / srcH;
            // todo: ensure precision in above, and round to nearest EVEN value (for memory alignment)
        }

        //Log.debug(String.format("%dx%d -> (%d) %dx%d", srcW, srcH, maxSide, width, height));

        return new java.awt.Dimension(width, height);
    }

    public static tufts.vue.Size fitInto(float maxSide, int[] srcSize) {
        return fitInto(maxSide, srcSize[0], srcSize[1]);
    }
    
    public static tufts.vue.Size fitInto(float maxSide, Size s) {
        return fitInto(maxSide, s.width, s.height);
    }
    
    public static tufts.vue.Size fitInto(float maxSide, float srcW, float srcH) {

        final float width, height;

        if (srcW < 1 || srcH < 1) {
            Log.error("fitInto " + maxSide + ": bad source size " + srcW + "x" + srcH);
            // make sure we never generate NaN results
            if (srcW < 1)
                srcW = 16;
            if (srcH < 1)
                srcH = 16;
        }

        if (srcW > srcH) {
            width = maxSide;
            height = srcH * maxSide / srcW;
        } else {
            height = maxSide;
            width = srcW * maxSide / srcH;
        }

        return new tufts.vue.Size(width, height);
    }
    

    private static Handle produceDrawnIcon(Image source, int maxSide)
    {
        final java.awt.Dimension size = fitInto(maxSide, source.getWidth(null), source.getHeight(null));

        final Image iconSource;
        int transparency;

        if (source instanceof BufferedImage)
            transparency = ((BufferedImage)source).getTransparency();
        else
            transparency = Transparency.OPAQUE;

        if (ALLOW_HIGH_QUALITY_ICONS /*&& DrawContext.isImageQualityRequested()*/) {
            // this is clever: we can still make use of the high-quality image
            // smoothing (tho it's still quite slow) by copying out the quality
            // image data then immediately flusing the created image.  And with our
            // old 1GB busting use-case we're still down at 82MB after GC cool-down.
            // Amazing.
            iconSource = source.getScaledInstance(size.width, size.height, Image.SCALE_SMOOTH);

            if (iconSource instanceof sun.awt.image.ToolkitImage) { // will probably always be a ToolkitImage
                // generally neeeded in case the the image has alpha, so transparency will be Transparency.TRANSLUCENT
                // This will normally have already been pulled from the BufferedImage source, but just in
                // case the source wasn't a BufferedImage:
                transparency = ((sun.awt.image.ToolkitImage)iconSource).getColorModel().getTransparency();
            }
                
            // note: there are supposed to be faster methods available for generating
            // similar quality that involve multi-pass down-scaling that have been
            // documented, tho the benchmarks don't likely include a comparison to
            // the most recent Mac OS X Java 1.6 implementation, that may use
            // CoreImage underneath. See:
            // http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html
                
        } else {
            iconSource = source;
        }

            
        final Image icon = tufts.vue.gui.GUI.getDeviceConfigForWindow(null)
            .createCompatibleImage(size.width, size.height, transparency);

        final Graphics2D g = (Graphics2D) icon.getGraphics();
            
        if (iconSource == source) {
            // The source is the original raw image -- we'll be down-scaling during the
            // drawImage, so quality is going to be low.  Note: setImageQuality is not much
            // help (if any?) as to quality in Java 1.6 (used to make a big difference in java
            // 1.5), but we try anyway.
            DrawContext.setImageQuality(g, true);
        }

        g.drawImage(iconSource, 0, 0, size.width, size.height, null);

        if (iconSource != source)
            iconSource.flush(); // crucial to release the memory consumed
        
        return new Handle(icon); // note: could load with source size info, but not needed in this case
    }

    private static Image producePlatformIcon(Image source, int maxSide)
    {
        final Dimension size = fitInto(maxSide, source.getWidth(null), source.getHeight(null));

        final Image icon;

        // Note that on Mac OS X this returns an apple.awt.OSXImage's, and does so
        // IMMEDIATELY, which means they will later hang the paint thread the first time
        // they're painted (and your entire application) as the image production
        // (scaling/smoothing) takes place in lazy fashion at the last possible moment.

        // Note that whatever's returned will also probably always be an instance of
        // sun.awt.image.ToolkitImage (as OSXImage is), but how the various scaling
        // hints are handled, when/if they're lazy evaluated, etc is going to vary by
        // platform.

        // SCALE_SMOOTH produces dramatically higher quality than SCALE_FAST on Mac,
        // but is significantly slower
        icon = source.getScaledInstance(size.width, size.height, Image.SCALE_SMOOTH);
            
        // On Mac OSX, immediately drawing the image to a scratch buffer will force
        // the loading (and the actual scaling) of the apple.awt.OSXImage

//         if (false) {
//             // (would we have wanted to render icon, not _image?)                
//             ScratchGraphics.drawImage(source, 0, 0, size.width, size.height, null); 
//         }
            
        // This doesn't appear to be having a big impact on memory -- OSXImage is
        // apparently holding a reference to the underlying content.  Even if all
        // images are read & scaled sequentially, we run out of memory (bumping
        // against 1GB using our test case).

        return icon;
    }
//     private static final Image ScratchImage;
//     private static final Graphics2D ScratchGraphics;

//     static {
//         if (USE_SCALED_INSTANCE) {
//             ScratchImage = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
//             ScratchGraphics = (Graphics2D) ScratchImage.getGraphics();
//         } else {
//             ScratchImage = null;
//             ScratchGraphics = null;
//         }
//     }

    
    public static void dumpImage(Image image, String debug) {

        Object g;
        try {
            g = image.getGraphics();
        } catch (Throwable t) {
            g = t;
        }
        Object s;
        try {
            s = image.getSource();
        } catch (Throwable t) {
            s = t;
        }
        
        Log.debug("Image " + debug + ": " + Util.tags(image)
                  + "\n\t    size: " + image.getWidth(null) + "x" + image.getHeight(null)
                  + "\n\t  source: " + Util.tags(s)
//                  + "\n\tgraphics: " + Util.tags(g)
//                   + "\n\taccelPri: " + image.getAccelerationPriority()
//                   + "\n\t    caps: " + image.getCapabilities(null)
                  );
        
        //Util.dump(image.getClass().getMethods());
    }

    private static ImageReader getDecoder(ImageInputStream istream, ImageSource imageSRC)
    {
        java.util.Iterator iri = ImageIO.getImageReaders(istream);

        ImageReader reader = null;
        int idx = 0;
        while (iri.hasNext()) {
            final ImageReader ir = (ImageReader) iri.next();

            String formatName;
            try {
                formatName = ir.getFormatName();
            } catch (Throwable t) {
                formatName = "[" + t + "]";
            }
            
            if (reader == null) {
                reader = ir;
            }
//             else if ("ico".equalsIgnoreCase(formatName)) {
//                 try {
//                     if (imageSRC.key.toString().toLowerCase().endsWith(".ico")) {
//                         if (DEBUG.IMAGE) out("CHOOSING ICO READER FOR .ICO");
//                         reader = ir;
//                     }
//                 } catch (Throwable t) {
//                     Log.error("getDecoder: " + imageSRC + " " + t);
//                 }
//             }
            

            if (DEBUG.IMAGE) {
                out("\t found ImageReader #" + idx + ": " + Util.tags(ir)
                    + "; format=" + formatName
                    + "; provider=" + Util.tags(ir.getOriginatingProvider()));
            }
            idx++;
        }

        return reader;
    }
    
    // nl.ikarus.nxt.priv.imageio.icoreader.lib.ICOReaderSpi.registerIcoReader(); // not needed: is self registering or SPI providing
    // nl.ikarus.nxt.priv.imageio.icoreader.lib.ICOReader [this reader, from ICOReader-1.04.jar, appears to mostly work]

    /* Apparently, not all decoders actually report to the listeners, (e.g., TIFF), so we're not using this for now */
    private static class ReadListener implements IIOReadProgressListener {
        public void sequenceStarted(ImageReader source, int minIndex) {
            out("sequenceStarted; minIndex="+minIndex);
        }
        public void sequenceComplete(ImageReader source) {
            out("sequenceComplete");
        }
        public void imageStarted(ImageReader source, int imageIndex) {
            out("imageStarted; imageIndex="+imageIndex);
        }
        public void imageProgress(ImageReader source, float pct) {
            out("imageProgress; "+(int)(pct + 0.5f) + "%");
        }
        public void imageComplete(ImageReader source) {
            out("imageComplete");
        }
        public void thumbnailStarted(ImageReader source, int imageIndex, int thumbnailIndex){}
        public void thumbnailProgress(ImageReader source, float percentageDone) {}
        public void thumbnailComplete(ImageReader source) {}
        public void readAborted(ImageReader source) {
            out("readAborted");
        }
    }

    private static String tag(Object o) {
        if (o instanceof java.awt.Component)
            return tufts.vue.gui.GUI.name(o);
        else if (o instanceof LWComponent)
            return o.toString();
        //return ((LWComponent)o).getDiagnosticLabel();
        else
            return Util.tags(o);
        
//         String s = Util.tag(o);
//         s += "[";
//         if (o instanceof Thread) {
//             s += ((Thread)o).getName();
//         } else if (o instanceof BufferedImage) {
//             BufferedImage bi = (BufferedImage) o;
//             s += bi.getWidth() + "x" + bi.getHeight();
//         } else if (o != null)
//             s += o.toString();
//         return s + "]";
    }
    
    private static void out(Object o) {
        Log.debug(o);
        //Log.debug((o==null?"null":o.toString()));

        /*
        String s = "Images " + (""+System.currentTimeMillis()).substring(8);
        s += " [" + Thread.currentThread().getName() + "]";
        System.err.println(s + " " + (o==null?"null":o.toString()));
        */
    }

    
    /*
    private static void copyStreamToFile(InputStream in, File file)
        throws java.io.IOException
    {
        ByteBuffer buf = ByteBuffer.allocate(2048);
        FileOutputStream fout = new FileOutputStream(file);
        FileChannel fcout = fout.getChannel();
        ReadableByteChannel chin = Channels.newChannel(in);

        while (true) { 
            buf.clear(); 
            int r = chin.read(buf);
            //out("read " + r + " bytes");
            if (DEBUG.IMAGE) System.err.print(r + "; ");
            if (r == -1)
                break; 
            buf.flip(); 
            fcout.write(buf);
        }

        fcout.close();
        chin.close();
        if (DEBUG.IMAGE) out("\nFILLED " + file);
    }

    
    private static File cacheURLContent(URL url, InputStream in)
        throws java.io.IOException
    {
        File file = getCacheFile(url);
        copyStreamToFile(in, file);
        return file;
    }
    */

    /*
    static {
         // ImageIO file caching is a runtime-only scheme for allowing
         // file streams to seek backwards: nothing to with a presistant store.
         // It's also on by default.
         javax.imageio.ImageIO.setUseCache(true);
         javax.imageio.ImageIO.setCacheDirectory(new java.io.File("/tmp"));
     }
    */

    public static void main(String args[]) throws Exception {

        // GUI init required for fully loading all image codecs (tiff gets left behind otherwise)
        // Ah: the TIFF reader in Java 1.5 apparently comes from the UI library:
        // [Loaded com.sun.imageio.plugins.tiff.TIFFImageReader
        // from /System/Library/Frameworks/JavaVM.framework/Versions/1.5.0/Classes/ui.jar]

        VUE.init(args);
        
        System.out.println(java.util.Arrays.asList(javax.imageio.ImageIO.getReaderFormatNames()));
        System.out.println(java.util.Arrays.asList(javax.imageio.ImageIO.getReaderMIMETypes()));

        String filename = args[0];
        java.io.File file = null;
        java.net.URL url = null;
        Object imageSRC;
        if (args[0].startsWith("http:") || args[0].startsWith("file:"))
            imageSRC = url = new java.net.URL(filename);
        else
            imageSRC = file = new java.io.File(filename);

        DEBUG.IMAGE=true;

////getImage(imageSRC, new LWImage()); new Impl no longer an Images.Listener
        //loadImage(imageSRC, null);


        /*
          
        ImageInputStream iis = ImageIO.createImageInputStream(imageSRC);

        out("Got ImageInputStream " + iis);
        
        java.util.Iterator i = ImageIO.getImageReaders(iis);

        ImageReader IR = null;
        int idx = 0;
        while (i.hasNext()) {
            ImageReader ir = (ImageReader) i.next();
            if (IR == null)
                IR = ir;
            out("\tfound ImageReader #" + idx + " " + ir);
            idx++;
        }

        if (IR == null) {
            out("NO IMAGE READER FOUND FOR " + imageSRC);
            if (file == null)
                System.err.println("ImageIO.read got: " + ImageIO.read(url));
            else
                System.err.println("ImageIO.read got: " + ImageIO.read(file));
            //System.out.println("Reading " + file);
            System.exit(0);
        }

        out("Chosen ImageReader for stream " + IR + " formatName=" + IR.getFormatName());
        

        IR.addIIOReadProgressListener(new ReadListener());
        out("added progress listener");

        out("Reading " + IR);
        IR.setInput(iis);
        out("Input for reader set to " + iis);
        //out("meta-data: " + IR.getImageMetadata(0));
        out("Getting size...");
        int w = IR.getWidth(0);
        int h = IR.getHeight(0);
        out("ImageReader got size " + w + "x" + h);
        BufferedImage bi = IR.read(0);
        out("ImageReader.read(0) got " + bi);

        */




        /*
          The below code requires the JAI libraries:
          // JAI (Java Advandced Imaging) libraries
          /System/Library/Java/Extensions/jai_core.jar
          /System/Library/Java/Extensions/jai_codec.jar

          Using this code below will also get us decoding .fpx images,
          tho we would need to convert it from the resulting RenderedImage / PlanarImage
          
        */

        /*
        try {
            // Use the ImageCodec APIs
            com.sun.media.jai.codec.SeekableStream stream = new com.sun.media.jai.codec.FileSeekableStream(filename);
            String[] names = com.sun.media.jai.codec.ImageCodec.getDecoderNames(stream);
            System.out.println("ImageCodec API's found decoders: " + java.util.Arrays.asList(names));
            com.sun.media.jai.codec.ImageDecoder dec =
                com.sun.media.jai.codec.ImageCodec.createImageDecoder(names[0], stream, null);
            java.awt.image.RenderedImage im = dec.decodeAsRenderedImage();
            System.out.println("ImageCodec API's got RenderedImage: " + im);
            Object image = javax.media.jai.PlanarImage.wrapRenderedImage(im);
            System.out.println("ImageCodec API's got PlanarImage: " + image);
        } catch (Exception e) {
            e.printStackTrace();
        }

        // We're not magically getting any new codec's added to ImageIO after the above code
        // finds the .fpx codec...
        
        System.out.println(java.util.Arrays.asList(javax.imageio.ImageIO.getReaderFormatNames()));
        System.out.println(java.util.Arrays.asList(javax.imageio.ImageIO.getReaderMIMETypes()));
        */


     }

}




/**
 * An implementation of <code>ImageInputStream</code> that gets its
 * input from a regular <code>InputStream</code>.  As the data
 * is read, is it backed by a File for seeking backward.
 *
 */
class FileBackedImageInputStream extends ImageInputStreamImpl
{
    private static final org.apache.log4j.Logger Log = org.apache.log4j.Logger.getLogger(FileBackedImageInputStream.class);

    private static final int BUFFER_LENGTH = 2048;

    private final RandomAccessFile cache;
    private final byte[] streamBuf = new byte[BUFFER_LENGTH];
    private final InputStream stream;
    
    private long length = 0L;
    private boolean foundEOF = false;

    private final Images.Listener listener;

    private final File file;

    // todo opt: pass in content size up front to init RAF to full size at start
    /**
     * @param stream - image data stream -- will be closed when reading is done
     * @param file - file to write incoming data to to use as cache
     */
    public FileBackedImageInputStream(InputStream stream, File file, Images.Listener listener)
        throws IOException, Images.ImageException
    {
        if (stream == null || file == null)
            throw new IllegalArgumentException("FileBackedImageInputStream: stream or file is null");

        this.stream = stream;
        this.cache = new RandomAccessFile(file, "rw");
        this.file = file;
        this.listener = listener;

        this.cache.setLength(0); // in case already there

        if (true) {

            final byte[] testBuf = new byte[64];
            final int got = read(testBuf);
            super.seek(0); // put as back at the start
            
            final String contentHead = new String(testBuf, 0, got, "US-ASCII");
            
            if (DEBUG.IMAGE) {
                Log.debug(String.format("ContentHead; got=%d; streamPos=%d; length=%d [%s]",
                                        got,
                                        streamPos,
                                        length,
                                        contentHead.trim()));

                // For inspecting largeer content-head's in log debug stream:
                // compress strings of newlines/whitespaces into single newlines:
                //contentHead = contentHead.replaceAll("(\\n\\s*)(\\n\\s*)+", "\n");
                // or just compress all whitespace down to single spaces:
                //contentHead = contentHead.replaceAll("\\s+", " ");
                //Log.debug("CONTENT-HEAD:\n" + contentHead + "\n-------");
            }

        
            final String trimmed = contentHead.trim();
            final String matcher = trimmed.substring(0, Math.min(16, trimmed.length())).toUpperCase();

            if (matcher.startsWith("<HTML>") || matcher.startsWith("<!DOCTYPE")) {
                if (DEBUG.IMAGE)
                    Log.warn("Stream " + stream + " contains HTML, not image data: [" + contentHead.trim() + "]");
                else
                    Log.info("Stream contains HTML, not image data: see cache file for HTML sample:\n\t" + file);
                
                // DEBUG: we force this readUntil to get more info on the streams that are starting
                // with <HTML> every once in a while: we can be sure to have a cache file with a bit 
                // of data in it we can inspect afterwords.
                readUntil(BUFFER_LENGTH);

                if (DEBUG.IMAGE) {
                    byte[] buf = new byte[BUFFER_LENGTH];
                    int n = read(buf);
                    super.seek(0);
                    Log.debug("Cache contents:\n");
                    System.out.println(new String(buf, 0, n, "US-ASCII"));
                }
                
                close();
                throw new Images.DataException("Content is HTML, not image data");
                
            } else {
                readUntil(BUFFER_LENGTH);  // debug: same reason as above
            }

            // setting this at the end will prevent any gotImageProgress callbacks to any listener
            // until we at know it's not an HTML data stream
            //this.listener = listener;
        }
    }

    /*
    public void seek(long pos) throws IOException {
        System.err.println("SEEK " + pos);
        super.seek(pos);
    }
    */

    /**
     * Ensures that at least <code>pos</code> bytes are cached,
     * or the end of the source is reached.  The return value
     * is equal to the smaller of <code>pos</code> and the
     * length of the source file.
     */
    private long readUntil(long pos) throws IOException {

        //System.err.println("<=" + pos + "; ");
        
        // We've already got enough data cached
        if (pos < length)
            return pos;

        // pos >= length but length isn't getting any bigger, so return it
        if (foundEOF)
            return length;

        long len = pos - length;
        cache.seek(length);
        while (len > 0) {
            // Copy a buffer's worth of data from the source to the cache
            // BUFFER_LENGTH will always fit into an int so this is safe
            final int nbytes = stream.read(streamBuf, 0, (int)Math.min(len, (long)BUFFER_LENGTH));
            if (nbytes == -1) {
                if (DEBUG.IMAGE && DEBUG.IO) System.err.println("<EOF @ " + length + ">");
                foundEOF = true;
                return length;
            }

            //if (DEBUG.IMAGE && DEBUG.IO) System.err.format("+%4d; ", nbytes);
            if (DEBUG.IO) Log.debug(String.format("+%4d bytes; %7d total", nbytes, length+nbytes));
            cache.write(streamBuf, 0, nbytes);
            len -= nbytes;
            length += nbytes;
            //System.out.println("READ TO " + length);
            if (listener != null)
                listener.gotImageProgress(stream, length, -1);
        }

        return pos;
    }

    public int read() throws IOException {
        bitOffset = 0;
        long next = streamPos + 1;
        long pos = readUntil(next);
        if (pos >= next) {
            if (DEBUG.IMAGE && DEBUG.IO) Log.debug("SEEK " + (streamPos+1));
            cache.seek(streamPos++);
            return cache.read();
        } else {
            return -1;
        }
    }

    public int read(byte[] b, int off, int len) throws IOException {
        if (b == null)
            throw new NullPointerException();

        if (off < 0 || len < 0 || off + len > b.length || off + len < 0)
            throw new IndexOutOfBoundsException();

        if (len == 0)
            return 0;


        checkClosed();

        bitOffset = 0;

        long pos = readUntil(streamPos + len);

        // len will always fit into an int so this is safe
        len = (int)Math.min((long)len, pos - streamPos);
        if (len > 0) {
            if (DEBUG.IMAGE && DEBUG.IO) Log.debug("SEEK " + streamPos);
            cache.seek(streamPos);
            cache.readFully(b, off, len);
            streamPos += len;
            return len;
        } else {
            return -1;
        }
    }

    public void close() throws IOException {
        super.close();
        cache.close();
        stream.close();
    }

    public String toString() {
        return getClass().getName() + "[" + file.toString() + "]";
    }

}


