content: /**
   * Copyright (C) 2012 the original author or authors.
   *
   * Licensed 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 co.jirm.core.sql;
  
  import static co.jirm.core.util.JirmPrecondition.check;
  import static com.google.common.base.Strings.emptyToNull;
  import static com.google.common.base.Strings.nullToEmpty;
  import static com.google.common.collect.Maps.newHashMap;
  import static com.google.common.collect.Maps.newLinkedHashMap;
  
  import java.io.IOException;
  import java.io.StringReader;
  import java.net.URI;
  import java.util.List;
  import java.util.Map;
  import java.util.Set;
  import java.util.concurrent.Callable;
  import java.util.concurrent.ExecutionException;
  import java.util.concurrent.TimeUnit;
  import java.util.regex.Matcher;
  import java.util.regex.Pattern;
  
  import co.jirm.core.JirmIllegalArgumentException;
  import co.jirm.core.JirmIllegalStateException;
  import co.jirm.core.sql.SqlPartialParser.ResourceLoader.CachedResourceLoader;
  import co.jirm.core.util.JirmUrlEncodedUtils;
  import co.jirm.core.util.ResourceUtils;
  
  import com.google.common.base.Charsets;
  import com.google.common.base.Joiner;
  import com.google.common.base.Optional;
  import com.google.common.cache.Cache;
  import com.google.common.cache.CacheBuilder;
  import com.google.common.collect.ImmutableList;
  import com.google.common.collect.ImmutableMap;
  import com.google.common.collect.ImmutableSet;
  import com.google.common.collect.Maps;
  import com.google.common.collect.Sets;
  import com.google.common.io.LineReader;
  import com.google.common.io.Resources;
  import com.google.common.util.concurrent.UncheckedExecutionException;
  
  
  public class SqlPartialParser {
  	
  	private static final Pattern tokenPattern = Pattern.compile("^[ \t]*-- ?\\{(.*?)\\}[ \t]*$");
  	/*
  	 * FileDeclaration = SQL and list of HashDeclaration and list of References
  	 * HashDeclaration = list of References and SQL
  	 * References = SQL
  	 */
  	
  	protected abstract static class DeclarationSql {
  		private final Path path;
  		private final List<String> declaredSql;
  		private final List<ReferenceSql> references;
  		protected DeclarationSql(String path, List<String> declaredSql, List<ReferenceSql> references) {
  			super();
  			this.path = Path.create(path);
  			this.declaredSql = declaredSql;
  			this.references = references;
  		}
  
  		public Path getPath() {
  			return path;
  		}
  		
  		public List<String> getDeclaredSql() {
  			return declaredSql;
  		}
  		
  		public List<ReferenceSql> getReferences() {
  			return references;
  		}
  
  		@Override
  		public String toString() {
  			return "DeclarationSql [path=" + path + ", declaredSql=" + declaredSql + ", references=" + references + "]";
  		}
  		
  		public abstract boolean isHash();
  		
  		public abstract boolean isFile();
  		
  		public abstract int getStartIndex();
  		
  		public List<String> inner() {
  			return isHash() ? declaredSql.subList(1, declaredSql.size() - 1) : declaredSql;
  		}
  		
  		public String join() {
  			return Joiner.on("\n").join(inner());
  		}
  		
  	}
  	
  	protected static class FileDeclarationSql extends DeclarationSql {
  		
  		private final Map<String, HashDeclarationSql> hashDeclarations;
  
  		private FileDeclarationSql(String path, List<String> declaredSql, List<ReferenceSql> references, Map<String, HashDeclarationSql> hashDeclarations) {
  			super(path, declaredSql, references);
  			this.hashDeclarations = hashDeclarations;
  		}
  		
  		public Map<String, HashDeclarationSql> getHashDeclarations() {
  			return hashDeclarations;
  		}
  		
  		@Override
  		public int getStartIndex() {
  			return 0;
  		}
  		
  		public boolean isHash() {
  			return false;
  		}
  		
  		public boolean isFile() {
  			return true;
  		}
  		
  	}
  	
  	protected static class HashDeclarationSql extends DeclarationSql {
  
  		private final int startIndex;
  		private final int length;
  		
  		private HashDeclarationSql(String path, List<String> declaredSql, List<ReferenceSql> references, int startIndex, int length) {
  			super(path, declaredSql, references);
  			this.startIndex = startIndex;
  			this.length = length;
  		}
  		
  		
  		public int getStartIndex() {
  			return startIndex;
  		}
  		
  		public int getLength() {
  			return length;
  		}
  		@Override
  		public boolean isHash() {
  			return true;
  		}
  		public boolean isFile() {
  			return false;
  		}
  		
  		public String header() {
  			return getDeclaredSql().get(0);
  		}
  		
  		public String footer() {
  			return getDeclaredSql().get(getDeclaredSql().size() - 1);
  		}
  		
  	}
  	
  	protected static class ReferenceHeader {
  		private final Path path;
  		private final Map<String, List<String>> parameters;
  		
  		private ReferenceHeader(Path path, Map<String, List<String>> parameters) {
  			super();
  			this.path = path;
  			this.parameters = parameters;
  		}
  		
  		public static ReferenceHeader parse(String tag) {
  			tag = tag.trim();
  			String t = tag.startsWith(">") ? tag.substring(1).trim() : tag;
  			
  			String[] parts = t.split("[ \t]+");
  			//final String u;
  			//final URI uri;
  			final String path;
  			final String fragment;
  			final String query;
  			if (parts.length > 1) {
  				check.state( ! t.contains("?"), "Cannot mix space and '?' style");
  				URI u = URI.create(parts[0]);
  				path = u.getPath();
  				fragment = u.getFragment();
  				query = parts[1];
  			}
  			else {
  				URI u = URI.create(t);
  				path = u.getPath();
  				fragment = u.getFragment();
  				query = u.getQuery();				
  			}
  			
  			StringBuilder s = new StringBuilder();
  			if (path != null) {
  				s.append(path);
  			}
  			if (emptyToNull(query) != null) {
  				s.append("?").append(query);
  			}
  			if(fragment != null) {
  				s.append("#").append(fragment);
  			}
  			URI uri = URI.create(s.toString());
  			
  			Map<String, List<String>> parameters = ImmutableMap.copyOf(JirmUrlEncodedUtils.parseParameters(uri, "UTF-8"));
  			
  			String p = (uri.getPath() != null ? uri.getPath() : "")
  					+  (nullToEmpty(uri.getFragment()).isEmpty() ? "" : "#" + uri.getFragment());
  			return new ReferenceHeader(Path.create(p), parameters);
  		}
  		
  		
  		public Path getPath() {
  			return path;
  		}
  		
  		public Map<String, List<String>> getParameters() {
  			return parameters;
  		}
  		
  	}
  	
  	protected static class ReferenceSql {
  		private final Path referencePath;
  		private final Path currentPath;
  		private final List<String> declaredSql;
  		private final int startIndex;
  		private final int length;
  		private final Map<String,List<String>> parameters;
  		
  		private ReferenceSql(String referencePath, String currentPath, List<String> declaredSql, int startIndex, int length, Map<String,List<String>> parameters) {
  			super();
  			this.referencePath = Path.create(referencePath);
  			this.currentPath = Path.create(currentPath);
  			this.declaredSql = declaredSql;
  			this.startIndex = startIndex;
  			this.length = length;
  			this.parameters = parameters;
  		}
  		
  		
  		public List<String> getDeclaredSql() {
  			return declaredSql;
  		}
  		
  		public Path getCurrentPath() {
  			return currentPath;
  		}
  		
  		public Path getReferencePath() {
  			return referencePath;
  		}
  		
  		public int getStartIndex() {
  			return startIndex;
  		}
  		
  		public int getLength() {
  			return length;
  		}
  		
  		public Map<String, List<String>> getParameters() {
  			return parameters;
  		}
  		
  		public boolean isSame() {
  			List<String> values = getParameters().get("same");
  			if (values == null) {
  				return false;
  			}
  			if (values.isEmpty()) {
  				return false;
  			}
  			String v = values.get(values.size() - 1);
  			if ("false".equals(v)) {
  				return false;
  			}
  			
  			return true;
  		}
  		
  		public List<String> inner() {
  			return declaredSql.subList(1, declaredSql.size() - 1);
  		}
  		
  		public String join() {
  			return Joiner.on("\n").join(inner());
  		}
  		
  	}
  	
  	public static class ExpandedSql {
  		private final List<String> expanded;
  		private final DeclarationSql declaration;
  		
  		private ExpandedSql(List<String> expanded, DeclarationSql declaration) {
  			super();
  			this.expanded = expanded;
  			this.declaration = declaration;
  		}
  		
  		public static ExpandedSql create(DeclarationSql declaration, List<String> expanded) {
  			return new ExpandedSql(expanded, declaration);
  		}
  		
  		public List<String> getExpanded() {
  			return expanded;
  		}
  		
  		public DeclarationSql getDeclaration() {
  			return declaration;
  		}
  		
  		public List<String> inner() {
  			return declaration.isHash() ? expanded.subList(1, expanded.size() - 1) : expanded;
  		}
  		
  		public String join() {
  			return Joiner.on("\n").join(inner());
  		}
  
  		@Override
  		public String toString() {
  			return "ExpandedSql [expanded=" + expanded + ", declaration=" + declaration + "]";
  		}
  		
  		
  	}
  	
  	public static class Path {
  		final String path;
  		final Optional<String> hash;
  		private Path(String path, Optional<String> hash) {
  			super();
  			this.path = path;
  			this.hash = hash;
  		}
  		public static Path create(String path) {
  			String[] parts = path.split("#");
  			if (parts.length > 1) {
  				return new Path(parts[0], Optional.of(parts[1]));
  			}
  			return new Path(parts[0], Optional.<String>absent());
  		}
  		
  		public String getPathWithOutHash() {
  			return path;
  		}
  		
  		public Optional<String> getHash() {
  			return hash;
  		}
  		
  		public String getFullPath() {
  			return hash.isPresent() ? path + "#" + hash.get() : path;
  		}
  		
  		public boolean isRelative() {
  			return isJustHash() || ! path.startsWith("/");
  		}
  		
  		public boolean isJustHash() {
  			return hash.isPresent() && nullToEmpty(path).isEmpty();
  		}
  		
  		public Path parent() {
  			check.state(! isJustHash(), "no parent for just hash");
  			int index = path.lastIndexOf("/");
  			check.state( index > -1, "no parent");
  			String parentPath = path.substring(0, index);
  			return Path.create(parentPath);
  		}
  		
  		public Path fromRelative(Path p) {
  			check.state(p.isRelative(), "path should be relative");
  			final Path r;
  			if (p.isJustHash()) {
  				r = Path.create(this.getPathWithOutHash() + p.getFullPath());
  			}
  			else {
  				r = Path.create(this.parent().getPathWithOutHash() + "/" + p.getFullPath());
  			}
  			return r;
  		}
  		@Override
  		public String toString() {
  			return "Path [" + getFullPath() + "]";
  		}
  		
  		
  	}
  	
  	
  	
  	private enum PSTATE {
  		HASH,
  		REFERENCE,
  		OTHER,
  	}
  	
  	protected static interface ResourceLoader {
  		public String load(String path);
  		public static ResourceLoader DEFAULT_LOADER = new ResourceLoader() {
  			@Override
  			public String load(String path) {
  				try {
  					String p = check.notNull(path, "path is null");
  					check.state(p.startsWith("/"), "path should start with '/' but was: {}", path);
  					p = p.substring(1);
  					return Resources.toString(Resources.getResource(p), 
  							Charsets.UTF_8);
  				} catch (IOException e) {
  					throw new RuntimeException(e);
  				}
  			}
  		};
  		
  		public static class CachedResourceLoader implements ResourceLoader {
  			@Override
  			public String load(String path) {
  				try {
  					String p = check.notNull(path, "path is null");
  					check.state(p.startsWith("/"), "path should start with '/' but was: {}", path);
  					p = p.substring(1);
  					return ResourceUtils.getClasspathResourceAsString(p);
  				} catch (IOException e) {
  					throw new RuntimeException(e);
  				}
  			}
  		}
  		
  		public static ResourceLoader CACHED_LOADER = new CachedResourceLoader();
  	}
  	
  	private static Cache<String, ExpandedSql> fromPathCache = CacheBuilder.newBuilder()
  			.maximumSize(100)
  			.expireAfterAccess(ResourceUtils.expire, TimeUnit.SECONDS)
  			.build();
  	
  	private static Cache<String, String> fromPathToStringCache = CacheBuilder.newBuilder()
  			.maximumSize(100)
  			.expireAfterAccess(ResourceUtils.expire, TimeUnit.SECONDS)
  			.build();
  	
  	public static String parseFromPath(final String path) {
  		try {
  			return fromPathToStringCache.get(path, new Callable<String>() {
  				@Override
  				public String call() throws Exception {
  					return _parseFromPath(path);
  				}
  			});
  		} 
  		catch (ExecutionException e) {
  			throw new RuntimeException(e);
  		}
  		catch (UncheckedExecutionException e) {
  			if (e.getCause() instanceof JirmIllegalStateException 
  					|| e.getCause() instanceof JirmIllegalArgumentException) {
  				throw (RuntimeException) e.getCause();
  			}
  			throw e;
  		}
  	}
  	
  	private static String _parseFromPath(final String path) {
  		Map<String, ExpandedSql> c = newLinkedHashMap(fromPathCache.asMap());
  		Parser p = new Parser(CachedResourceLoader.CACHED_LOADER, c);
  		fromPathCache.asMap().putAll(c);
  		return p.expand(path).join();
  	}
  	
  	public static String parseFromPath(Class<?> k , final String path) {
  		String resolved;
  		resolved = "/" + check.notNull(ResourceUtils.resolvePath(k, path), "bug path failed: {}", path);
  		return parseFromPath(resolved);
  	}
  	
  	public static class Parser {
  
  		private final ResourceLoader loader;
  		private final Map<String, ExpandedSql> cache;
  		
  		private Parser(ResourceLoader resourceLoader, Map<String, ExpandedSql> cache) {
  			super();
  			this.loader = resourceLoader;
  			this.cache = cache;
  		}
  		
  		public static Parser create() {
  			return new Parser(ResourceLoader.DEFAULT_LOADER, Maps.<String, ExpandedSql> newLinkedHashMap());
  		}
  
  		public ExpandedSql expand(String path) {
  			return _expand(path, Sets.<String>newHashSet());
  		}
  
  		protected ExpandedSql _expand(String path, Set<String> seenPaths) {
  			check.state(! seenPaths.contains(path), "Cycle detected for path: {}, paths involved: {}",  
  					path, seenPaths);
  			if (seenPaths.contains(path)) {
  			}
  			Path p = Path.create(path);
  			ExpandedSql loaded = cache.get(p.getFullPath());
  			if (loaded != null)
  				return loaded;
  			boolean hash = p.getHash().isPresent();
  			final ExpandedSql e;
  			if (hash) {
  				ExpandedSql ef = cache.get(p.getPathWithOutHash());
  				final FileDeclarationSql fd;
  				if (ef != null) {
  					fd = (FileDeclarationSql) ef.getDeclaration();
  				}
  				else {
  					fd = loadFile(p.getPathWithOutHash(), loader);
  				}
  				HashDeclarationSql h = fd.getHashDeclarations().get(p.getHash().get());
  				check.notNull(h, "Not Found: {}, Hash #{} not found in file: {}", 
  						p.getFullPath(), p.getHash().get(), p.getPathWithOutHash());
  				
  				e = _expand(h, seenPaths);
  			}
  			else {
  				FileDeclarationSql fd = loadFile(path, loader);
  				e = _expand(fd, seenPaths);
  			}
  			cache.put(path, e);
  			return e;
  		}
  
  		protected ExpandedSql _expand(ReferenceSql f, Set<String> seenPaths) {
  			Path cp = f.getCurrentPath();
  			Path rp = f.getReferencePath();
  			final Path path;
  			if (rp.isRelative()) {
  				path = cp.fromRelative(rp);
  			}
  			else {
  				path = rp;
  			}
  			return _expand(path.getFullPath(), seenPaths);
  		}
  
  		protected ExpandedSql _expand(DeclarationSql f, Set<String> seenPaths) {
  
  			ImmutableList.Builder<String> sb = ImmutableList.builder();
  			
  			List<String> lines = f.getDeclaredSql();
  
  			List<ReferenceSql> references = f.getReferences();
  			Map<Integer, ExpandedSql> byLine = Maps.newLinkedHashMap();
  			Map<Integer, ReferenceSql> referenceSql = Maps.newLinkedHashMap();
  			
  			for (ReferenceSql r : references) {
  
  				ExpandedSql e = _expand(r, seenPaths);
  				r.getStartIndex();
  				
  				DeclarationSql ds = e.getDeclaration();
  				boolean validate = ! r.isSame() || ds.inner().equals(r.inner());
  				check.state(validate, 
  						"Reference '> {}' in {} at line: {}" +
  						" does" +
  						"\nNOT MATCH declaration {} at line: {}" +
  						"\nREFERENCE:" +
  						"\n{}\n" +
  						"DECLARATION:" +
  						"\n{}\n",
  						r.getReferencePath().getFullPath(),
  						r.getCurrentPath().getFullPath(),
  						r.getStartIndex(),
  						ds.getPath().getFullPath(),
  						ds.getStartIndex(),
  						r.join(), 
  						ds.join());
  				byLine.put(r.getStartIndex(), e);
  				referenceSql.put(r.getStartIndex(), r);
  			}
  			
  			
  			final Set<String> hashLinesToRemove;
  			if (f.isFile()) {
  				FileDeclarationSql fd = (FileDeclarationSql) f;
  				hashLinesToRemove = Sets.newHashSetWithExpectedSize(fd.getHashDeclarations().size() * 2);
  				for (HashDeclarationSql hd : fd.getHashDeclarations().values()) {
  					hashLinesToRemove.add(hd.header());
  					hashLinesToRemove.add(hd.footer());
  				}
  			}
  			else {
  				hashLinesToRemove = ImmutableSet.<String>of();
  			}
  			
  			/*
  			 * Replace the reference SQL with the expanded SQL
  			 */
  			for (int i = 0; i < lines.size();) {
  				ExpandedSql e = byLine.get(i + f.getStartIndex());
  				ReferenceSql r = referenceSql.get(i + f.getStartIndex());
  				
  				String line = lines.get(i);
  				if (e != null) {
  					sb.addAll(e.inner());
  					i += r.getLength();
  				}
  				else if (hashLinesToRemove.contains(line)) {
  					i++;
  				}
  				else {
  					sb.add(line);
  					i++;
  				}
  			}
  			return ExpandedSql.create(f, sb.build());
  		}
  
  
  	}
  	
  	
  	private static FileDeclarationSql loadFile(String path, ResourceLoader loader) {
  		return processFile(path, loader.load(path));
  	}
  	
  	public static FileDeclarationSql processFile(String path, String sql) {
  		try {
  			return _processFile(path, sql);
  		} catch (IOException e) {
  			throw new RuntimeException(e);
  		}
  	}
  	
  	
  	private static FileDeclarationSql _processFile(String path, String sql) throws IOException {
  		
  		LineReader lr = new LineReader(new StringReader(sql));
  		String line;
  		ImmutableList.Builder<ReferenceSql> references = ImmutableList.builder();
  		ImmutableMap.Builder<String,HashDeclarationSql> hashes = ImmutableMap.builder();
  		ImmutableList.Builder<ReferenceSql> hashReferences = ImmutableList.builder();
  		Map<String, HashDeclarationSql> nameToHash = newHashMap();
  		
  		ImmutableList.Builder<String> referenceContent = ImmutableList.builder();
  		ImmutableList.Builder<String> hashContent = ImmutableList.builder();
  		ImmutableList.Builder<String> fileContent = ImmutableList.builder();
  
  		
  		boolean first = true;
  		PSTATE state = PSTATE.OTHER;
  		PSTATE previousState = PSTATE.OTHER;
  		
  		String currentHash = null;
  		String currentReference = null;
  		Map<String,List<String>> currentReferenceParameters = ImmutableMap.of();
  		int hashStartIndex = 0;
  		int referenceStartIndex = 0;
  		int lineIndex = 0;
  		
  		String PE = "For path: '{}', ";
  		
  		while ( (line = lr.readLine()) != null) {
  			if (first) first = false;
  			Matcher m = tokenPattern.matcher(line);
  			String tag;
  			if (m.matches() && (tag = m.group(1)) != null && ! (tag = tag.trim()).isEmpty()) {
  				if (tag != null && tag.startsWith("#")) {
  					check.state(state != PSTATE.HASH, PE + "Cannot hash within hash at line: {}.", path, lineIndex);
  					state = PSTATE.HASH;
  					hashContent = ImmutableList.builder();
  					hashReferences = ImmutableList.builder();
  					currentHash = tag.substring(1).trim();
  					HashDeclarationSql existing = nameToHash.get(currentHash);
  					if (existing != null) {
  						throw check.stateInvalid( 
  								PE + "Hash: '#{}' already defined at line: {}, new definition at line: {}",
  								path,
  								currentHash, existing.getStartIndex(), lineIndex);
  					}
  					hashContent.add(line);
  					hashStartIndex = lineIndex;
  				}
  				else if (tag != null && tag.startsWith(">")) {
  					check.state(state != PSTATE.REFERENCE, PE + "Cannot reference within reference at line: {}.", path, lineIndex);
  					previousState = state;
  					state = PSTATE.REFERENCE;
  					referenceContent = ImmutableList.builder();
  					ReferenceHeader h = ReferenceHeader.parse(tag);
  					currentReference = h.getPath().getFullPath();
  					currentReferenceParameters = h.getParameters();
  					
  					check.state(! currentReference.isEmpty(), PE + "No reference defined", path);
  					referenceStartIndex = lineIndex;
  					referenceContent.add(line);
  					if (previousState == PSTATE.HASH) {
  						hashContent.add(line);
  					}
  				}
  				else if (tag != null && tag.equals("<")) {
  					check.state(state == PSTATE.REFERENCE, PE + "Invalid close of reference line: {}", path, lineIndex);
  					state = previousState;
  					int length = lineIndex - referenceStartIndex + 1;
  					referenceContent.add(line);
  					check.state(length > -1, "length should be greater than -1");
  					check.state(length >= 0, PE + "Hash Line index incorrect. Index: {}, Reference start: {}", path, lineIndex, referenceStartIndex);
  					ReferenceSql rsql = new ReferenceSql(currentReference, 
  							path, referenceContent.build(), referenceStartIndex, length, currentReferenceParameters);
  					references.add(rsql);
  					if (PSTATE.HASH == previousState) {
  						hashReferences.add(rsql);
  						hashContent.add(line);
  					}
  				}
  				else if (tag != null && tag.startsWith("/")) {
  					check.state(state == PSTATE.HASH, PE + "Hash not started or reference not finished line: {}", path, lineIndex);
  					String t = tag.substring(1).trim();
  					check.state(! t.isEmpty(), PE + "No close hash is defined at line: {}", path, lineIndex);
  					check.state(t.equals(currentHash), PE + "Should be current hash tag: {} at line: {}", path, currentHash, lineIndex);
  					state = PSTATE.OTHER;
  					int length = lineIndex - hashStartIndex + 1;
  					hashContent.add(line);
  					check.state(length >= 0, PE + "Hash Line index incorrect. Index: {}, Hash start: {}", path, lineIndex, hashStartIndex);
  					HashDeclarationSql hash = new HashDeclarationSql(path + "#" + currentHash, hashContent.build(), hashReferences.build(), hashStartIndex, length);
  					nameToHash.put(currentHash, hash);
  					hashes.put(currentHash, hash);
  				}
  				else {
  					throw check.stateInvalid(PE + "Malformed hash or reference: {} at line: {}", path, tag, lineIndex);
  				}
  			}
  			else {
  				if (PSTATE.HASH == state || PSTATE.HASH == previousState) {
  					hashContent.add(line);
  				}
  				if (PSTATE.REFERENCE == state) {
  					referenceContent.add(line);
  				}
  			}
  			fileContent.add(line);
  			
  			lineIndex++;
  		}
  
  		check.state(PSTATE.OTHER == state, "Reference or hash not closed");
  		FileDeclarationSql f = new FileDeclarationSql(path, fileContent.build(), references.build(), hashes.build());
  		return f;
  
  	}
  	
  
  }
  