/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache 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.apache.org/licenses/LICENSE-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 flash.tools.debugger.concrete;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import flash.tools.debugger.NoResponseException;
import flash.tools.debugger.Session;
import flash.tools.debugger.SourceFile;
import flash.tools.debugger.SourceLocator;
import flash.tools.debugger.VersionException;
import flash.util.FileUtils;

/**
 * A module which is uniquly identified by an id, contains
 * a short and long name and also a script
 */
public class DModule implements SourceFile
{
	private ScriptText			m_script;			// lazy-initialized by getScript()
	private boolean				m_gotRealScript;
	private final String		m_rawName;
	private final String		m_shortName;
	private final String		m_path;
	private final String		m_basePath;
	private final int			m_id;
	private final int			m_bitmap;
	private final ArrayList<Integer>		m_line2Offset;
	private final ArrayList<Object>			m_line2Func;		// each array is either null, String, or String[]
	private final HashMap<String, Integer>	m_func2FirstLine;	// maps function name (String) to first line of function (Integer)
	private final HashMap<String, Integer>	m_func2LastLine;	// maps function name (String) to last line of function (Integer)
	private String				m_packageName;
	private boolean				m_gotAllFncNames;
	private int					m_anonymousFunctionCounter = 0;
	private SourceLocator		m_sourceLocator;
	private int					m_sourceLocatorChangeCount;
	private int m_isolateId;
	private final static String	m_newline = System.getProperty("line.separator"); //$NON-NLS-1$

	/**
	 * @param name filename in "basepath;package;filename" format
	 */
	public DModule(SourceLocator sourceLocator, int id, int bitmap, String name, String script, int isolateId)
	{
		// If the caller gave us the script text, then we will create m_script
		// now.  But if the caller gave us an empty string, then we won't bother
		// looking for a disk file until someone actually asks for it.
		if (script != null && script.length() > 0)
		{
			m_script = new ScriptText(script);
			m_gotRealScript = true;
		}

		NameParser nameParser = new NameParser(name);

		m_sourceLocator = sourceLocator;
		m_rawName = name;
		m_basePath = nameParser.getBasePath(); // may be null
		m_bitmap = bitmap;
		m_id = id;
		m_shortName = generateShortName(nameParser);
		m_path = generatePath(nameParser);
		m_line2Offset = new ArrayList<Integer>();
		m_line2Func = new ArrayList<Object>();
		m_func2FirstLine = new HashMap<String, Integer>();
		m_func2LastLine = new HashMap<String, Integer>();
		m_packageName = nameParser.getPackage();
        m_gotAllFncNames = false;
        m_isolateId = isolateId;
	}

	public synchronized ScriptText getScript()
	{
		// If we have been using "dummy" source, and the user has changed the list of
		// directories that are searched for source, then we want to search again
		if (!m_gotRealScript &&
			m_sourceLocator != null &&
			m_sourceLocator.getChangeCount() != m_sourceLocatorChangeCount)
		{
			m_script = null;
		}

		// lazy-initialize m_script, so that we don't read a disk file until
		// someone actually needs to look at the file
		if (m_script == null)
		{
            String script = scriptFromDisk(getRawName());
			if (script == null)
			{
				script = ""; // use dummy source for now //$NON-NLS-1$
			}
			else
			{
				m_gotRealScript = true; // we got the real source
			}
			m_script = new ScriptText(script);
		}
		return m_script;
	}

	/* getters */
	public String		getBasePath()			{ return m_basePath; }
	public String		getName()				{ return m_shortName; }
	public String		getFullPath()			{ return m_path; }
	public String       getPackageName()		{ return (m_packageName == null) ? "" : m_packageName; } //$NON-NLS-1$
	public String		getRawName()			{ return m_rawName; }
	public int			getId()					{ return m_id; }
	public int			getBitmap()				{ return m_bitmap; }
	public int			getLineCount()			{ return getScript().getLineCount(); }
	public String		getLine(int i)			{ return (i > getLineCount()) ? "// code goes here" : getScript().getLine(i); } //$NON-NLS-1$

	void setPackageName(String name)    { m_packageName = name; }

	/**
	 * @return the offset within the swf for a given line 
	 * of source.  0 if unknown.
	 */
	public int getOffsetForLine(int line)
	{ 
		int offset = 0;
		if (line < m_line2Offset.size())
		{
			Integer i = m_line2Offset.get(line);
			if (i != null)
				offset = i.intValue();
		}
		return offset;
	}

	public int getLineForFunctionName(Session s, String name)
	{
		int value = -1;
        primeAllFncNames(s);
		Integer i = m_func2FirstLine.get(name);
		if (i != null)
			value = i.intValue();

		return value;
	}

    /*
     * @see flash.tools.debugger.SourceFile#getFunctionNameForLine(flash.tools.debugger.Session, int)
     */
    public String getFunctionNameForLine(Session s, int line)
    {
        primeFncName(s, line);

    	String[] funcNames = getFunctionNamesForLine(s, line);

    	if (funcNames != null && funcNames.length == 1)
    		return funcNames[0];
    	else
    		return null;
    }

	/**
	 * Return the function names for a given line number, or an empty array
	 * if there are none; never returns <code>null</code>.
	 */
    private String[] getFunctionNamesForLine(Session s, int line)
    {
        primeFncName(s, line);

		if (line < m_line2Func.size())
		{
			Object obj = m_line2Func.get(line);
			
			if (obj instanceof String)
				return new String[] { (String) obj };
			else if (obj instanceof String[])
				return (String[]) obj;
		}

		return new String[0];
    }

	public String[] getFunctionNames(Session s)
	{
		/* find out the size of the array */
        primeAllFncNames(s);
		int count = m_func2FirstLine.size();
		return m_func2FirstLine.keySet().toArray(new String[count]);
	}

	private static String generateShortName(NameParser nameParser)
	{
		String name = nameParser.getOriginalName();
		String s = name;

		if (nameParser.isPathPackageAndFilename()) {
			s = nameParser.getFilename();
		} else {
			/* do we have a file name? */
			int dotAt = name.lastIndexOf('.');
			if (dotAt > 1)
			{
				/* yes let's strip the directory off */
				int lastSlashAt = name.lastIndexOf('\\', dotAt);
				lastSlashAt = Math.max(lastSlashAt, name.lastIndexOf('/', dotAt));
	
				s = name.substring(lastSlashAt+1);
			}
			else
			{
				/* not a file name ... */
				s = name;
			}
		}
		return s.trim();
	}

	/**
	 * Produce a name that contains a file specification including full path.
	 * File names may come in as 'mx.bla : file:/bla.foo.as' or as
	 * 'file://bla.foo.as' or as 'C:\'(?) or as 'basepath;package;filename'
	 */
	private static String generatePath(NameParser nameParser)
	{
		String name = nameParser.getOriginalName();
		String s = name;

		/* strip off first colon of stuff if package exists */
		int colonAt = name.indexOf(':');
		if (colonAt > 1 && !name.startsWith("Actions for")) //$NON-NLS-1$
		{
			if (name.charAt(colonAt+1) == ' ')
				s = name.substring(colonAt+2);
		}
		else if (name.indexOf('.') > -1 && name.charAt(0) != '<' )
		{
			/* some other type of file name */
			s = nameParser.recombine();
		}
		else
		{
			// no path
			s = ""; //$NON-NLS-1$
		}
		return s.trim();
	}

	public void lineMapping(StringBuilder sb)
	{
		Map<String, String> args = new HashMap<String, String>();
		args.put("fileName", getName() ); //$NON-NLS-1$
		args.put("fileNumber", Integer.toString(getId()) ); //$NON-NLS-1$
        sb.append(PlayerSessionManager.getLocalizationManager().getLocalizedTextString("functionsInFile", args)); //$NON-NLS-1$
		sb.append(m_newline);

		String[] funcNames = m_func2FirstLine.keySet().toArray(new String[m_func2FirstLine.size()]);
		Arrays.sort(funcNames, new Comparator<String>() {

			public int compare(String o1, String o2) {
				int line1 = m_func2FirstLine.get(o1).intValue();
				int line2 = m_func2FirstLine.get(o2).intValue();
				return line1 - line2;
			}
			
		});

		for (int i=0; i<funcNames.length; ++i)
		{
			String name = funcNames[i];
			int firstLine = m_func2FirstLine.get(name).intValue();
			int lastLine = m_func2LastLine.get(name).intValue();

			sb.append(" "); //$NON-NLS-1$
			sb.append(name);
			sb.append(" "); //$NON-NLS-1$
			sb.append(firstLine);
			sb.append(" "); //$NON-NLS-1$
			sb.append(lastLine);
			sb.append(m_newline);
		}
	}

    int compareTo(DModule other)
    {
        return getName().compareTo(other.getName());
    }

    /**
     * Called in order to make sure that we have a function name available
     * at the given location.  For AVM+ swfs we don't need a swd and therefore
     * don't have access to function names in the same fashion.
     * We need to ask the player for a particular function name.
     */
    void primeFncName(Session s, int line)
    {
		// for now we do all, optimize later
		primeAllFncNames(s);
    }

    void primeAllFncNames(Session s)
    {
        // send out the request for all functions that the player knows
        // about for this module

        // we block on this call waiting for an answer and after we get it
        // the DManager thread should have populated our mapping tables
        // under the covers.  If its fails then no biggie we just won't
        // see anything in the tables.
        PlayerSession ps = (PlayerSession)s;
        if (!m_gotAllFncNames && ps.playerVersion() >= 9)
        {
            try
            {
                ps.requestFunctionNames(m_id, -1, m_isolateId);
            }
            catch (VersionException e)
            {
                ;
            }
            catch (NoResponseException e)
            {
                ;
            }
        }
        m_gotAllFncNames = true;
    }

	void addLineFunctionInfo(int offset, int line, String funcName)
	{
		addLineFunctionInfo(offset, line, line, funcName);
	}

	/**
	 * Called by DSwfInfo in order to add function name / line / offset mapping
	 * information to the module.  
	 */
	void addLineFunctionInfo(int offset, int firstLine, int lastLine, String funcName)
	{
		int line;

		// strip down the function name
		if (funcName == null || funcName.length() == 0)
		{
			funcName = "<anonymous$" + (++m_anonymousFunctionCounter) + ">"; //$NON-NLS-1$ //$NON-NLS-2$
		}
		else
		{
			// colons or slashes then this is an AS3 name, strip off the core::
			int colon = funcName.lastIndexOf(':');
			int slash = funcName.lastIndexOf('/');
			if (colon > -1 || slash > -1)
			{
				int greater = Math.max(colon, slash);
                funcName = funcName.substring(greater+1);
            }
            else
            {
    			int dot = funcName.lastIndexOf('.');
	    		if (dot > -1)
		    	{
                    // extract function and package
                    String pkg = funcName.substring(0, dot);
                    funcName = funcName.substring(dot+1);

                    // attempt to set the package name while we're in here
                    setPackageName(pkg);
//					System.out.println(m_id+"-func="+funcName+",pkg="+pkg);
                }
            }
		}

//		System.out.println(m_id+"@"+offset+"="+getPath()+".adding func="+funcName);

		// make sure m_line2Offset is big enough for the lines we're about to set
		m_line2Offset.ensureCapacity(firstLine+1);
		while (firstLine >= m_line2Offset.size())
			m_line2Offset.add(null);

		// add the offset mapping
		m_line2Offset.set(firstLine, Integer.valueOf(offset));

		// make sure m_line2Func is big enough for the lines we're about to se
		m_line2Func.ensureCapacity(lastLine+1);
		while (lastLine >= m_line2Func.size())
			m_line2Func.add(null);

		// offset and byteCode ignored currently, only add the name for the first hit
		for (line = firstLine; line <= lastLine; ++line)
		{
			Object funcs = m_line2Func.get(line);
			// A line can correspond to more than one function.  The most common case
			// of that is an MXML tag with two event handlers on the same line, e.g.
			//		<mx:Button mouseOver="overHandler()" mouseOut="outHandler()" />;
			// another case is the line that declares an inner anonymous function:
			//		var f:Function = function() { trace('hi') }
			// In any such case, we store a list of function names separated by commas,
			// e.g. "func1, func2"
			if (funcs == null)
			{
				m_line2Func.set(line, funcName);
			}
			else if (funcs instanceof String)
			{
				String oldFunc = (String) funcs;
				m_line2Func.set(line, new String[] { oldFunc, funcName });
			}
			else if (funcs instanceof String[])
			{
				String[] oldFuncs = (String[]) funcs;
				String[] newFuncs = new String[oldFuncs.length + 1];
				System.arraycopy(oldFuncs, 0, newFuncs, 0, oldFuncs.length);
				newFuncs[newFuncs.length - 1] = funcName;
				m_line2Func.set(line, newFuncs);
			}
		}

		// add to our function name list
		if (m_func2FirstLine.get(funcName) == null)
		{
			m_func2FirstLine.put(funcName, Integer.valueOf(firstLine));
			m_func2LastLine.put(funcName, Integer.valueOf(lastLine));
		}
	}

    /**
     * Scan the disk looking for the location of where the source resides.  May
     * also peel open a swd file looking for the source file.
     * @param name original full path name of the source file
     * @return string containing the contents of the file, or null if not found
     */
    private String scriptFromDisk(String name)
    {
        // we expect the form of the filename to be in the form
        // "c:/src/project;debug;myFile.as"
        // where the semicolons demark the include directory searched by the
        // compiler followed by package directories then file name.
        // any slashes are to be forward slash only!

        // translate to neutral form
        name = name.replace('\\','/');  //@todo remove this when compiler is complete

        // pull the name apart
        final char SEP = ';';
        String pkgPart = ""; //$NON-NLS-1$
        String pathPart = ""; //$NON-NLS-1$
        String namePart = ""; //$NON-NLS-1$
        int at = name.indexOf(SEP);
        if (at > -1)
        {
            // have at least 2 parts to name
            int nextAt = name.indexOf(SEP, at+1);
            if (nextAt > -1)
            {
                // have 3 parts
                pathPart = name.substring(0, at);
                pkgPart = name.substring(at+1, nextAt);
                namePart = name.substring(nextAt+1);
            }
            else
            {
                // 2 parts means no package.
                pathPart = name.substring(0, at);
                namePart = name.substring(at+1);
            }
        }
        else
        {
            // should not be here....
            // trim by last slash
            at = name.lastIndexOf('/');
            if (at > -1)
            {
				// cheat by looking for dirname "mx" in path
				int mx = name.lastIndexOf("/mx/"); //$NON-NLS-1$
				if (mx > -1)
				{
					pathPart = name.substring(0, mx);
					pkgPart = name.substring(mx+1, at);
				}
				else
				{
					pathPart = name.substring(0, at);
				}
				
                namePart = name.substring(at+1);
            }
            else
            {
                pathPart = "."; //$NON-NLS-1$
                namePart = name;
            }
        }

        String script = null;
        try
        {
            // now try to locate the thing on disk or in a swd.
        	Charset realEncoding = null;
        	Charset bomEncoding = null;
        	InputStream in = locateScriptFile(pathPart, pkgPart, namePart);
        	if (in != null)
        	{
        		try
        		{
        			// Read the file using the appropriate encoding, based on
        			// the BOM (if there is a BOM) or the default charset for
        			// the system (if there isn't a BOM)
                    BufferedInputStream bis = new BufferedInputStream( in );
                    bomEncoding = getEncodingFromBOM(bis);
        			script = pullInSource(bis, bomEncoding);

        			// If the file is an XML file with an <?xml> directive,
        			// it may specify a different directive 
        			realEncoding = getEncodingFromXMLDirective(script);
        		}
        		finally
        		{
        			try { in.close(); } catch (IOException e) {}
        		}
        	}
        	
        	// If we found an <?xml> directive with a specified encoding, and
        	// it doesn't match the encoding we used to read the file initially,
        	// start over.
        	if (realEncoding != null && !realEncoding.equals(bomEncoding))
        	{
	            in = locateScriptFile(pathPart, pkgPart, namePart);
	            if (in != null)
	            {
					try
					{
						// Read the file using the real encoding, based on the
						// <?xml...> directive
	                    BufferedInputStream bis = new BufferedInputStream( in );
	                    getEncodingFromBOM(bis);
	        			script = pullInSource(bis, realEncoding);
					}
					finally
					{
						try { in.close(); } catch (IOException e) {}
					}
	            }
        	}
        }
        catch(FileNotFoundException fnf)
        {
            fnf.printStackTrace();  // shouldn't really happen
        }
        return script;
    }

    /**
     * Logic to poke around on disk in order to find the given
     * filename.  We look under the mattress and all other possible
     * places for the silly thing.  We always try locating
     * the file directly first, if that fails then we hunt out
     * the swd.
     */
    InputStream locateScriptFile(String path, String pkg, String name) throws FileNotFoundException
    {
		if (m_sourceLocator != null)
		{
			m_sourceLocatorChangeCount = m_sourceLocator.getChangeCount();
			InputStream is = m_sourceLocator.locateSource(path, pkg, name);
			if (is != null)
				return is;
		}

        // convert slashes first
        path = path.replace('/', File.separatorChar);
        pkg = pkg.replace('/', File.separatorChar);
        File f;

        // use a package base directory if it exists
		if (path.length() > 0)
		{
	        try
	        {
				String pkgAndName = ""; //$NON-NLS-1$
				if (pkg.length() > 0) // have to do this so we don't end up with just "/filename"
					pkgAndName += pkg + File.separatorChar;
				pkgAndName += name;
	            f = new File(path, pkgAndName);
	            if (f.exists())
	                return new FileInputStream(f);
	        }
	        catch(NullPointerException npe)
	        {
	            // skip it.
	        }
		}

        // try the current directory plus package
		if (pkg.length() > 0) // new File("", foo) looks in root directory!
		{
			f = new File(pkg, name);
			if (f.exists())
				return new FileInputStream(f);
		}

        // look in the current directory without the package
        f = new File(name);
        if (f.exists())
            return new FileInputStream(f);

        // @todo try to pry open a swd file...
               
        return null;
    }
    
    /**
     * See if this document starts with a BOM and try to figure
     * out an encoding from it.
     * @param bis		BufferedInputStream for document (so that we can reset the stream
     * 					if we establish that the first characters aren't a BOM)
     * @return			CharSet from BOM (or system default / null)
     */
	private Charset getEncodingFromBOM(BufferedInputStream bis)
	{
		Charset bomEncoding = null;
		bis.mark(3);
		String bomEncodingString;
		try
		{
			bomEncodingString = FileUtils.consumeBOM(bis, null);
		}
		catch (IOException e)
		{
			bomEncodingString = System.getProperty("file.encoding"); //$NON-NLS-1$
		}

		bomEncoding = Charset.forName(bomEncodingString);

		return bomEncoding;
	}

    /**
     * Syntax for an <?xml ...> directive with an encoding (used by getEncodingFromXMLDirective)
     */
    private static final Pattern sXMLDeclarationPattern = Pattern.compile("^<\\?xml[^>]*encoding\\s*=\\s*(\"([^\"]*)\"|'([^']*)')"); //$NON-NLS-1$
    
    /**
     * See if this document starts with an <?xml ...> directive and
     * try to figure out an encoding from it.
     * @param entireSource		source of document
     * @return					specified Charset (or null)
     */
    private Charset getEncodingFromXMLDirective(String entireSource)
    {
    	String encoding = null;
    	Matcher xmlDeclarationMatcher = sXMLDeclarationPattern.matcher(entireSource);
    	if (xmlDeclarationMatcher.find())
    	{
    		encoding = xmlDeclarationMatcher.group(2);
    		if (encoding == null)
    			encoding = xmlDeclarationMatcher.group(3);
    		
    		try
    		{
    			return Charset.forName(encoding);
    		}
    		catch (IllegalArgumentException e)
    		{}
    	}
    	return null;
    }

    /**
     * Given an input stream containing source file contents, read in each line
     * @param in			stream of source file contents (with BOM removed)
     * @param encoding		encoding to use (based on BOM, system default, or <?xml...> directive
     * 						if this is null, the system default will be used)
     * @return				source file contents (as String)
     */
    String pullInSource(InputStream in, Charset encoding)
    {
        String script = ""; //$NON-NLS-1$
        BufferedReader f = null;
        try
        {
        	StringBuilder sb = new StringBuilder();
        	Reader reader = null;
        	if (encoding == null)
        		reader = new InputStreamReader(in);
        	else
        		reader = new InputStreamReader(in, encoding);
            f = new BufferedReader(reader);
            String line;
            while((line = f.readLine()) != null)
            {
                sb.append(line);
                sb.append('\n');
            }
            script = sb.toString();
        }
        catch (IOException e)
        {
            e.printStackTrace();  //To change body of catch statement use File | Settings | File Templates.
        }
        return script;
    }

    /** for debugging */
    @Override
	public String toString()
    {
    	return getFullPath();
    }
 
	/**
	 * Given a filename of the form "basepath;package;filename", return an
	 * array of 3 strings, one for each segment.
	 * @param name a string which *may* be of the form "basepath;package;filename"
	 * @return an array of 3 strings for the three pieces; or, if 'name' is
	 * not of expected form, returns null
	 */
	private static class NameParser
	{
		private String fOriginalName;
		private String fBasePath;
		private String fPackage;
		private String fFilename;
		private String fRecombinedName;

		public NameParser(String name)
		{
			fOriginalName = name;

			/* is it of "basepath;package;filename" format? */
			int semicolonCount = 0;
			int i = 0;
			int firstSemi = -1;
			int lastSemi = -1;
			while ( (i = name.indexOf(';', i)) >= 0 )
			{
				++semicolonCount;
				if (firstSemi == -1)
					firstSemi = i;
				lastSemi = i;
				++i;
			}

			if (semicolonCount == 2)
			{
				fBasePath = name.substring(0, firstSemi);
				fPackage = name.substring(firstSemi+1, lastSemi);
				fFilename = name.substring(lastSemi+1);
			}
		}

		public boolean isPathPackageAndFilename()
		{
			return (fBasePath != null);
		}

		public String getOriginalName()
		{
			return fOriginalName;
		}

		public String getBasePath()
		{
			return fBasePath;
		}

		public String getFilename()
		{
			return fFilename;
		}

		public String getPackage()
		{
			return fPackage;
		}

		/**
		 * Returns a "recombined" form of the original name.
		 * 
		 * For filenames which came in in the form "basepath;package;filename",
		 * the recombined name is the original name with the semicolons replaced
		 * by platform-appropriate slash characters.  For any other type of original
		 * name, the recombined name is the same as the incoming name.
		 */
		public String recombine()
		{
			if (fRecombinedName == null)
			{
				if (isPathPackageAndFilename())
				{
					char slashChar;
					if (fOriginalName.indexOf('\\') != -1)
						slashChar = '\\';
					else
						slashChar = '/';

					fRecombinedName = fOriginalName.replaceAll(";;", ";").replace(';', slashChar); //$NON-NLS-1$ //$NON-NLS-2$
				}
				else
				{
					fRecombinedName = fOriginalName;
				}
			}
			return fRecombinedName;
		}
	}

}
