/*
 * 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.brooklyn.entity.network.bind;

import static com.google.common.base.Preconditions.checkNotNull;

import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.util.Collection;
import java.util.Map;

import org.apache.brooklyn.api.entity.Entity;
import org.apache.brooklyn.api.entity.EntitySpec;
import org.apache.brooklyn.api.policy.PolicySpec;
import org.apache.brooklyn.api.sensor.AttributeSensor;
import org.apache.brooklyn.api.sensor.Sensor;
import org.apache.brooklyn.core.entity.Attributes;
import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
import org.apache.brooklyn.core.location.Machines;
import org.apache.brooklyn.entity.group.AbstractMembershipTrackingPolicy;
import org.apache.brooklyn.entity.group.DynamicGroup;
import org.apache.brooklyn.entity.software.base.SoftwareProcessImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.CharMatcher;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Predicate;
import com.google.common.base.Splitter;
import com.google.common.collect.BiMap;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Multimaps;

import org.apache.brooklyn.location.ssh.SshMachineLocation;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.net.Cidr;
import org.apache.brooklyn.util.ssh.BashCommands;
import org.apache.brooklyn.util.text.Strings;

/**
 * This sets up a BIND DNS server.
 * <p>
 * <b>NOTE</b> This entity has only been certified on <i>CentOS</i>, <i>RHEL</i>,
 * <i>Ubuntu</i> and <i>Debian</i> operating systems.
 */
public class BindDnsServerImpl extends SoftwareProcessImpl implements BindDnsServer {

    private static final Logger LOG = LoggerFactory.getLogger(BindDnsServerImpl.class);
    private final Object serialMutex = new Object();

    // As per RFC 952 and RFC 1123.
    private static final CharMatcher DOMAIN_NAME_FIRST_CHAR_MATCHER = CharMatcher.inRange('a', 'z')
                .or(CharMatcher.inRange('A', 'Z'))
                .or(CharMatcher.inRange('0', '9'));
    private static final CharMatcher DOMAIN_NAME_MATCHER = DOMAIN_NAME_FIRST_CHAR_MATCHER
            .or(CharMatcher.is('-'));


    private class HostnameTransformer implements Function<Entity, String> {
        @Override
        public String apply(Entity input) {
            String hostname = input.getAttribute(getConfig(HOSTNAME_SENSOR));
            hostname = DOMAIN_NAME_FIRST_CHAR_MATCHER.negate().trimFrom(hostname);
            hostname = DOMAIN_NAME_MATCHER.negate().trimAndCollapseFrom(hostname, '-');
            if (hostname.length() > 63) {
                hostname = hostname.substring(0, 63);
            }
            return hostname;
        }
    }

    public BindDnsServerImpl() {
        super();
    }

    @Override
    public void init() {
        super.init();
        checkNotNull(getConfig(HOSTNAME_SENSOR), "%s requires value for %s", getClass().getName(), HOSTNAME_SENSOR);
        checkNotNull(getConfig(ADDRESS_SENSOR), "%s requires value for %s", getClass().getName(), ADDRESS_SENSOR);
        DynamicGroup entities = addChild(EntitySpec.create(DynamicGroup.class)
                .displayName("BIND-managed entities")
                .configure(DynamicGroup.ENTITY_FILTER, getEntityFilter()));
        sensors().set(ENTITIES, entities);
        sensors().set(A_RECORDS, ImmutableMap.<String, String>of());
        sensors().set(CNAME_RECORDS, ImmutableMultimap.<String, String>of());
        sensors().set(PTR_RECORDS, ImmutableMap.<String, String>of());
        sensors().set(ADDRESS_MAPPINGS, ImmutableMultimap.<String, String>of());
        synchronized (serialMutex) {
            sensors().set(SERIAL, System.currentTimeMillis() / 1000);
        }
    }

    @Override
    public void postRebind() {
        update();
    }

    @Override
    public Class<?> getDriverInterface() {
        return BindDnsServerDriver.class;
    }

    @Override
    public Multimap<String, String> getAddressMappings() {
        return getAttribute(ADDRESS_MAPPINGS);
    }

    @Override
    public Map<String, String> getReverseMappings() {
        return getAttribute(PTR_RECORDS);
    }

    @Override
    public BindDnsServerDriver getDriver() {
        return (BindDnsServerDriver) super.getDriver();
    }

    @Override
    public void connectSensors() {
        connectServiceUpIsRunning();
    }

    @Override
    public void disconnectSensors() {
        super.disconnectSensors();
        disconnectServiceUpIsRunning();
    }

    @Override
    protected void preStart() {
        String reverse = getConfig(REVERSE_LOOKUP_NETWORK);
        if (Strings.isBlank(reverse)) reverse = getAttribute(ADDRESS);
        sensors().set(REVERSE_LOOKUP_CIDR, new Cidr(reverse + "/24"));
        String reverseLookupDomain = Joiner.on('.').join(Iterables.skip(Lists.reverse(Lists.newArrayList(
                Splitter.on('.').split(reverse))), 1)) + ".in-addr.arpa";
        sensors().set(REVERSE_LOOKUP_DOMAIN, reverseLookupDomain);

        policies().add(PolicySpec.create(MemberTrackingPolicy.class)
                .displayName("Address tracker")
                .configure(AbstractMembershipTrackingPolicy.SENSORS_TO_TRACK, ImmutableSet.<Sensor<?>>of(getConfig(HOSTNAME_SENSOR), getConfig(ADDRESS_SENSOR)))
                .configure(AbstractMembershipTrackingPolicy.GROUP, getEntities()));
    }

    @Override
    public void postStart() {
        update();
    }

    public static class MemberTrackingPolicy extends AbstractMembershipTrackingPolicy {
        @Override
        protected void onEntityChange(Entity member) {
            if (LOG.isTraceEnabled()) {
                LOG.trace("State of {} on change: {}", member, member.getAttribute(Attributes.SERVICE_STATE_ACTUAL).name());
            }
            ((BindDnsServerImpl) entity).update();
        }
        @Override
        protected void onEntityAdded(Entity member) {
            if (LOG.isTraceEnabled()) {
                LOG.trace("State of {} on added: {}", member, member.getAttribute(Attributes.SERVICE_STATE_ACTUAL).name());
            }
            ((BindDnsServerImpl) entity).configureResolver(member);
        }
    }

    private class HasHostnameAndValidLifecycle implements Predicate<Entity> {
        @Override
        public boolean apply(Entity input) {
            switch (input.getAttribute(Attributes.SERVICE_STATE_ACTUAL)) {
            case STOPPED:
            case STOPPING:
            case DESTROYED:
                return false;
            default:
                return input.getAttribute(getConfig(HOSTNAME_SENSOR)) != null;
            }
        }
    }

    private transient boolean hasLoggedDeprecationAboutAddressSensor = false;
    
    public void update() {
        Lifecycle serverState = getAttribute(Attributes.SERVICE_STATE_ACTUAL);
        if (Lifecycle.STOPPED.equals(serverState) || Lifecycle.STOPPING.equals(serverState)
                || Lifecycle.DESTROYED.equals(serverState) || !getAttribute(Attributes.SERVICE_UP)) {
            LOG.debug("Skipped update of {} when service state is {} and running is {}",
                    new Object[]{this, getAttribute(Attributes.SERVICE_STATE_ACTUAL), getAttribute(SERVICE_UP)});
            return;
        }
        synchronized (this) {
            Iterable<Entity> availableEntities = FluentIterable.from(getEntities().getMembers())
                    .filter(new HasHostnameAndValidLifecycle());
            LOG.debug("{} updating with entities: {}", this, Iterables.toString(availableEntities));
            ImmutableListMultimap<String, Entity> hostnameToEntity = Multimaps.index(availableEntities,
                    new HostnameTransformer());

            Map<String, String> octetToName = Maps.newHashMap();
            BiMap<String, String> ipToARecord = HashBiMap.create();
            Multimap<String, String> aRecordToCnames = MultimapBuilder.hashKeys().hashSetValues().build();
            Multimap<String, String> ipToAllNames = MultimapBuilder.hashKeys().hashSetValues().build();

            for (Map.Entry<String, Entity> entry : hostnameToEntity.entries()) {
                String domainName = entry.getKey();
                Entity entity = entry.getValue();
                
                String address = null;
                
                AttributeSensor<String> addressSensor = getConfig(ADDRESS_SENSOR);
                if (addressSensor!=null) {
                    address = entity.getAttribute(addressSensor);
                    
                } else {
                    if (!hasLoggedDeprecationAboutAddressSensor) {
                        LOG.warn("BIND entity "+this+" is using legacy machine inspection to determine IP address; set the "+ADDRESS_SENSOR.getName()+" config to ensure compatibility with future versions");
                        hasLoggedDeprecationAboutAddressSensor = true;
                    }
                    Maybe<SshMachineLocation> location = Machines.findUniqueMachineLocation(entity.getLocations(), SshMachineLocation.class);
                    if (!location.isPresent()) {
                        LOG.debug("Member {} of {} does not have an hostname so will not be configured", entity, this);
                    } else if (ipToARecord.inverse().containsKey(domainName)) {
                        // already has a hostname, ignore (could log if domain is different?)
                    } else {
                        address = location.get().getAddress().getHostAddress();
                    }
                }
                
                if (Strings.isBlank(address)) {
                    continue;
                }
                
                ipToAllNames.put(address, domainName);
                if (!ipToARecord.containsKey(address)) {
                    ipToARecord.put(address, domainName);
                    if (getReverseLookupNetwork().contains(new Cidr(address + "/32"))) {
                        String octet = Iterables.get(Splitter.on('.').split(address), 3);
                        if (!octetToName.containsKey(octet)) octetToName.put(octet, domainName);
                    }
                } else {
                    aRecordToCnames.put(ipToARecord.get(address), domainName);
                }
            }
            sensors().set(A_RECORDS, ImmutableMap.copyOf(ipToARecord.inverse()));
            sensors().set(PTR_RECORDS, ImmutableMap.copyOf(octetToName));
            sensors().set(CNAME_RECORDS, Multimaps.unmodifiableMultimap(aRecordToCnames));
            sensors().set(ADDRESS_MAPPINGS, Multimaps.unmodifiableMultimap(ipToAllNames));

            // Update Bind configuration files and restart the service
            getDriver().updateBindConfiguration();
       }
    }

    protected void configureResolver(Entity entity) {
        Maybe<SshMachineLocation> machine = Machines.findUniqueMachineLocation(entity.getLocations(), SshMachineLocation.class);
        if (machine.isPresent()) {
            if (getConfig(REPLACE_RESOLV_CONF)) {
                machine.get().copyTo(new StringReader(getConfig(RESOLV_CONF_TEMPLATE)), "/etc/resolv.conf");
            } else {
                appendTemplate(getConfig(INTERFACE_CONFIG_TEMPLATE), "/etc/sysconfig/network-scripts/ifcfg-eth0", machine.get());
                machine.get().execScript("reload network", ImmutableList.of(BashCommands.sudo("service network reload")));
            }
            LOG.info("configured resolver on {}", machine);
        } else {
            LOG.debug("{} can't configure resolver at {}: no SshMachineLocation", this, entity);
        }
    }

    protected void appendTemplate(String template, String destination, SshMachineLocation machine) {
        String content = ((BindDnsServerSshDriver) getDriver()).processTemplate(template);
        String temp = "/tmp/template-" + Strings.makeRandomId(6);
        machine.copyTo(new ByteArrayInputStream(content.getBytes()), temp);
        machine.execScript("updating file", ImmutableList.of(
                BashCommands.sudo(String.format("tee -a %s < %s", destination, temp)),
                String.format("rm -f %s", temp)));
    }


    @Override
    public Predicate<? super Entity> getEntityFilter() {
        return getConfig(ENTITY_FILTER);
    }

    // Mostly used in templates
    public String getManagementCidr() {
        return getConfig(MANAGEMENT_CIDR);
    }

    public Integer getDnsPort() {
        return getAttribute(DNS_PORT);
    }

    public String getDomainName() {
        return getConfig(DOMAIN_NAME);
    }

    /**
     * Increments the serial number sensor and returns it.
     * Increment is so that it is guaranteed to be valid for use in a 
     * modified domain.zone or reverse.zone file.
     * <p>
     * The side-effect is not entirely obvious by the method name but it
     * makes it easier to use from the freemarker templates which call it!
     */
    public long getSerial() {
        synchronized (serialMutex) {
            long next = getAttribute(SERIAL) + 1;
            sensors().set(SERIAL, next);
            return next;
        }
    }

    public Cidr getReverseLookupNetwork() {
        return getAttribute(REVERSE_LOOKUP_CIDR);
    }

    public String getReverseLookupDomain() {
        return getAttribute(REVERSE_LOOKUP_DOMAIN);
    }

    public DynamicGroup getEntities() {
        return getAttribute(ENTITIES);
    }

    public Map<String, String> getAddressRecords() {
        return getAttribute(A_RECORDS);
    }

    public Multimap<String, String> getCanonicalNameRecords() {
        return getAttribute(CNAME_RECORDS);
    }

    public Map<String, Collection<String>> getCnamesForTemplates() {
        return getAttribute(CNAME_RECORDS).asMap();
    }

    public Map<String, String> getPointerRecords() {
        return getAttribute(PTR_RECORDS);
    }

}
