package com.clevergang.dbtests;

import com.clevergang.dbtests.repository.api.DataRepository;
import com.clevergang.dbtests.repository.api.data.*;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static java.util.stream.Collectors.toList;

/**
 * Implementation of the scenarios. Note that the scenarios are always the same, what changes is the
 * DB API implementation. To make things little bit easier for us we do not autowire the
 * DB API implementation, but we pass it to the constructor of the Scenarios class instead - this
 * isn't typical pattern we use in production code.
 *
 * @author Bretislav Wajtr
 */
@SuppressWarnings("WeakerAccess")
public class Scenarios {
    private static final Logger logger = LoggerFactory.getLogger(Scenarios.class);

    private final DataRepository repository;

    public Scenarios(DataRepository repository) {
        this.repository = repository;
    }

    /**
     * 1. Fetch single entity based on primary key
     * <br/>
     * This is the case when pid comes from outside (typically from UI) and we need to fetch complete record from database.
     *
     * @param companyPid Primary key of the record coming from outside
     */
    public void fetchSingleEntityScenario(Integer companyPid) {
        Company company = repository.findCompany(companyPid);

        // check some post conditions
        assert company != null;
        assert company.getPid().equals(companyPid);
        assert company.getName().equals("CleverGang");
        logger.info("Fetched result: {}", company);
    }

    /**
     * 2. Fetch list of entities based on condition
     * <br/>
     * This is a case when we want to get records from database using some kind of filter. Filter values typically come from UI.
     * @param employeeMinSalary Example of external filter value
     */
    public void fetchListOfEntitiesScenario(Integer employeeMinSalary) {
        List<Employee> employees = repository.employeesWithSalaryGreaterThan(employeeMinSalary);

        // check some post conditions
        assert employees != null;
        assert employees.size() == 3;
        assert employees.get(0).getSalary().compareTo(new BigDecimal(employeeMinSalary)) > 0;
        assert employees.get(1).getSalary().compareTo(new BigDecimal(employeeMinSalary)) > 0;
        assert employees.get(2).getSalary().compareTo(new BigDecimal(employeeMinSalary)) > 0;
        logger.info("Fetched result: {}", employees);
    }

    /**
     * 3. Save new single entity and return primary key
     */
    public void saveNewEntityScenario() {
        Project project = new Project();
        project.setName("TestProject");
        project.setDate(LocalDate.now());

        // SCENARIO CODE STARTS HERE
        Integer newPid = repository.insertProject(project);

        // check some post conditions
        logger.info("Scenario three, pid of inserted entity: {}", newPid);
        assert newPid != null;
        assert newPid > 2;

        Project storedProject = repository.findProject(newPid);
        logger.info("Scenario three, stored project: {}", storedProject);

        assert storedProject != null;
        assert newPid.equals(storedProject.getPid());
        assert project.getName().equals(storedProject.getName());
        assert project.getDate().equals(storedProject.getDate());
   }

    /**
     * 4. Batch insert multiple entities of same type and return generated keys
     * <br/>
     * This scenario represents a situation, when business method, as a result of it's execution, wants to store
     * multiple records of same type into database effectively.
     * </br>
     * In our scenario method, we create 1000 products first and then we want to store them into the database
     * as fast as we can - through batch insert functionality.
     */
    public void batchInsertMultipleEntitiesScenario() {
        // create a list of thousand products
        List<Project> projects = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            Project project = new Project();
            project.setName(RandomStringUtils.randomAlphabetic(10));
            project.setDate(LocalDate.now());
            projects.add(project);
        }

        // SCENARIO CODE STARTS HERE
        long start = System.nanoTime();
        List<Integer> newPids = repository.insertProjects(projects);

        // check some post conditions
        Integer projectsCount = repository.getProjectsCount();
        long end = System.nanoTime();

        assert projectsCount == 1002;
        logger.info("Scenario 4. output {}", newPids);
        logger.info("Rough time needed for execution (without commit): {} ms", (end - start) / 1000000d);

    }

    /**
     * 5. Update single existing entity - update all fields of entity at once
     * <br/>
     * This scenario covers typical situation in information systems where a detail of a record is displayed in UI, user
     * has possibility to modify any field of the record and then he/she presses Save button -> complete record data are sent
     * back to server and the record should be updated in database.
     */
    public void updateCompleteEntityScenario() {
        // Imagine that this object comes from UI edit dialog, which is typical scenario
        Employee employeeToUpdate = performSomeEmployeeRecordModificationsInUI(1);

        // SCENARIO CODE STARTS HERE
        repository.updateEmployee(employeeToUpdate);

        // check some post conditions
        Employee updatedEmployee = repository.findEmployee(1);
        assert employeeToUpdate.getPid().equals(updatedEmployee.getPid());
        assert employeeToUpdate.getDepartmentPid().equals(updatedEmployee.getDepartmentPid());
        assert employeeToUpdate.getName().equals(updatedEmployee.getName());
        assert employeeToUpdate.getSurname().equals(updatedEmployee.getSurname());
        assert employeeToUpdate.getEmail().equals(updatedEmployee.getEmail());
        assert employeeToUpdate.getSalary().equals(updatedEmployee.getSalary());
    }

    private Employee performSomeEmployeeRecordModificationsInUI(Integer employeePid) {
        Employee employeeToUpdate = new Employee();
        employeeToUpdate.setPid(employeePid);
        employeeToUpdate.setDepartmentPid(6);
        employeeToUpdate.setName("Curt1");
        employeeToUpdate.setSurname("Odegaard1");
        employeeToUpdate.setEmail("[email protected]");  // <-- this is updated value
        employeeToUpdate.setSalary(new BigDecimal("15000.00")); // <-- this is updated value
        return employeeToUpdate;
    }

    /**
     * 6. Fetch many-to-one relation (Company for Department)
     */
    public void fetchManyToOneRelationScenario() {
        Department softwareDevelopmentDepartment = repository.findDepartment(3);

        // SCENARIO CODE STARTS HERE
        // Getting Company for Department (many-to-one relation) in JPA is quite easy. You typically have
        // @ManyToOne relation defined in the Department entity class, so once you have instance of Department,
        // you just call department.getCompany() and JPA does the magic for you (typically one or more lazy selects
        // are executed). We don't have any such magical call here, but non-JPA approach is quite straightforward
        // too: we have company_pid, so just ask DataRepository for the record:
        Company company = repository.findCompany(softwareDevelopmentDepartment.getCompanyPid());

        // check some post conditions
        assert company.getName().equals("CleverGang");
        assert company.getPid().equals(1);
        logger.info("Department {} is in the {} company", softwareDevelopmentDepartment.getName(), company.getName());
    }

    /**
     * 7. Fetch one-to-many relation (Departments for Company)
     */
    public void fetchOneToManyRelationScenario() {
        Company company = repository.findCompany(1);

        // SCENARIO CODE STARTS HERE
        // For one-to-many relations the situation is quite similar to many-to-one relations (scenario six). In JPA this
        // is "easy" - you define @OneToMany relation in the Company entity and then you just call getDepartments() method ->
        // a lazy select is issued and Departments are fetched from DB. However you can also use EAGER FetchType strategy which
        // causes the relation to load along with the primary entity - this behavior is (by our opinion) the source of all evil
        // in JPA... So, in non-JPA approach, we don't have any "eager" loads, just explicit calls for data:
        List<Department> departments = repository.findDepartmentsOfCompany(company);

        // check some post conditions
        assert departments.size() == 4;
        logger.info("There are {} departments in {} company", departments.size(), company.getName());
    }

    /**
     * 8. Update entities one-to-many relation (Departments in Company) - add two items, update two items and delete one item - all at once
     * <br/>
     * This scenario covers situation where we have no idea what operations were performed by the user. We only
     * have new list of Departments and we have to efficiently update DB so it exactly reflects new Departments list.
     * <br/>
     * At the same time, we DON'T want to take the tempting path and do it by "removing all the existing departments of the company from
     * database and then inserting new values". This is actually not what the user did nor it's what we should do -> what if the departments
     * which were only updated have some additional relations in the database? -> if we delete them, we will delete also those relations ->
     * always ask yourself if this is something you want (or you want risk).
     */
    public void updateCompleteOneToManyRelationScenario() {
        Company company = repository.findCompany(1); // Clevergang company

        // this call simulates what typically happens in UI - the user chooses new list of departments for company
        // (adds new ones, updates some other or removes some departments). The new list is transferred from
        // UI to business service a List<Department> with no information about what departments were deleted or which
        // ones were updated - the business service has to determine these changes - which is what rest of the code in this method does
        List<Department> newDepartments = createNewDepartmentsList(company);


        // SCENARIO CODE STARTS HERE - update departments in DB
        updateDepartments(company, newDepartments);

        // check some post conditions
        List<Department> departmentsForCompany = repository.findDepartmentsOfCompany(company);
        assert departmentsForCompany.size() == 5;
        assert departmentsForCompany.get(0).getName().equals("Back office");
        assert departmentsForCompany.get(1).getName().equals("IT Department Updated");
        assert departmentsForCompany.get(2).getName().equals("Software Development");
        assert departmentsForCompany.get(3).getName().equals("New department 1");
        assert departmentsForCompany.get(4).getName().equals("New department 2");
        logger.info("State of database at the end of scenario eight: {}", departmentsForCompany);
    }

    private void updateDepartments(Company company, List<Department> newDepartments) {
        // --------------------------------------------------------------------------------------------------------------------------------------
        // The pattern for one-to-many relation update begins here - but BEWARE, it'll work nicely only for few items in the one to many relation
        // --------------------------------------------------------------------------------------------------------------------------------------

        // first get current departments
        List<Department> currentDepartments = repository.findDepartmentsOfCompany(company);
        logger.info("Company {} current departments {}", company.getName(), currentDepartments);

        // now determine which departments were deleted
        Collection<Integer> newPIDs = CollectionUtils.collect(newDepartments, Department::getPid);
        List<Department> deletedDepartments = currentDepartments.stream()
                .filter(department -> !newPIDs.contains(department.getPid()))
                .collect(toList());

        // ... and which were added or updated
        Map<Boolean, List<Department>> addedOrUpdatedDepartments = newDepartments.stream()
                .filter(department -> !currentDepartments.contains(department))  // filtering out not changed items, assumes that equals and hashCode is properly coded in Department class
                .collect(Collectors.partitioningBy(d -> d.getPid() == null));  // split (partition) by new or updated (pid is either null or not)
        List<Department> addedDepartments = addedOrUpdatedDepartments.get(Boolean.TRUE);
        List<Department> updatedDepartments = addedOrUpdatedDepartments.get(Boolean.FALSE);

        // now perform relevant operations on each list:
        repository.deleteDepartments(deletedDepartments);
        repository.insertDepartments(addedDepartments);
        repository.updateDepartments(updatedDepartments);
    }

    private List<Department> createNewDepartmentsList(Company company) {
        List<Department> departments = repository.findDepartmentsOfCompany(company);

        // delete one item
        departments.removeIf(department -> department.getName().equals("Lazy Department"));

        // add two items (notice they don't have their own pid yet)
        departments.add(new Department(company.getPid(), "New department 1"));
        departments.add(new Department(company.getPid(), "New department 2"));

        // update one item
        departments.stream()
                .filter(it -> it.getName().equals("IT Department"))
                .findFirst()
                .ifPresent(it -> it.setName("IT Department Updated"));

        return departments;
    }


    /**
     * 9. Complex select - construct select where conditions based on some boolean conditions + throw in some joins
     * <br/>
     * In our case we are executing following query:<br/>
     * Query: get all projects, where the total cost of the project per month is greater than 70000. In the same result set
     * get all companies participating on such project along with cost of the project for the company.
     */
    public void executeComplexSelectScenario() {
        List<ProjectsWithCostsGreaterThanOutput> projectsWithCostsGreaterThan = repository.getProjectsWithCostsGreaterThan(70000);

        // check some post conditions
        assert projectsWithCostsGreaterThan != null;
        assert projectsWithCostsGreaterThan.size() == 2;
        assert projectsWithCostsGreaterThan.get(0).getCompanyName().equals("CleverGang");
        assert projectsWithCostsGreaterThan.get(0).getCompanyCost().equals(new BigDecimal("72000.00"));
        assert projectsWithCostsGreaterThan.get(1).getCompanyName().equals("Supersoft");
        assert projectsWithCostsGreaterThan.get(1).getCompanyCost().equals(new BigDecimal("13000.00"));
        logger.info("executeComplexSelectScenario output: {}", projectsWithCostsGreaterThan);
    }

    /**
     * 10. Call stored procedure/function and process results
     */
    public void callStoredProcedureScenario() {
        RegisterEmployeeOutput output = repository.callRegisterEmployee("Bretislav", "Wajtr", "[email protected]", new BigDecimal(40000), "MyDepartment", "MyCompany");

        // check some post conditions
        assert output != null;
        assert output.getEmployeePid() != null;
        assert output.getEmployeePid() > 10;
        assert output.getDepartmentPid() != null;
        assert output.getDepartmentPid() > 7;
        assert output.getCompanyPid() != null;
        assert output.getCompanyPid() > 3;
        logger.info("callStoredProcedureScenario output: {}", output);
    }


    /**
     * 11. Execute query using JDBC simple Statement (not PreparedStatement)
     * <br/>
     * Motivation why we need the "static statement" feature: In 96% of the cases, you’re better off writing
     * a PreparedStatement rather than a static statement - it's safer (sql injection),
     * easier (complex data types like dates) and sometimes faster (prepared statements reuse). However, there are
     * edge cases for complex queries and lot of data where it's actually faster to use simple statement query, because
     * your database’s cost-based optimiser or planner obtains some heads-up about what kind of data is really going to
     * be affected by the query and can therefore execute the query faster.
     * <p>
     * Good SQL API framework should offer way how to execute simple static statements.
     */
    public void executeSimpleStaticStatementScenario() {
        Company output = repository.findCompanyUsingSimpleStaticStatement(1);

        // check some post conditions
        assert output != null;
        assert output.getName().equals("CleverGang");
        logger.info("Output of scenario 11: {}", output);
    }

    /**
     * 12. Delete one record by PID
     * <br/>
     * Just one of the CRUD operations.
     */
    public void removeSingleEntityScenario() {
        repository.removeProject(2);

        // check some post conditions
        Integer projectCount = repository.getProjectsCount();
        assert projectCount == 1;
    }

}