package leigh.astro.scheduler;

import com.mchange.v2.c3p0.ComboPooledDataSource;
import leigh.astro.app.SubLoaderApp;
import leigh.astro.astrometrics.Astrometrics;
import leigh.astro.astrometrics.Location;
import leigh.astro.catalog.CatalogObject;
import leigh.astro.catalog.StellarCatalog;
import leigh.astro.db.AstroSql;
import leigh.astro.db.AstroStore;
import leigh.astro.fits.AstroSub;
import leigh.astro.fits.Exposure;
import leigh.astro.fits.FitsScanner;
import leigh.astro.scheduler.ekos.EkosController;
import leigh.astro.scheduler.ekos.EkosPlan;
import leigh.astro.unit.Rect;
import leigh.astro.unit.SphericalCoordinate;
import leigh.mecha.cred.Credential;
import leigh.mecha.log.MechaLogger;
import leigh.mecha.log.MechaLoggerFactory;
import leigh.mecha.util.VelocityWatch;
import org.shredzone.commons.suncalc.SunTimes;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * Implementation of an astronomy scheduler (tasker).
 * <p>
 * Night of 12/18/20 is the first ed80+moonlite+asi1200mm+efw night.
 *
 * @author Alex Leigh
 */
public class Planner implements Runnable {
    private static final MechaLogger logger = MechaLoggerFactory.getLogger(Planner.class);
    private final Location slateHouse = new Location("Slate House", 33.65125, -111.93546);
    public static final int UNLIMITED = -1;
    private final FitsScanner scanner = new FitsScanner();
    private final Path adb = Paths.get("/Volumes/cal326/adb");
    private final StellarCatalog ngc = new StellarCatalog();
    private final boolean armed = true;

    public Planner() throws IOException {
        scanner.scan(adb);
    }

    /**
     * Return a {@link Location} object representing Slate House in Phoenix, AZ.
     */
    public Location getSlateHouse() {
        return slateHouse;
    }

    /**
     * Return the {@link StellarCatalog} associated with this planner.
     */
    public StellarCatalog getNgc() {
        return ngc;
    }

    /**
     * Find subs that match the given target.
     *
     * @param coords
     */
    public ArrayList<AstroSub> find(SphericalCoordinate coords) {
        ArrayList<AstroSub> result = new ArrayList<>();

        for (AstroSub sub : scanner.getSubs()) {
            if (coords.isNear(sub.getObj())) result.add(sub);
        }

        return result;
    }


    /**
     * Determine the earliest and latest tasking dates for the given set of tasks, and print it to the screen.
     *
     * @param tasks The tasks to process.
     */
    public void taskingInfo(List<Tasking> tasks) {
        Instant earliest = null;
        Instant latest = null;

        for (Tasking t : tasks) {
            Instant start = t.getMinTime();
            Instant end = t.getMinTime().plus((long) t.getLength(), ChronoUnit.SECONDS);

            if (earliest == null) {
                earliest = start;
            } else {
                if (start.isBefore(earliest)) earliest = start;
            }

            if (latest == null) {
                latest = end;
            } else {
                if (end.isAfter(latest)) latest = end;
            }
        }

        logger.info("Taskings. [from: {}] [to: {}]", earliest, latest);
    }

    /**
     * Create an observation plan for the given taskings, beginning at the given start time and running until
     * the following sunrise, for the given observation location. The planner will treat taskings in priority
     * order where a lower index in the list means a higher priority.
     * <p>
     * For every possible of usable observatory time, the scheduler will attempt to find an exposure. For example,
     * if the first tasking can run, it will. If it cannot due to observation limitations then the second tasking
     * will be evaluated, etc. This is re-evaluated on a per-exposure basis, so if 30 exposures are desired for B,
     * but A becomes available 15 exposures in, the 16th exposure will be of A.
     *
     * <code>limit</code> specifies how many taskings to produce a plan for. If a limit is set, then the plan will
     * only include that many tasks, and therefore will not include the entire possible observation period. This is
     * required to produce single-shot jobs for EKOS to avoid imperfections in their scheduler implementation. To
     * create an unlimited number of taskings, set this value to Scheduler.UNLIMITED.
     */
    public List<Tasking> plan(List<Tasking> todo, Instant startTime, Location location, int limit, Instant endTime) {
        Rect balcony = new Rect(new SphericalCoordinate(225, 20), new SphericalCoordinate(330, 38));
        Instant simTime = startTime;

        logger.info("Generating observation plan. [startTime: {}] [endTime: {}] [limit: {}]",
                startTime, endTime, limit);

        // These are the commands we'll schedule
        ArrayList<Tasking> commands = new ArrayList<>();

        VelocityWatch vw = new VelocityWatch(logger);

        Tasking lastCmd = null;

        while (simTime.isBefore(endTime)) {
            vw.event("tick");

            SunTimes yesterdayTimes = SunTimes.compute()
                    .on(Date.from(simTime.minus(1, ChronoUnit.DAYS)))       // set a date
                    .at(location.getLat(), location.getLng())   // set a location
                    .execute();     // get the results

            SunTimes todayTimes = SunTimes.compute()
                    .on(Date.from(simTime))       // set a date
                    .at(location.getLat(), location.getLng())   // set a location
                    .execute();     // get the results

            logger.debug("Sim time: {} set {}", simTime, yesterdayTimes.getSet().toInstant());

            // Is it daytime?
            if (simTime.isAfter(yesterdayTimes.getSet().toInstant()) &&
                    simTime.isBefore(todayTimes.getRise().toInstant())) {
                vw.event("nightTick");

                // Calculate MST for the schedule time
                double mst = Astrometrics.meanSiderealTime(Date.from(simTime), (float) location.getLng());
                Exposure next = null;

                // Search for a tasking. We will take the first available for this timeslot, in priority order.
                for (Tasking task : todo) {
                    // Determine if the object is visible
                    CatalogObject object = task.getCatalogObject();
                    SphericalCoordinate taskObj = object.getJ2000Coordinate();

                    // Calculate the object position for this simTime
                    SphericalCoordinate apparent =
                            Astrometrics.calc(mst - taskObj.getX(), taskObj.getY(), location.getLat());

                    logger.info("Candidate object hcs: [apparent: {}] [obj: {}]", apparent, object);

                    if (balcony.isWithin(apparent)) {
                        vw.event("visibleObj");
                        logger.info("Object is visible at this time: [time: {}] [obj: {}]", simTime, object);
                        Exposure e = task.pop();
                        if (e != null) {
                            next = e;

                            Tasking newCmd = null;

                            if (lastCmd != null && lastCmd.getCatalogObject().equals(object)) newCmd = lastCmd;

                            if (newCmd == null) {
                                // Before we do this, check to see if we are at our limit already.
                                if (limit != UNLIMITED && commands.size() >= limit) {
                                    // We are at our limit, so we need to exit now.
                                    vw.status();
                                    return commands;
                                }

                                newCmd = new Tasking(object, simTime);
                                commands.add(newCmd);
                                lastCmd = newCmd;
                                vw.event("tasking");
                            }

                            newCmd.increment(e);
                            logger.debug("Enqueue Exposure: {} {}", simTime, e);
                            break;
                        }

                        // We might be done - are all taskings empty?
                        boolean empty = true;
                        for (Tasking t : todo) {
                            for (int cnt : t.getExposures().values()) {
                                if (cnt != 0) {
                                    empty = false;
                                    break;
                                }
                            }
                            if (empty == false) {
                                break;
                            }
                        }

                        if (empty) {
                            logger.info("Depleted all tasks.");
                            vw.status();
                            return commands;
                        }

                    } else {
                        vw.event("rejectedObj");
                        logger.debug("Object rejected, outside of viewport.");
                    }
                }

                if (next == null) {
                    logger.debug("Ran out of available tasking. Skipping 30s.");
                    simTime = simTime.plus(30, ChronoUnit.SECONDS);
                } else {
                    logger.debug("Skipping time for exposure: {}", next);
                    simTime = simTime.plus((long) next.getLength(), ChronoUnit.SECONDS);
                }
            } else {
                // It's daytime.
                vw.event("dayTick");
                simTime = simTime.plus(5, ChronoUnit.MINUTES);
            }
        }

        logger.info("End time reached. Stopping planning. [simTime: {}]", simTime);

        vw.status();
        // Note this is not the only exit point
        return commands;
    }

    @Override
    public void run() {
        // FIXME If EKOS is offline, the planner won't attempt to recover any FITS files that might be on the remote
        // TODO: Move this to the TelescopeController class, since that's really what it is
        try {
            ComboPooledDataSource sql = AstroSql.getDataSource();
            Runtime run = Runtime.getRuntime();
            EkosController controller = new EkosController("telescope",
                    new Credential("aleigh", "foo"));
            SubLoaderApp loader = new SubLoaderApp();

            while (true) {
                if (controller.isRunning() && armed) {
                    // TODO: Check if it is daylight - if it's daylight and EKOS is running a job, kill the job.
                    logger.info("EKOS is already running a job. Sleeping.");
                } else {
                    if (armed) {
                        logger.info("Retrieving image data from remote controller...");
                        Process proc = run.exec("/Volumes/cal326/astro/sync.sh");
                        proc.waitFor();
                        scanner.ingest(adb, Paths.get("/Volumes/cal326/astro"));
                        proc.waitFor();
                        logger.info("Data transfer complete.");
                        loader.process(Path.of("/Volumes/cal326/adb"));
                    }

                    scanner.scan(adb);

                    List<Tasking> todo;
                    try (Connection con = sql.getConnection()) {
                        AstroStore store = new AstroStore(con);
                        todo = store.getTaskings();
                    }

                    logger.info("Generating observation plan...");

                    // Plan no more than 8 hours ahead, working on the premise no job will wind up that long
                    // and prevents us from planning into the next night.
                    List<Tasking> commands = plan(todo, Instant.now(), getSlateHouse(), 1,
                            Instant.now().plus(8, ChronoUnit.HOURS));

                    EkosPlan plan = controller.generate(commands, getSlateHouse());

                    logger.info("Generated observation plan. [sequences: {}]", plan.getSequences().size());

                    if (plan.getStartTime() != null && plan.getStartTime().isBefore(Instant.now())) {
                        if (armed) {
                            logger.info("Publishing plan to remote controller...");
                            controller.scheduleStop();
                            controller.publishScheduleFile(plan);
                            controller.loadSchedlule(plan.getId());
                            controller.scheduleStart();
                        } else {
                            logger.warn("Scheduler is disarmed. Would have transmitted schedule.");
                        }
                        logger.info("Observation plan published. Remote controller started.");
                    } else {
                        logger.info("Plan begins in the future or is empty. Sleeping. [start: {}]",
                                plan.getStartTime());
                    }
                }
                Thread.sleep(60 * 1000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}