/**
 * 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 org.apache.cxf.management.web.logging.atom;

import java.io.IOException;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.logging.Handler;

import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.StreamingOutput;
import javax.ws.rs.core.UriBuilder;

import org.apache.abdera.model.Entry;
import org.apache.abdera.model.Feed;
import org.apache.cxf.Bus;
import org.apache.cxf.jaxrs.ext.MessageContext;
import org.apache.cxf.jaxrs.ext.StreamingResponse;
import org.apache.cxf.jaxrs.ext.search.ConditionType;
import org.apache.cxf.jaxrs.ext.search.OrSearchCondition;
import org.apache.cxf.jaxrs.ext.search.PrimitiveStatement;
import org.apache.cxf.jaxrs.ext.search.SearchCondition;
import org.apache.cxf.jaxrs.ext.search.SearchConditionVisitor;
import org.apache.cxf.jaxrs.ext.search.SearchContext;
import org.apache.cxf.jaxrs.utils.ExceptionUtils;
import org.apache.cxf.management.web.logging.LogLevel;
import org.apache.cxf.management.web.logging.LogRecord;
import org.apache.cxf.management.web.logging.ReadWriteLogStorage;
import org.apache.cxf.management.web.logging.ReadableLogStorage;
import org.apache.cxf.management.web.logging.atom.converter.StandardConverter;


@Path("/logs")
public class AtomPullServer extends AbstractAtomBean {

    private List<LogRecord> records = new LinkedList<LogRecord>();
    private WeakHashMap<Integer, Feed> feeds = new WeakHashMap<Integer, Feed>();
    private ReadableLogStorage storage;
    private int pageSize = 20;
    private int maxInMemorySize = 1000;
    private volatile int recordsSize;
    private volatile boolean alreadyClosed;
    private SearchCondition<LogRecord> readableStorageCondition;
    private Map<String, Object> activeStreams;
    
    @Context
    private MessageContext context;
    
    private List<String> endpointAddresses;
    private String serverAddress;
    
    public void setEndpointAddress(String address) {
        setEndpointAddresses(Collections.singletonList(address));
    }
    
    public void setEndpointAddresses(List<String> addresses) {
        this.endpointAddresses = addresses;
    }
    
    public void setServerAddress(String address) {
        this.serverAddress = address;
    }
    
    @Override
    public void init() {
        // the storage might've been used to save previous records or it might
        // point to a file log entries are added to
        if (storage != null) {
            //-1 can be returned by read-only storage if it does not know in advance
            // a number of records it may contain 
            recordsSize = storage.getSize();
        }
        
        if (storage == null || storage instanceof ReadWriteLogStorage) {
            super.init();
        } else {
            // super.init() results in the additional Handler being created and publish()
            // method being called as a result. If the storage is read-only it is assumed it points to
            // the external source of log records thus no need to get the publish events here
            
            // instead we create a SearchCondition the external storage will check against when
            // loading the matching records on request
            
            List<SearchCondition<LogRecord>> list = new LinkedList<SearchCondition<LogRecord>>();
            for (LoggerLevel l : super.getLoggers()) {
                LogRecord r = new LogRecord();
                r.setLoggerName(l.getLogger());
                r.setLevel(LogLevel.valueOf(l.getLevel()));
                list.add(new SearchConditionImpl(r));
            }
            readableStorageCondition = list.size() == 0 ? null : new OrSearchCondition<LogRecord>(list);
        }
        activeStreams = Collections.synchronizedMap(new HashMap<String, Object>());
        initBusProperty();
    }
    

    @Override
    protected Handler createHandler() {
        return new AtomPullHandler(this);
    }
    
    @SuppressWarnings("unchecked")
    protected void initBusProperty() {
        if (endpointAddresses != null && serverAddress != null && getBus() != null) {
            Bus bus = getBus();
            synchronized (bus) {
                Map<String, String> addresses = 
                    (Map<String, String>)bus.getProperty("org.apache.cxf.extensions.logging.atom.pull");
                if (addresses == null) {
                    addresses = new HashMap<String, String>();
                }
                for (String address : endpointAddresses) {
                    addresses.put(address, serverAddress + "/logs");
                }
                bus.setProperty("org.apache.cxf.extensions.logging.atom.pull", addresses);
            }
        }
    }
    
    @GET
    @Produces("application/atom+xml")
    public Feed getXmlFeed() {
        return getXmlFeedWithPage(1);
    }
    
    @GET
    @Produces("application/atom+xml")
    @Path("{id}")
    public Feed getXmlFeedWithPage(@PathParam("id") int page) {
        
        // lets check if the Atom reader is asking for a set of records which has already been 
        // converted to Feed
        
        synchronized (feeds) {
            Feed f = feeds.get(page);
            if (f != null) {
                return f;
            }
        }
        
        Feed feed = null;
        SearchCondition<LogRecord> condition = getCurrentCondition();
        synchronized (records) {
            List<LogRecord> list = new LinkedList<LogRecord>();
            int lastPage = fillSubList(list, page, condition);
            Collections.sort(list, new LogRecordComparator());
            feed = (Feed)new CustomFeedConverter(page).convert(list).get(0);
            setFeedPageProperties(feed, page, lastPage);
        }
        // if at the moment we've converted n < pageSize number of records only and
        // persist a Feed keyed by a page then another reader requesting the same page 
        // may miss latest records which might've been added since the original request
        if (condition == null && feed.getEntries().size() == pageSize) {
            synchronized (feeds) {
                feeds.put(page, feed);
            }
        }
        return feed;
    }
    
    @GET
    @Produces({"text/html", "application/xhtml+xml" })
    @Path("alternate/{id}")
    public String getAlternateFeed(@PathParam("id") int page) {
        List<LogRecord> list = new LinkedList<LogRecord>();
        fillSubList(list, page, getCurrentCondition());
        Collections.sort(list, new LogRecordComparator());
        return convertEntriesToHtml(list);
        
    }

    @GET
    @Produces({"text/html", "application/xhtml+xml" })
    @Path("subscribe/alternate")
    public StreamingOutput getAlternateContinuousFeed(@HeaderParam("requestId") String reqid) {
        final String key = reqid == null ? "*" : reqid;
        return new StreamingOutput() {
            public void write(final OutputStream out) throws IOException, WebApplicationException {
                // return the last entry
                out.write(convertEntryToHtmlFragment(((LinkedList<LogRecord>)records).getLast()).getBytes());
                
                activeStreams.put(key, out);
            }
        };
    }
    

    @GET
    @Produces("application/atom+xml;type=entry")
    @Path("subscribe")
    public StreamingResponse<Entry> getXmlContinuousFeed(@HeaderParam("requestId") String reqid) {
        final String key = reqid == null ? "*" : reqid;
        return new StreamingResponse<Entry>() {
            public void writeTo(final StreamingResponse.Writer<Entry> out) throws IOException {
                // return the last entry
                Entry entry = (Entry) new StandardConverter(StandardConverter.Output.ENTRY, 
                                                            StandardConverter.Multiplicity.ONE, 
                                                            StandardConverter.Format.CONTENT)
                    .convert(Collections.singletonList(((LinkedList<LogRecord>)records).getLast())).get(0);
                out.write(entry);

                activeStreams.put(key, out);
            }
        };
    }

    @GET
    @Produces("text/plain")
    @Path("unsubscribe/{key}")
    public Boolean unsubscribeContinuousFeed(@PathParam("key") String key) {
        return activeStreams.remove(key) != null;
    }

    @GET
    @Path("entry/{id}")
    @Produces("application/atom+xml;type=entry")
    public Entry getEntry(@PathParam("id") int index) {
        List<LogRecord> list = getLogRecords(index, getCurrentCondition());
        return (Entry)new CustomEntryConverter(index).convert(list).get(0);
    }
    
    @GET
    @Path("entry/alternate/{id}")
    @Produces({"text/html", "application/xhtml+xml" })
    public String getAlternateEntry(@PathParam("id") int index) {
        List<LogRecord> logRecords = getLogRecords(index, getCurrentCondition());
        return convertEntryToHtml(logRecords.get(0));
    }
    
    @GET
    @Path("records")
    @Produces("text/plain")
    public int getNumberOfAvailableRecords() {
        return recordsSize;
    }
    
    private List<LogRecord> getLogRecords(int index, SearchCondition<LogRecord> theSearch) {
        List<LogRecord> list = new LinkedList<LogRecord>();
        if (storage != null) {
            int storageSize = storage.getSize();
            if (recordsSize == -1 || index < storageSize) {
                storage.load(list, theSearch, index, 1);
            } else if (index < recordsSize) {
                list.add(records.get(index - storageSize));   
            }
        } else {
            list.add(records.get(index));
        }
        if (list.size() != 1) { 
            throw ExceptionUtils.toNotFoundException(null, null);
        }
        return list;
    }
    
    
    protected int fillSubList(List<LogRecord> list, int page, SearchCondition<LogRecord> theSearch) {
        int oldListSize = list.size();
        
        if (storage != null) {
            page = storage.load(list, theSearch, page, pageSize);
        }
        
        if (recordsSize == -1 || recordsSize == 0 || list.size() == pageSize) {
            return page;
        }
        
        int fromIndex = page == 1 ? list.size() 
                                  : (page - 1) * pageSize + list.size();
        if (fromIndex > recordsSize) {
            // this should not happen really
            page = 1;
            fromIndex = 0;
        }
        int toIndex = page * pageSize;
        if (toIndex > recordsSize) {
            toIndex = recordsSize;
        }
        int offset = storage != null ? pageSize - (list.size() - oldListSize) : 0;
        fromIndex -= offset;
        toIndex -= offset;
        list.addAll(filterRecords(records.subList(fromIndex, toIndex), theSearch));
        
        
        if (theSearch != null && list.size() < pageSize && page * pageSize < recordsSize) {
            return fillSubList(list, page + 1, theSearch);    
        } else {
            return page;
        }
    }
    
    private List<LogRecord> filterRecords(List<LogRecord> list, SearchCondition<LogRecord> theSearch) {
        return theSearch == null ? list : theSearch.findAll(list);
    }
    
    private SearchCondition<LogRecord> getCurrentCondition() {
        SearchCondition<LogRecord> current = context.getContext(SearchContext.class)
            .getCondition(LogRecord.class);
        if (current == null) {
            return readableStorageCondition;
        } else {
            return current;
        }
    }
    
    private String getSearchExpression() {
        return context.getContext(SearchContext.class).getSearchExpression();
    }
    
    protected void setFeedPageProperties(Feed feed, int page, int lastPage) {
        String self = context.getUriInfo().getAbsolutePath().toString();
        feed.addLink(self, "self");
        
        int feedSize = feed.getEntries().size();
        String searchExpression = getSearchExpression();
        
        String uri = context.getUriInfo().getBaseUriBuilder().path("logs").build().toString();
        feed.addLink(uri + "/alternate/" + page, "alternate");
        if (recordsSize != -1) {
            if (page > 2) {
                feed.addLink(createLinkUri(uri, searchExpression), "first");
            }
            
            if (searchExpression == null && lastPage * pageSize < recordsSize
                || searchExpression != null && feedSize == pageSize) {
                feed.addLink(createLinkUri(uri + "/" + (lastPage + 1), searchExpression), "next");
            }
            
            if (searchExpression == null && page * (pageSize + 1) < recordsSize) {
                feed.addLink(uri + "/" + (recordsSize / pageSize + 1), "last");
            }
        } else if (feedSize == pageSize) {
            feed.addLink(createLinkUri(uri + "/" + (lastPage + 1), searchExpression), "next");
        }
        if (page > 1) {
            uri = page > 2 ? uri + "/" + (page - 1) : uri;
            feed.addLink(createLinkUri(uri, searchExpression), "previous");
        }
    }
    
    private String createLinkUri(String uri, String search) {
        return search == null ? uri : uri + "?_s=" + search; 
    }
    
    public void publish(LogRecord record) {
        if (alreadyClosed) {
            System.err.println("AtomPullServer has been closed, the following log record can not be saved : "
                               + record.toString());
            return;
        }
        synchronized (records) {
            if (records.size() == maxInMemorySize) {
                if (storage instanceof ReadWriteLogStorage) {
                    ((ReadWriteLogStorage)storage).save(records);
                    records.clear();
                } else {
                    LogRecord oldRecord = records.remove(0);
                    System.err.println("The oldest log record is removed : " + oldRecord.toString());
                }
            } 
            records.add(record);
            ++recordsSize;
        }
        submit(record);
    }
    
    private void submit(final LogRecord record) {
        //TODO use an executor to broadcast the record asynchronously
        //TODO take the search condition in consideration to filter out those non-matching entries
        if (activeStreams.size() > 0) {
            byte[] rbytes = null;
            Entry rentry = null;
            for (Iterator<Object> it = activeStreams.values().iterator(); it.hasNext();) {
                Object out = it.next();
                try {
                    if (out instanceof OutputStream) {
                        if (rbytes == null) {
                            rbytes = convertEntryToHtmlFragment(record).getBytes();
                        }
                        ((OutputStream)out).write(rbytes);
                    } else if (out instanceof StreamingResponse.Writer) {
                        ClassLoader cl = Thread.currentThread().getContextClassLoader();
                        try {
                            // switch TCCL as abdera uses TCCL to load various abdera classes during serialization 
                            Thread.currentThread().setContextClassLoader(StandardConverter.class.getClassLoader());
                            if (rentry == null) {
                                rentry = (Entry) new StandardConverter(StandardConverter.Output.ENTRY, 
                                                                       StandardConverter.Multiplicity.ONE, 
                                                                       StandardConverter.Format.CONTENT)
                                    .convert(Collections.singletonList(record)).get(0);
                            }
                            ((StreamingResponse.Writer<Entry>)out).write(rentry);
                        } finally {
                            Thread.currentThread().setContextClassLoader(cl);
                        }
                    }
                } catch (Throwable t) {
                    // REVISIT
                    // the reason for not logging anything here is to not further clog the logger 
                    // with this log broadcasting failure.
                    System.err.print("ERROR | AtomPullServer | " + t + "; Unregistering " + out);
                    it.remove();
                }
            }
        }
    }

    public void setPageSize(int size) {
        pageSize = size;
    }

    public void setMaxInMemorySize(int maxInMemorySize) {
        this.maxInMemorySize = maxInMemorySize;
    }

    public void setStorage(ReadableLogStorage storage) {
        this.storage = storage;
    }
    
    public void close() {
        if (alreadyClosed) {
            return;
        }
        alreadyClosed = true;
        if (storage instanceof ReadWriteLogStorage) {
            ((ReadWriteLogStorage)storage).save(records);
        }
    }
    
    public synchronized void reset() {
        records.clear();
        recordsSize = 0;
        feeds.clear();
        activeStreams.clear();
    }
    
    // TODO : this all can be done later on in a simple xslt template
    private String convertEntriesToHtml(List<LogRecord> rs) {
        StringBuilder sb = new StringBuilder();
        startHtmlHeadAndBody(sb, "CXF Service Log Entries");
        addRecordToTable(sb, rs, true);
        sb.append("</body></html>");
        return sb.toString();
    }
    // TODO : this all can be done later on in a simple xslt template
    private String convertEntryToHtml(LogRecord r) {
        StringBuilder sb = new StringBuilder();
        startHtmlHeadAndBody(sb, r.getLevel().toString());
        addRecordToTable(sb, Collections.singletonList(r), false);
        sb.append("</body></html>");
        return sb.toString();
    }
    private String convertEntryToHtmlFragment(LogRecord r) {
        StringBuilder sb = new StringBuilder();
        DateFormat df = new SimpleDateFormat("dd/MM/yy HH:mm:ss");
        addRecordToTable(sb, df, r, false);
        return sb.toString();
    }
    
    private void addRecordToTable(StringBuilder sb, List<LogRecord> list, boolean forFeed) {
        DateFormat df = new SimpleDateFormat("dd/MM/yy HH:mm:ss");
        sb.append("<table border=\"1\">");
        sb.append("<tr><th>Date</th><th>Level</th><th>Logger</th><th>Message</th></tr>");
        for (LogRecord lr : list) {
            addRecordToTable(sb, df, lr, forFeed);
        }
        sb.append("</table><br/><br/>");
    }
    
    private void addRecordToTable(StringBuilder sb, DateFormat df, LogRecord lr, boolean forFeed) {
        sb.append("<tr>");
        sb.append("<td>" + df.format(lr.getDate()) + "</td>");
        sb.append("<td>" + lr.getLevel().toString() + "</td>");
        sb.append("<td>" + lr.getLoggerName() + "</td>");
        String message = null;
        if (lr.getMessage().length() > 0) {
            message =  lr.getThrowable().length() > 0 ? lr.getMessage() + " : " + lr.getThrowable()
                       : lr.getMessage();
        } else if (lr.getThrowable().length() > 0) {
            message = lr.getThrowable();
        } else {
            message = "&nbsp";
        }
        if (forFeed && lr.getThrowable().length() > 0) {
            message = message.substring(0, message.length() / 2);
        }
        sb.append("<td>" + message + "</td>");
        sb.append("</tr>");
    }

    private void startHtmlHeadAndBody(StringBuilder sb, String title) {
        sb.append("<html xmlns=\"http://www.w3.org/1999/xhtml\">");
        sb.append("<head>");
        sb.append("<meta http-equiv=\"content-type\" content=\"text/html;charset=UTF-8\"/>");
        sb.append("<title>" + "Log record with level " + title + "</title>");
        sb.append("</head>");
        sb.append("<body>");
    }
    
    private static class SearchConditionImpl implements SearchCondition<LogRecord> {
        private LogRecord template;
        
        SearchConditionImpl(LogRecord l) {
            this.template = l;
        }

        public boolean isMet(LogRecord pojo) {
            
            return (template.getLevel() == LogLevel.ALL
                   || pojo.getLevel().compareTo(template.getLevel()) <= 0)
                   && template.getLoggerName().equals(pojo.getLoggerName());
        }

        public LogRecord getCondition() {
            return new LogRecord(template);
        }

        public ConditionType getConditionType() {
            return ConditionType.CUSTOM;
        }

        public List<SearchCondition<LogRecord>> getSearchConditions() {
            return null;
        }

        public List<LogRecord> findAll(Collection<LogRecord> pojos) {
            List<LogRecord> list = new LinkedList<LogRecord>();
            for (LogRecord r : pojos) {
                if (isMet(r)) {
                    list.add(r);
                }
            }
            return list;
        }

        public PrimitiveStatement getStatement() {
            return null;
        }

        public void accept(SearchConditionVisitor<LogRecord, ?> visitor) {
        }
    }
    
    private static class LogRecordComparator implements Comparator<LogRecord> {

        public int compare(LogRecord r1, LogRecord r2) {
            return r1.getDate().compareTo(r2.getDate()) * -1;
        }
        
    }
    
    private class CustomFeedConverter extends StandardConverter {
        private int page;
        CustomFeedConverter(int page) {
            super(Output.FEED, Multiplicity.MANY, Format.CONTENT);
            this.page = page;
        }
        
        @Override
        protected void setDefaultEntryProperties(Entry entry, List<LogRecord> rs, int entryIndex) {
            super.setDefaultEntryProperties(entry, rs, entryIndex);
            UriBuilder builder = context.getUriInfo().getAbsolutePathBuilder().path("entry");
            Integer realIndex = page == 1 ? entryIndex : page * pageSize + entryIndex;

            entry.addLink(builder.clone().path(realIndex.toString()).build().toString(), "self");
            entry.addLink(builder.path("alternate").path(realIndex.toString()).build().toString(), 
                          "alternate");
        }
        
    }
    
    private class CustomEntryConverter extends StandardConverter {
        private String selfFragment;
        private String altFragment;
        CustomEntryConverter(int index) {
            super(Output.ENTRY, Multiplicity.ONE, Format.CONTENT);
            this.selfFragment = "logs/entry/" + index;
            this.altFragment = "logs/alternate/entry/" + index;
        }
        
        @Override
        protected void setDefaultEntryProperties(Entry entry, List<LogRecord> rs, int entryIndex) {
            super.setDefaultEntryProperties(entry, rs, entryIndex);
            entry.addLink(context.getUriInfo().getBaseUriBuilder().path(selfFragment).build().toString(),
                "self");
            entry.addLink(context.getUriInfo().getBaseUriBuilder().path(altFragment).build().toString(),
                "alternate");
        }
    }
}
