/*
 * XCat.java
 * Copyright (C) 2001 The Free Software Foundation
 * 
 * This file is part of GNU JAXP, a library.
 *
 * GNU JAXP is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 * 
 * GNU JAXP is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 * As a special exception, if you link this library with other files to
 * produce an executable, this library does not by itself cause the
 * resulting executable to be covered by the GNU General Public License.
 * This exception does not however invalidate any other reasons why the
 * executable file might be covered by the GNU General Public License. 
 */


package gnu.xml.util;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.StringTokenizer;
import java.util.Stack;
import java.util.Vector;

import org.xml.sax.Attributes;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXNotRecognizedException;
import org.xml.sax.SAXParseException;
import org.xml.sax.XMLReader;

import org.xml.sax.ext.DefaultHandler2;
import org.xml.sax.ext.EntityResolver2;

import org.xml.sax.helpers.XMLReaderFactory;

/**
 * Packages <a href=
    "http://www.oasis-open.org/committees/entity/spec-2001-08-06.html"
    >OASIS XML Catalogs</a>,
 * primarily for entity resolution by parsers.
 * That specification defines an XML syntax for mappings between
 * identifiers declared in DTDs (particularly PUBLIC identifiers) and
 * locations.  SAX has always supported such mappings, but conventions for
 * an XML file syntax to maintain them have previously been lacking.
 *
 * <p> This has three main operational modes.  The primary intended mode is
 * to create a resolver, then preloading it with one or more site-standard
 * catalogs before using it with one or more SAX parsers: <pre>
 *	XCat	catalog = new XCat ();
 *	catalog.setErrorHandler (diagnosticErrorHandler);
 *	catalog.loadCatalog ("file:/local/catalogs/catalog.cat");
 *	catalog.loadCatalog ("http://shared/catalog.cat");
 *	...
 *	catalog.disableLoading ();
 *	parser1.setEntityResolver (catalog);
 *	parser2.setEntityResolver (catalog);
 *	...</pre>
 *
 * <p>A second mode is to arrange that your application uses instances of
 * this class as its entity resolver, and automatically loads catalogs
 * referenced by <em>&lt;?oasis-xml-catalog...?&gt;</em> processing
 * instructions found before the DTD in documents it parses.
 * It would then discard the resolver after each parse.
 *
 * <p> A third mode applies catalogs in contexts other than entity
 * resolution for parsers.
 * The {@link #resolveURI resolveURI()} method supports resolving URIs
 * stored in XML application data, rather than inside DTDs.
 * Catalogs would be loaded as shown above, and the catalog could
 * be used concurrently for parser entity resolution and for
 * application URI resolution.
 * </p>
 *
 * <center><hr width='70%'></center>
 *
 * <p>Errors in catalogs implicitly loaded (during resolution) are ignored
 * beyond being reported through any <em>ErrorHandler</em> assigned using
 * {@link #setErrorHandler setErrorHandler()}.  SAX exceptions
 * thrown from such a handler won't abort resolution, although throwing a
 * <em>RuntimeException</em> or <em>Error</em> will normally abort both
 * resolution and parsing.  Useful diagnostic information is available to
 * any <em>ErrorHandler</em> used to report problems, or from any exception
 * thrown from an explicit {@link #loadCatalog loadCatalog()} invocation.
 * Applications can use that information as troubleshooting aids.
 *
 * <p>While this class requires <em>SAX2 Extensions 1.1</em> classes in
 * its class path, basic functionality does not require using a SAX2
 * parser that supports the extended entity resolution functionality.
 * See the original SAX1
 * {@link #resolveEntity(java.lang.String,java.lang.String) resolveEntity()}
 * method for a list of restrictions which apply when it is used with
 * older SAX parsers.
 *
 * @see EntityResolver2
 *
 * @author David Brownell
 */
public class XCat implements EntityResolver2
{
    private Catalog		catalogs [];
    private boolean		usingPublic = true;
    private boolean		loadingPermitted = true;
    private boolean		unified = true;
    private String		parserClass;
    private ErrorHandler	errorHandler;

    // private EntityResolver	next;	// chain to next if we fail...

    //
    // NOTE:  This is a straightforward implementation, and if
    // there are lots of "nextCatalog" or "delegate*" entries
    // in use, two tweaks would be worth considering:
    //
    //	- Centralize some sort of cache (key by URI) for individual
    //	  resolvers.  That'd avoid multiple copies of a given catalog.
    //
    //	- Have resolution track what catalogs (+modes) have been
    //	  searched.  This would support loop detection.
    //


    /**
     * Initializes without preloading a catalog.
     * This API is convenient when you may want to arrange that catalogs
     * are automatically loaded when explicitly referenced in documents,
     * using the <em>oasis-xml-catalog</em> processing instruction.
     * In such cases you won't usually be able to preload catalogs.
     */
    public XCat () { }

    /**
     * Initializes, and preloads a catalog using the default SAX parser.
     * This API is convenient when you operate with one or more standard
     * catalogs.
     *
     * <p> This just delegates to {@link #loadCatalog loadCatalog()};
     * see it for exception information.
     *
     * @param uri absolute URI for the catalog file.
     */
    public XCat (String uri)
    throws SAXException, IOException
	{ loadCatalog (uri); }


    /**
     * Loads an OASIS XML Catalog.
     * It is appended to the list of currently active catalogs, or
     * reloaded if a catalog with the same URI was already loaded.
     * Callers have control over what parser is used, how catalog parsing
     * errors are reported, and whether URIs will be resolved consistently.
     *
     * <p> The OASIS specification says that errors detected when loading
     * catalogs "must recover by ignoring the catalog entry file that
     * failed, and proceeding."  In this API, that action can be the
     * responsibility of applications, when they explicitly load any
     * catalog using this method.
     *
     * <p>Note that catalogs referenced by this one will not be loaded
     * at this time.  Catalogs referenced through <em>nextCatalog</em>
     * or <em>delegate*</em> elements are normally loaded only if needed. 
     *
     * @see #setErrorHandler
     * @see #setParserClass
     * @see #setUnified
     *
     * @param uri absolute URI for the catalog file.
     *
     * @exception IOException As thrown by the parser, typically to
     *	indicate problems reading data from that URI.
     * @exception SAXException As thrown by the parser, typically to
     *	indicate problems parsing data from that URI.  It may also
     *  be thrown if the parser doesn't support necessary handlers. 
     * @exception IllegalStateException When attempting to load a
     *	catalog after loading has been {@link #disableLoading disabled},
     *	such as after any entity or URI lookup has been performed.
     */
    public synchronized void loadCatalog (String uri)
    throws SAXException, IOException
    {
	Catalog		catalog;
	int		index = -1;

	if (!loadingPermitted)
	    throw new IllegalStateException ();
	
	uri = normalizeURI (uri);
	if (catalogs != null) {
	    // maybe just reload
	    for (index = 0; index < catalogs.length; index++)
		if (uri.equals (catalogs [index].catalogURI))
		    break;
	}
	catalog = loadCatalog (parserClass, errorHandler, uri, unified);

	// add to list of catalogs
	if (catalogs == null) {
	    index = 0;
	    catalogs = new Catalog [1];
	} else if (index == catalogs.length) {
	    Catalog		tmp [];

	    tmp = new Catalog [index + 1];
	    System.arraycopy (catalogs, 0, tmp, 0, index);
	    catalogs = tmp;
	}
	catalogs [index] = catalog;
    }


    /**
     * "New Style" external entity resolution for parsers.
     * Calls to this method prevent explicit loading of additional catalogs
     * using {@link #loadCatalog loadCatalog()}.
     *
     * <p>This supports the full core catalog functionality for locating
     * (and relocating) parsed entities that have been declared in a
     * document's DTD.
     *
     * @param name Entity name, such as "dudley", "%nell", or "[dtd]".
     * @param publicId Either a normalized public ID, or null.
     * @param baseURI Absolute base URI associated with systemId.
     * @param systemId URI found in entity declaration (may be
     *	relative to baseURI).
     *
     * @return Input source for accessing the external entity, or null
     *	if no mapping was found.  The input source may have opened
     *	the stream, and will have a fully resolved URI.
     *
     * @see #getExternalSubset
     */
    public InputSource resolveEntity (
	String name,		// UNUSED ... systemId is always non-null
	String publicId,
	String baseURI,		// UNUSED ... it just lets sysId be relative
	String systemId
    ) throws SAXException, IOException
    {
	if (loadingPermitted)
	    disableLoading ();

	try {
	    // steps as found in OASIS XML catalog spec 7.1.2
	    // steps 1, 8 involve looping over the list of catalogs
	    for (int i = 0; i < catalogs.length; i++) {
		InputSource	retval;
		retval = catalogs [i].resolve (usingPublic, publicId, systemId);
		if (retval != null)
		    return retval;;
	    }
	} catch (DoneDelegation x) {
	    // done!
	}
	// step 9 involves returning "no match" 
	return null;
    }


    /**
     * "New Style" parser callback to add an external subset.
     * For documents that don't include an external subset, this may
     * return one according to <em>doctype</em> catalog entries.
     * (This functionality is not a core part of the OASIS XML Catalog
     * specification, though it's presented in an appendix.)
     * If no such entry is defined, this returns null to indicate that
     * this document will not be modified to include such a subset.
     * Calls to this method prevent explicit loading of additional catalogs
     * using {@link #loadCatalog loadCatalog()}.
     *
     * <p><em>Warning:</em> That catalog functionality can be dangerous.
     * It can provide definitions of general entities, and thereby mask
     * certain well formedess errors.
     *
     * @param name Name of the document element, either as declared in
     *	a DOCTYPE declaration or as observed in the text.
     * @param baseURI Document's base URI (absolute).
     *
     * @return Input source for accessing the external subset, or null
     *	if no mapping was found.  The input source may have opened
     *	the stream, and will have a fully resolved URI.
     */
    public InputSource getExternalSubset (String name, String baseURI)
    throws SAXException, IOException
    {
	if (loadingPermitted)
	    disableLoading ();
	try {
	    for (int i = 0; i < catalogs.length; i++) {
		InputSource retval = catalogs [i].getExternalSubset (name);
		if (retval != null)
		    return retval;
	    }
	} catch (DoneDelegation x) {
	    // done!
	}
	return null;
    }


    /**
     * "Old Style" external entity resolution for parsers.
     * This API provides only core functionality.
     * Calls to this method prevent explicit loading of additional catalogs
     * using {@link #loadCatalog loadCatalog()}.
     *
     * <p>The functional limitations of this interface include:</p><ul>
     *
     *	<li>Since system IDs will be absolutized before the resolver
     *	sees them, matching against relative URIs won't work.
     *	This may affect <em>system</em>, <em>rewriteSystem</em>,
     *	and <em>delegateSystem</em> catalog entries.
     *
     *	<li>Because of that absolutization, documents declaring entities
     *	with system IDs using URI schemes that the JVM does not recognize
     *	may be unparsable.  URI schemes such as <em>file:/</em>,
     *	<em>http://</em>, <em>https://</em>, and <em>ftp://</em>
     *	will usually work reliably.
     *
     *	<li>Because missing external subsets can't be provided, the
     *	<em>doctype</em> catalog entries will be ignored.
     *	(The {@link #getExternalSubset getExternalSubset()} method is
     *	a "New Style" resolution option.)
     *
     *	</ul>
     *
     * <p>Applications can tell whether this limited functionality will be
     * used: if the feature flag associated with the {@link EntityResolver2}
     * interface is not <em>true</em>, the limitations apply.  Applications
     * can't usually know whether a given document and catalog will trigger
     * those limitations.  The issue can only be bypassed by operational
     * procedures such as not using catalogs or documents which involve
     * those features.
     *
     * @param publicId Either a normalized public ID, or null
     * @param systemId Always an absolute URI.
     *
     * @return Input source for accessing the external entity, or null
     *	if no mapping was found.  The input source may have opened
     *	the stream, and will have a fully resolved URI.
     */
    final public InputSource resolveEntity (String publicId, String systemId)
    throws SAXException, IOException
    {
	return resolveEntity (null, publicId, null, systemId);
    }


    /**
     * Resolves a URI reference that's not defined to the DTD.
     * This is intended for use with URIs found in document text, such as
     * <em>xml-stylesheet</em> processing instructions and in attribute
     * values, where they are not recognized as URIs by XML parsers.
     * Calls to this method prevent explicit loading of additional catalogs
     * using {@link #loadCatalog loadCatalog()}.
     *
     * <p>This functionality is supported by the OASIS XML Catalog
     * specification, but will never be invoked by an XML parser.
     * It corresponds closely to functionality for mapping system
     * identifiers for entities declared in DTDs; closely enough that
     * this implementation's default behavior is that they be
     * identical, to minimize potential confusion.
     *
     * <p>This method could be useful when implementing the
     * {@link javax.xml.transform.URIResolver} interface, wrapping the
     * input source in a {@link javax.xml.transform.sax.SAXSource}.
     *
     * @see #isUnified
     * @see #setUnified
     *
     * @param baseURI The relevant base URI as specified by the XML Base
     *	specification.  This recognizes <em>xml:base</em> attributes
     *	as overriding the actual (physical) base URI.
     * @param uri Either an absolute URI, or one relative to baseURI
     *
     * @return Input source for accessing the mapped URI, or null
     *	if no mapping was found.  The input source may have opened
     *	the stream, and will have a fully resolved URI.
     */
    public InputSource resolveURI (String baseURI, String uri)
    throws SAXException, IOException
    {
	if (loadingPermitted)
	    disableLoading ();

	// NOTE:  baseURI isn't used here, but caller MUST have it,
	// and heuristics _might_ use it in the future ... plus,
	// it's symmetric with resolveEntity ().

	// steps 1, 6 involve looping
	try {
	    for (int i = 0; i < catalogs.length; i++) {
		InputSource	tmp = catalogs [i].resolveURI (uri);
		if (tmp != null)
		    return tmp;
	    }
	} catch (DoneDelegation x) {
	    // done
	}
	// step 7 reports no match
	return null;
    }


    /** 
     * Records that catalog loading is no longer permitted.
     * Loading is automatically disabled when lookups are performed,
     * and should be manually disabled when <em>startDTD()</em> (or
     * any other DTD declaration callback) is invoked, or at the latest
     * when the document root element is seen.
     */
    public synchronized void disableLoading ()
    {
	// NOTE:  this method and loadCatalog() are synchronized
	// so that it's impossible to load (top level) catalogs
	// after lookups start.  Likewise, deferred loading is also
	// synchronized (for "next" and delegated catalogs) to
	// ensure that parsers can share resolvers.
	loadingPermitted = false;
    }


    /**
     * Returns the error handler used to report catalog errors.
     * Null is returned if the parser's default error handling
     * will be used.
     *
     * @see #setErrorHandler
     */
    public ErrorHandler getErrorHandler ()
	{ return errorHandler; }

    /**
     * Assigns the error handler used to report catalog errors.
     * These errors may come either from the SAX2 parser or
     * from the catalog parsing code driven by the parser. 
     *
     * <p> If you're sharing the resolver between parsers, don't
     * change this once lookups have begun.
     *
     * @see #getErrorHandler
     *
     * @param parser The error handler, or null saying to use the default
     *	(no diagnostics, and only fatal errors terminate loading).
     */
    public void setErrorHandler (ErrorHandler handler)
	{ errorHandler = handler; }


    /**
     * Returns the name of the SAX2 parser class used to parse catalogs.
     * Null is returned if the system default is used.
     * @see #setParserClass
     */
    public String getParserClass ()
	{ return parserClass; }

    /**
     * Names the SAX2 parser class used to parse catalogs.
     *
     * <p> If you're sharing the resolver between parsers, don't change
     * this once lookups have begun.
     *
     * <p> Note that in order to properly support the <em>xml:base</em>
     * attribute and relative URI resolution, the SAX parser used to parse
     * the catalog must provide a {@link Locator} and support the optional
     * declaration and lexical handlers.
     *
     * @see #getParserClass
     *
     * @param parser The parser class name, or null saying to use the
     *	system default SAX2 parser.
     */
    public void setParserClass (String parser)
	{ parserClass = parser; }


    /**
     * Returns true (the default) if all methods resolve
     * a given URI in the same way.
     * Returns false if calls resolving URIs as entities (such as
     * {@link #resolveEntity resolveEntity()}) use different catalog entries
     * than those resolving them as URIs ({@link #resolveURI resolveURI()}),
     * which will generally produce different results.
     *
     * <p>The OASIS XML Catalog specification defines two related schemes
     * to map URIs "as URIs" or "as system IDs".
     * URIs use <em>uri</em>, <em>rewriteURI</em>, and <em>delegateURI</em>
     * elements.  System IDs do the same things with <em>systemId</em>,
     * <em>rewriteSystemId</em>, and <em>delegateSystemId</em>.
     * It's confusing and error prone to maintain two parallel copies of
     * such data.  Accordingly, this class makes that behavior optional.
     * The <em>unified</em> interpretation of URI mappings is preferred,
     * since it prevents surprises where one URI gets mapped to different
     * contents depending on whether the reference happens to have come
     * from a DTD (or not).
     *
     * @see #setUnified
     */
    public boolean isUnified ()
	{ return unified; }

    /**
     * Assigns the value of the flag returned by {@link #isUnified}.
     * Set it to false to be strictly conformant with the OASIS XML Catalog
     * specification.  Set it to true to make all mappings for a given URI
     * give the same result, regardless of the reason for the mapping.
     *
     * <p>Don't change this once you've loaded the first catalog.
     *
     * @param value new flag setting
     */
    public void setUnified (boolean value)
	{ unified = value; }


    /**
     * Returns true (the default) if a catalog's public identifier
     * mappings will be used.
     * When false is returned, such mappings are ignored except when
     * system IDs are discarded, such as for
     * entities using the <em>urn:publicid:</em> URI scheme in their
     * system identifiers.  (See RFC 3151 for information about that
     * URI scheme.  Using it in system identifiers may not work well
     * with many SAX parsers unless the <em>resolve-dtd-uris</em>
     * feature flag is set to false.)
     * @see #setUsingPublic
     */
    public boolean isUsingPublic ()
	{ return usingPublic; }

    /**
     * Specifies which catalog search mode is used.
     * By default, public identifier mappings are able to override system
     * identifiers when both are available.
     * Applications may choose to ignore public
     * identifier mappings in such cases, so that system identifiers
     * declared in DTDs will only be overridden by an explicit catalog
     * match for that system ID.
     *
     * <p> If you're sharing the resolver between parsers, don't
     * change this once lookups have begun.
     * @see #isUsingPublic
     *
     * @param value true to always use public identifier mappings,
     *	false to only use them for system ids using the <em>urn:publicid:</em>
     *	URI scheme.
     */
    public void setUsingPublic (boolean value)
	{ usingPublic = value; }



    // hmm, what's this do? :)
    private static Catalog loadCatalog (
	String		parserClass,
	ErrorHandler	eh,
	String		uri,
	boolean		unified
    ) throws SAXException, IOException
    {
	XMLReader	parser;
	Loader		loader;
	boolean		doesIntern = false;

	if (parserClass == null)
	    parser = XMLReaderFactory.createXMLReader ();
	else
	    parser = XMLReaderFactory.createXMLReader (parserClass);
	if (eh != null)
	    parser.setErrorHandler (eh);
	// resolve-dtd-entities is at default value (unrecognized == true)

	try {
	    doesIntern = parser.getFeature (
		"http://xml.org/sax/features/string-interning");
	} catch (SAXNotRecognizedException e) { }

	loader = new Loader (doesIntern, eh, unified);
	loader.cat.parserClass = parserClass;
	loader.cat.catalogURI = uri;

	parser.setContentHandler (loader);
	parser.setProperty (
	    "http://xml.org/sax/properties/declaration-handler",
	    loader);
	parser.setProperty (
	    "http://xml.org/sax/properties/lexical-handler",
	    loader);
	parser.parse (uri);

	return loader.cat;
    }

    // perform one or both the normalizations for public ids
    private static String normalizePublicId (boolean full, String publicId)
    {
	if (publicId.startsWith ("urn:publicid:")) {
	    StringBuffer	buf = new StringBuffer ();
	    char		chars [] = publicId.toCharArray ();
boolean hasbug = false;

	    for (int i = 13; i < chars.length; i++) {
		switch (chars [i]) {
		case '+': 	buf.append (' '); continue;
		case ':': 	buf.append ("//"); continue;
		case ';': 	buf.append ("::"); continue;
		case '%':
// FIXME unhex that char!  meanwhile, warn and fallthrough ...
		    hasbug = true;
		default:	buf.append (chars [i]); continue;
		}
	    }
	    publicId = buf.toString ();
if (hasbug)
System.err.println ("nyet unhexing public id: " + publicId);
	    full = true;
	}

	// SAX parsers do everything except that URN mapping, but
	// we can't trust other sources to normalize correctly
	if (full) {
	    StringTokenizer	tokens;
	    String		token;

	    tokens = new StringTokenizer (publicId, " \r\n");
	    publicId = null;
	    while (tokens.hasMoreTokens ()) {
		if (publicId == null)
		    publicId = tokens.nextToken ();
		else
		    publicId += " " + tokens.nextToken ();
	    }
	}
	return publicId;
    }

    private static boolean isUriExcluded (int c)
	{ return c <= 0x20 || c >= 0x7f || "\"<>^`{|}".indexOf (c) != -1; }

    private static int hexNibble (int c)
    {
	if (c < 10)
	    return c + '0';
	return ('a' - 10) + c;
    }

    // handles URIs with "excluded" characters
    private static String normalizeURI (String systemId)
    {
	int			length = systemId.length ();

	for (int i = 0; i < length; i++) {
	    char	c = systemId.charAt (i);

	    // escape non-ASCII plus "excluded" characters
	    if (isUriExcluded (c)) {
		byte			buf [];
		ByteArrayOutputStream	out;
		int				b;

		// a JVM that doesn't know UTF8 and 8859_1 is unusable!
		try {
		    buf = systemId.getBytes ("UTF8");
		    out = new ByteArrayOutputStream (buf.length + 10);

		    for (i = 0; i < buf.length; i++) {
			b = buf [i] & 0x0ff;
			if (isUriExcluded (b)) {
			    out.write ((int) '%');
			    out.write (hexNibble (b >> 4));
			    out.write (hexNibble (b & 0x0f));
			} else
			    out.write (b);
		    }
		    return out.toString ("8859_1");
		} catch (IOException e) {
		    throw new RuntimeException (
			"can't normalize URI: " + e.getMessage ());
		}
	    }
	}
	return systemId;
    }

    // thrown to mark authoritative end of a search
    private static class DoneDelegation extends SAXException
    {
	DoneDelegation () { }
    }


    /**
     * Represents a OASIS XML Catalog, and encapsulates much of
     * the catalog functionality.
     */
    private static class Catalog
    {
	// loading infrastructure
	String		catalogURI;
	ErrorHandler	eh;
	boolean		unified;
	String		parserClass;

	// catalog data
	boolean		hasPreference;
	boolean		usingPublic;

	Hashtable	publicIds;
	Hashtable	publicDelegations;

	Hashtable	systemIds;
	Hashtable	systemRewrites;
	Hashtable	systemDelegations;

	Hashtable	uris;
	Hashtable	uriRewrites;
	Hashtable	uriDelegations;

	Hashtable	doctypes;

	Vector		next;

	// nonpublic!
	Catalog () { }

	
	// steps as found in OASIS XML catalog spec 7.1.2
	private InputSource locatePublicId (String publicId)
	throws SAXException, IOException
	{
	    // 5. return (first) 'public' entry
	    if (publicIds != null) {
		String	retval = (String) publicIds.get (publicId);
		if (retval != null) {
		    // IF the URI is accessible ...
		    return new InputSource (retval);
		}
	    }

	    // 6. return delegatePublic catalog match [complex]
	    if (publicDelegations != null)
		return checkDelegations (publicDelegations, publicId,
				publicId, null);
	    
	    return null;
	}

	// steps as found in OASIS XML catalog spec 7.1.2 or 7.2.2
	private InputSource mapURI (
	    String	uri,
	    Hashtable	ids,
	    Hashtable	rewrites,
	    Hashtable	delegations
	) throws SAXException, IOException
	{
	    // 7.1.2: 2. return (first) 'system' entry
	    // 7.2.2: 2. return (first) 'uri' entry
	    if (ids != null) {
		String	retval = (String) ids.get (uri);
		if (retval != null) {
		    // IF the URI is accessible ...
		    return new InputSource (retval);
		}
	    }

	    // 7.1.2: 3. return 'rewriteSystem' entries
	    // 7.2.2: 3. return 'rewriteURI' entries
	    if (rewrites != null) {
		String	prefix = null;
		String	replace = null;
		int	prefixLen = -1;

		for (Enumeration e = rewrites.keys ();
			e.hasMoreElements ();
			/* NOP */) {
		    String	temp = (String) e.nextElement ();
		    int		len = -1;

		    if (!uri.startsWith (temp))
			continue;
		    if (prefix != null
			    && (len = temp.length ()) < prefixLen)
			continue;
		    prefix = temp;
		    prefixLen = len;
		    replace = (String) rewrites.get (temp);
		}
		if (prefix != null) {
		    StringBuffer	buf = new StringBuffer (replace);
		    buf.append (uri.substring (prefixLen));
		    // IF the URI is accessible ...
		    return new InputSource (buf.toString ());
		}
	    }

	    // 7.1.2: 4. return 'delegateSystem' catalog match [complex]
	    // 7.2.2: 4. return 'delegateURI' catalog match [complex]
	    if (delegations != null)
		return checkDelegations (delegations, uri, null, uri);

	    return null;
	}


	/**
	 * Returns a URI for an external entity.
	 */
	public InputSource resolve (
	    boolean	usingPublic,
	    String	publicId,
	    String	systemId
	) throws SAXException, IOException
	{
	    boolean	preferSystem;
	    InputSource	retval;

	    if (hasPreference)
		preferSystem = !this.usingPublic;
	    else
		preferSystem = !usingPublic;
	    
	    if (publicId != null)
		publicId = normalizePublicId (false, publicId);

	    // behavior here matches section 7.1.1 of the oasis spec
	    if (systemId != null) {
		if (systemId.startsWith ("urn:publicid:")) {
		    String	temp = normalizePublicId (true, systemId);
		    if (publicId == null) {
			publicId = temp;
			systemId = null;
		    } else if (!publicId.equals (temp)) {
			// error; ok to recover by:
			systemId = null;
		    }
		} else
		    systemId = normalizeURI (systemId);
	    }

	    if (systemId == null && publicId == null)
		return null;

	    if (systemId != null) {
		retval = mapURI (systemId, systemIds, systemRewrites,
					systemDelegations);
		if (retval != null) {
		    retval.setPublicId (publicId);
		    return retval;
		}
	    }

	    if (publicId != null
		    && !(systemId != null && preferSystem)) {
		retval = locatePublicId (publicId);
		if (retval != null) {
		    retval.setPublicId (publicId);
		    return retval;
		}
	    }

	    // 7. apply nextCatalog entries
	    if (next != null) {
		int	length = next.size ();
		for (int i = 0; i < length; i++) {
		    Catalog	n = getNext (i);
		    retval = n.resolve (usingPublic, publicId, systemId);
		    if (retval != null)
			return retval;
		}
	    }

	    return null;
	}

	/**
	 * Maps one URI into another, for resources that are not defined
	 * using XML external entity or notation syntax.
	 */
	public InputSource resolveURI (String uri)
	throws SAXException, IOException
	{
	    if (uri.startsWith ("urn:publicid:"))
		return resolve (true, normalizePublicId (true, uri), null);

	    InputSource	retval;

	    uri = normalizeURI (uri);

	    // 7.2.2 steps 2-4
	    retval = mapURI (uri, uris, uriRewrites, uriDelegations);
	    if (retval != null)
		return retval;

	    // 7.2.2 step 5. apply nextCatalog entries
	    if (next != null) {
		int	length = next.size ();
		for (int i = 0; i < length; i++) {
		    Catalog	n = getNext (i);
		    retval = n.resolveURI (uri);
		    if (retval != null)
			return retval;
		}
	    }

	    return null;
	}


	/**
	 * Finds the external subset associated with a given root element.
	 */
	public InputSource getExternalSubset (String name)
	throws SAXException, IOException
	{
	    if (doctypes != null) {
		String	value = (String) doctypes.get (name);
		if (value != null) {
		    // IF the URI is accessible ...
		    return new InputSource (value);
		}
	    }
	    if (next != null) {
		int	length = next.size ();
		for (int i = 0; i < length; i++) {
		    Catalog	n = getNext (i);
		    if (n == null)
			continue;
		    InputSource	retval = n.getExternalSubset (name);
		    if (retval != null)
			return retval;
		}
	    }
	    return null;
	}

	private synchronized Catalog getNext (int i)
	throws SAXException, IOException
	{
	    Object	obj;

	    if (next == null || i < 0 || i >= next.size ())
		return null;
	    obj = next.elementAt (i);
	    if (obj instanceof Catalog)
		return (Catalog) obj;
	    
	    // ok, we deferred reading that catalog till now.
	    // load and cache it.
	    Catalog	cat = null;

	    try {
		cat = loadCatalog (parserClass, eh, (String) obj, unified);
		next.setElementAt (cat, i);
	    } catch (SAXException e) {
		// must fail quietly, says the OASIS spec
	    } catch (IOException e) {
		// same applies here
	    }
	    return cat;
	}

	private InputSource checkDelegations (
	    Hashtable	delegations,
	    String	id,
	    String	publicId,	// only one of public/system
	    String	systemId	// will be non-null...
	) throws SAXException, IOException
	{
	    Vector	matches = null;
	    int		length = 0;

	    // first, see if any prefixes match.
	    for (Enumeration e = delegations.keys ();
		    e.hasMoreElements ();
		    /* NOP */) {
		String	prefix = (String) e.nextElement ();

		if (!id.startsWith (prefix))
		    continue;
		if (matches == null)
		    matches = new Vector ();
		
		// maintain in longer->shorter sorted order
		// NOTE:  assumes not many matches will fire!
		int	index;

		for (index = 0; index < length; index++) {
		    String	temp = (String) matches.elementAt (index);
		    if (prefix.length () > temp.length ()) {
			matches.insertElementAt (prefix, index);
			break;
		    }
		}
		if (index == length)
		    matches.addElement (prefix);
		length++;
	    }
	    if (matches == null)
		return null;

	    // now we know the list of catalogs to replace our "top level"
	    // list ... we use it here, rather than somehow going back and
	    // restarting, since this helps avoid reading most catalogs.
	    // this assumes stackspace won't be a problem.
	    for (int i = 0; i < length; i++) {
		Catalog		catalog = null;
		InputSource	result;

		// get this catalog.  we may not have read it yet.
		synchronized (delegations) {
		    Object	prefix = matches.elementAt (i);
		    Object	cat = delegations.get (prefix);

		    if (cat instanceof Catalog)
			catalog = (Catalog) cat;
		    else {
			try {
			    // load and cache that catalog
			    catalog = loadCatalog (parserClass, eh,
				    (String) cat, unified);
			    delegations.put (prefix, catalog);
			} catch (SAXException e) {
			    // must ignore, says the OASIS spec
			} catch (IOException e) {
			    // same applies here
			}
		    }
		}

		// ignore failed loads, and proceed
		if (catalog == null)
		    continue;
		
		// we have a catalog ... resolve!
		// usingPublic value can't matter, there's no choice
		result = catalog.resolve (true, publicId, systemId);
		if (result != null)
		    return result;
	    }

	    // if there were no successes, the entire
	    // lookup failed (all the way to top level)
	    throw new DoneDelegation ();
	}
    }


    /** This is the namespace URI used for OASIS XML Catalogs.  */
    private static final String	catalogNamespace =
    	"urn:oasis:names:tc:entity:xmlns:xml:catalog";


    /**
     * Loads/unmarshals one catalog.
     */
    private static class Loader extends DefaultHandler2
    {
	private boolean		preInterned;
	private ErrorHandler	handler;
	private boolean		unified;
	private int		ignoreDepth;
	private Locator		locator;
	private boolean		started;
	private Hashtable	externals;
	private Stack		bases;

	Catalog			cat = new Catalog ();


	/**
	 * Constructor.
	 * @param flag true iff the parser already interns strings.
	 * @param eh Errors and warnings are delegated to this.
	 * @param unified true keeps one table for URI mappings;
	 *	false matches OASIS spec, storing mappings
	 *	for URIs and SYSTEM ids in parallel tables.
	 */
	Loader (boolean flag, ErrorHandler eh, boolean unified)
	{
	    preInterned = flag;
	    handler = eh;
	    this.unified = unified;
	    cat.unified = unified;
	    cat.eh = eh;
	}


	// strips out fragments
	private String nofrag (String uri)
	throws SAXException
	{
	    if (uri.indexOf ('#') != -1) {
		warn ("URI with fragment: " + uri);
		uri = uri.substring (0, uri.indexOf ('#'));
	    }
	    return uri;
	}

	// absolutizes relative URIs
	private String absolutize (String uri)
	throws SAXException
	{
	    // avoid creating URLs if they're already absolutized,
	    // or if the URI is already using a known scheme
	    if (uri.startsWith ("file:/")
		    || uri.startsWith ("http:/")
		    || uri.startsWith ("https:/")
		    || uri.startsWith ("ftp:/")
		    || uri.startsWith ("urn:")
		    )
		return uri;

	    // otherwise, let's hope the JDK handles this URI scheme.
	    try {
		URL	base = (URL) bases.peek ();
		return new URL (base, uri).toString ();
	    } catch (Exception e) {
		fatal ("can't absolutize URI: " + uri);
		return null;
	    }
	}

	// recoverable error
	private void error (String message)
	throws SAXException
	{
	    if (handler == null)
		return;
	    handler.error (new SAXParseException (message, locator));
	}

	// nonrecoverable error
	private void fatal (String message)
	throws SAXException
	{
	    SAXParseException	spe;
	    
	    spe = new SAXParseException (message, locator);
	    if (handler != null)
		handler.fatalError (spe);
	    throw spe;
	}

	// low severity problem
	private void warn (String message)
	throws SAXException
	{
	    if (handler == null)
		return;
	    handler.warning (new SAXParseException (message, locator));
	}

	// callbacks:

	public void setDocumentLocator (Locator l)
	    { locator = l; }

	public void startDocument ()
	throws SAXException
	{
	    if (locator == null)
		error ("no locator!");
	    bases = new Stack ();
	    String	uri = locator.getSystemId ();
	    try {
		bases.push (new URL (uri));
	    } catch (IOException e) {
		fatal ("bad document base URI: " + uri);
	    }
	}

	public void endDocument ()
	throws SAXException
	{
	    try {
		if (!started)
		    error ("not a catalog!");
	    } finally {
		locator = null;
		handler = null;
		externals = null;
		bases = null;
	    }
	}

	// XML Base support for external entities.

	// NOTE: expects parser is in default "resolve-dtd-uris" mode.
	public void externalEntityDecl (String name, String pub, String sys)
	throws SAXException
	{
	    if (externals == null)
		externals = new Hashtable ();
	    if (externals.get (name) == null)
		externals.put (name, pub);
	}

	public void startEntity (String name)
	throws SAXException
	{
	    if (externals == null)
		return;
	    String uri = (String) externals.get (name);

	    // NOTE: breaks if an EntityResolver substitutes these URIs.
	    // If toplevel loader supports one, must intercept calls...
	    if (uri != null) {
		try {
		    bases.push (new URL (uri));
		} catch (IOException e) {
		    fatal ("entity '" + name + "', bad URI: " + uri);
		}
	    }
	}

	public void endEntity (String name)
	{
	    if (externals == null)
		return;
	    String value = (String) externals.get (name);

	    if (value != null)
		bases.pop ();
	}

	/**
	 * Processes catalog elements, saving their data.
	 */
	public void startElement (String namespace, String local,
	    String qName, Attributes atts)
	throws SAXException
	{
	    // must ignore non-catalog elements, and their contents
	    if (ignoreDepth != 0 || !catalogNamespace.equals (namespace)) {
		ignoreDepth++;
		return;
	    }

	    // basic sanity checks
	    if (!preInterned)
		local = local.intern ();
	    if (!started) {
		started = true;
		if ("catalog" != local)
		    fatal ("root element not 'catalog': " + local);
	    }

	    // Handle any xml:base attribute
	    String	xmlbase = atts.getValue ("xml:base");

	    if (xmlbase != null) {
		URL	base = (URL) bases.peek ();
		try {
		    base = new URL (base, xmlbase);
		} catch (IOException e) {
		    fatal ("can't resolve xml:base attribute: " + xmlbase);
		}
		bases.push (base);
	    } else
		bases.push (bases.peek ());

	    // fetch multi-element attributes, apply standard tweaks
	    // values (uri, catalog, rewritePrefix) get normalized too,
	    // as a precaution and since we may compare the values
	    String	catalog = atts.getValue ("catalog");
	    if (catalog != null)
		catalog = normalizeURI (absolutize (catalog));

	    String	rewritePrefix = atts.getValue ("rewritePrefix");
	    if (rewritePrefix != null)
		rewritePrefix = normalizeURI (absolutize (rewritePrefix));

	    String	systemIdStartString;
	    systemIdStartString = atts.getValue ("systemIdStartString");
	    if (systemIdStartString != null) {
		systemIdStartString = normalizeURI (systemIdStartString);
		// unmatchable <rewriteSystemId>, <delegateSystemId> elements
		if (systemIdStartString.startsWith ("urn:publicid:")) {
		    error ("systemIdStartString is really a publicId!!");
		    return;
		}
	    }

	    String	uri = atts.getValue ("uri");
	    if (uri != null)
		uri = normalizeURI (absolutize (uri));

	    String	uriStartString;
	    uriStartString = atts.getValue ("uriStartString");
	    if (uriStartString != null) {
		uriStartString = normalizeURI (uriStartString);
		// unmatchable <rewriteURI>, <delegateURI> elements
		if (uriStartString.startsWith ("urn:publicid:")) {
		    error ("uriStartString is really a publicId!!");
		    return;
		}
	    }

	    // strictly speaking "group" and "catalog" shouldn't nest
	    // ... arbitrary restriction, no evident motivation

// FIXME stack "prefer" settings (two elements only!) and use
// them to populate different public mapping/delegation tables

	    if ("catalog" == local || "group" == local) {
		String	prefer = atts.getValue ("prefer");

		if (prefer != null && !"public".equals (prefer)) {
		    if (!"system".equals (prefer)) {
			error ("in <" + local + " ... prefer='...'>, "
			    + "assuming 'public'");
			prefer = "public";
		    }
		}
		if (prefer != null) {
		    if ("catalog" == local) {
			cat.hasPreference = true;
			cat.usingPublic = "public".equals (prefer);
		    } else {
			if (!cat.hasPreference || cat.usingPublic
				    != "public".equals (prefer)) {
fatal ("<group prefer=...> case not handled");
			}
		    }
		} else if ("group" == local && cat.hasPreference) {
fatal ("<group prefer=...> case not handled");
		}

	    //
	    // PUBLIC ids:  cleanly set up for id substitution
	    //
	    } else if ("public" == local) {
		String	publicId = atts.getValue ("publicId");
		String	value = null;

		if (publicId == null || uri == null) {
		    error ("expecting <public publicId=... uri=.../>");
		    return;
		}
		publicId = normalizePublicId (true, publicId);
		uri = nofrag (uri);
		if (cat.publicIds == null)
		    cat.publicIds = new Hashtable ();
		else
		    value = (String) cat.publicIds.get (publicId);
		if (value != null) {
		    if (!value.equals (uri))
			warn ("ignoring <public...> entry for " + publicId);
		} else
		    cat.publicIds.put (publicId, uri);

	    } else if ("delegatePublic" == local) {
		String	publicIdStartString;
		Object	value = null;

		publicIdStartString = atts.getValue ("publicIdStartString");
		if (publicIdStartString == null || catalog == null) {
		    error ("expecting <delegatePublic "
			+ "publicIdStartString=... catalog=.../>");
		    return;
		}
		publicIdStartString = normalizePublicId (true,
			publicIdStartString);
		if (cat.publicDelegations == null)
		    cat.publicDelegations = new Hashtable ();
		else
		    value = cat.publicDelegations.get (publicIdStartString);
		if (value != null) {
		    if (!value.equals (catalog))
			warn ("ignoring <delegatePublic...> entry for "
			    + uriStartString);
		} else
		    cat.publicDelegations.put (publicIdStartString, catalog);


	    //
	    // SYSTEM ids:  need substitution due to operational issues
	    //
	    } else if ("system" == local) {
		String	systemId = atts.getValue ("systemId");
		String	value = null;

		if (systemId == null || uri == null) {
		    error ("expecting <system systemId=... uri=.../>");
		    return;
		}
		systemId = normalizeURI (systemId);
		uri = nofrag (uri);
		if (systemId.startsWith ("urn:publicid:")) {
		    error ("systemId is really a publicId!!");
		    return;
		}
		if (cat.systemIds == null) {
		    cat.systemIds = new Hashtable ();
		    if (unified)
			cat.uris = cat.systemIds;
		} else
		    value = (String) cat.systemIds.get (systemId);
		if (value != null) {
		    if (!value.equals (uri))
			warn ("ignoring <system...> entry for " + systemId);
		} else
		    cat.systemIds.put (systemId, uri);

	    } else if ("rewriteSystem" == local) {
		String	value = null;

		if (systemIdStartString == null || rewritePrefix == null
			|| systemIdStartString.length () == 0
			|| rewritePrefix.length () == 0
			) {
		    error ("expecting <rewriteSystem "
			+ "systemIdStartString=... rewritePrefix=.../>");
		    return;
		}
		if (cat.systemRewrites == null) {
		    cat.systemRewrites = new Hashtable ();
		    if (unified)
			cat.uriRewrites = cat.systemRewrites;
		} else
		    value = (String) cat.systemRewrites.get (
		    				systemIdStartString);
		if (value != null) {
		    if (!value.equals (rewritePrefix))
			warn ("ignoring <rewriteSystem...> entry for "
			    + systemIdStartString);
		} else
		    cat.systemRewrites.put (systemIdStartString,
		    		rewritePrefix);

	    } else if ("delegateSystem" == local) {
		Object	value = null;

		if (systemIdStartString == null || catalog == null) {
		    error ("expecting <delegateSystem "
			+ "systemIdStartString=... catalog=.../>");
		    return;
		}
		if (cat.systemDelegations == null) {
		    cat.systemDelegations = new Hashtable ();
		    if (unified)
			cat.uriDelegations = cat.systemDelegations;
		} else
		    value = cat.systemDelegations.get (systemIdStartString);
		if (value != null) {
		    if (!value.equals (catalog))
			warn ("ignoring <delegateSystem...> entry for "
			    + uriStartString);
		} else
		    cat.systemDelegations.put (systemIdStartString, catalog);


	    //
	    // URI:  just like "system" ID support, except that
	    // fragment IDs are disallowed in "system" elements.
	    //
	    } else if ("uri" == local) {
		String	name = atts.getValue ("name");
		String	value = null;

		if (name == null || uri == null) {
		    error ("expecting <uri name=... uri=.../>");
		    return;
		}
		if (name.startsWith ("urn:publicid:")) {
		    error ("name is really a publicId!!");
		    return;
		}
		name = normalizeURI (name);
		if (cat.uris == null) {
		    cat.uris = new Hashtable ();
		    if (unified)
			cat.systemIds = cat.uris;
		} else
		    value = (String) cat.uris.get (name);
		if (value != null) {
		    if (!value.equals (uri))
			warn ("ignoring <uri...> entry for " + name);
		} else
		    cat.uris.put (name, uri);

	    } else if ("rewriteURI" == local) {
		String value = null;

		if (uriStartString == null || rewritePrefix == null
			|| uriStartString.length () == 0
			|| rewritePrefix.length () == 0
			) {
		    error ("expecting <rewriteURI "
			+ "uriStartString=... rewritePrefix=.../>");
		    return;
		}
		if (cat.uriRewrites == null) {
		    cat.uriRewrites = new Hashtable ();
		    if (unified)
			cat.systemRewrites = cat.uriRewrites;
		} else
		    value = (String) cat.uriRewrites.get (uriStartString);
		if (value != null) {
		    if (!value.equals (rewritePrefix))
			warn ("ignoring <rewriteURI...> entry for "
			    + uriStartString);
		} else
		    cat.uriRewrites.put (uriStartString, rewritePrefix);

	    } else if ("delegateURI" == local) {
		Object	value = null;

		if (uriStartString == null || catalog == null) {
		    error ("expecting <delegateURI "
			+ "uriStartString=... catalog=.../>");
		    return;
		}
		if (cat.uriDelegations == null) {
		    cat.uriDelegations = new Hashtable ();
		    if (unified)
			cat.systemDelegations = cat.uriDelegations;
		} else
		    value = cat.uriDelegations.get (uriStartString);
		if (value != null) {
		    if (!value.equals (catalog))
			warn ("ignoring <delegateURI...> entry for "
			    + uriStartString);
		} else
		    cat.uriDelegations.put (uriStartString, catalog);

	    //
	    // NON-DELEGATING approach to modularity
	    //
	    } else if ("nextCatalog" == local) {
		if (catalog == null) {
		    error ("expecting <nextCatalog catalog=.../>");
		    return;
		}
		if (cat.next == null)
		    cat.next = new Vector ();
		cat.next.addElement (catalog);

	    //
	    // EXTENSIONS from appendix E
	    //
	    } else if ("doctype" == local) {
		String	name = atts.getValue ("name");
		String	value = null;

		if (name == null || uri == null) {
		    error ("expecting <doctype name=... uri=.../>");
		    return;
		}
		name = normalizeURI (name);
		if (cat.doctypes == null)
		    cat.doctypes = new Hashtable ();
		else
		    value = (String) cat.doctypes.get (name);
		if (value != null) {
		    if (!value.equals (uri))
			warn ("ignoring <doctype...> entry for "
			    + uriStartString);
		} else
		    cat.doctypes.put (name, uri);
	    

	    //
	    // RESERVED ... ignore (like reserved attributes) but warn
	    //
	    } else {
		warn ("ignoring unknown catalog element: " + local);
		ignoreDepth++;
	    }
	}

	public void endElement (String uri, String local, String qName)
	throws SAXException
	{
	    if (ignoreDepth != 0)
		ignoreDepth--;
	    else
		bases.pop ();
	}
    }
}
