<?PHP
#
#   FILE:  MailingList.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2014 Internet Scout Project
#   http://scout.wisc.edu/cwis/
#

class MailingList extends Plugin
{
    /**
    * Register information about this plugin.
    */
    public function Register()
    {
        $this->Name = "MailingList";
        $this->Version = "1.1.1";
        $this->Description =
            "Links a CWIS site with mailing list software to provide "
            ."user-friendly mailing list subscription and transparent "
            ."updates to mailing list subscriptions when a user's e-mail "
            ."address is changed.  Requires exactly one mailing list backend "
            ."to be enabled.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
            "Mailer" => "1.1.0",
            "CWISCore" => "3.9.0",
            );
        $this->EnabledByDefault = FALSE;

        $this->CfgSetup["SubscriberListThreshold"] = array(
            "Type" => "Number",
            "Label" => "Subscriber List Threshold",
            "Help" =>
                "Number of subscribers above which the Mailing List information no "
                ."longer displays a list of all subscribers.  This avoids performance "
                ."problems when viewing information for large mailing lists.",
            "Default" => 750 );

        $this->CfgSetup["UnsubscribeWhenDeleted"] = array(
          "Type" => "Flag",
          "Label" => "Unsubscribe Users When Deleted",
          "Help" => "Unsubscribe users from the mailing list when deleted from CWIS.",
          "OnLabel" => "Yes",
          "OffLabel" => "No");

    }

    /**
    * Perform installation actions for this plugin -- creating a field
    * in the User schema and a Mailer template.
    * @return null|string NULL on success or error message on error
    */
    public function Install()
    {
        # add mailing list field
        $Schema = new MetadataSchema(MetadataSchema::SCHEMAID_USER);

        $MLSubsField = $Schema->AddField(
            "Mailing List Subscriptions",
            MetadataSchema::MDFTYPE_OPTION );

        if ($MLSubsField === NULL)
        {
            return "Could not create the Mailing List Subscriptions User field";
        }
        else
        {
            $MLSubsField->AllowMultiple(TRUE);
            $MLSubsField->Owner($this->Name);
            $MLSubsField->IsTempItem(FALSE);
        }

        $Mailer = $GLOBALS["G_PluginManager"]->GetPlugin("Mailer");
        $TemplateList = $Mailer->GetTemplateList();

        # if we don't have a subscription template, create one and set
        # it as ours
        if (!in_array("MailingList Subscription", $TemplateList))
        {
            global $G_SysConfig;

            $FromAddr = $G_SysConfig->PortalName()
                ." <".$G_SysConfig->AdminEmail().">";
            $TemplateId = $Mailer->AddTemplate(
                "MailingList Subscription",
                $FromAddr,
                $G_SysConfig->PortalName()." Mailing list confirmation",
                "", "",
                "Thank you for subscribing to a mailing list from "
                .$G_SysConfig->PortalName().".  To complete your subscription, "
                ."please click the link below:\n\n"
                ."    X-ACTIVATIONURL-X\n\n"
                ."If the link doesn't work, you can also go to this address\n\n"
                ."    X-MANUALACTIVATIONURL-X\n\n"
                ."And enter your user name and activation code:\n\n"
                ."    Name: X-USERNAME-X\n"
                ."    Code: X-ACTIVATIONCODE-X",
                "", "");

            $this->ConfigSetting("SubscriptionConfirmationTemplate", $TemplateId);
        }

        return NULL;
    }

    /**
    * Upgrade from a previous version.
    * @param string $PreviousVersion Previous version of the plugin.
    * @return NULL on success or error string otherwise.
    */
    public function Upgrade($PreviousVersion)
    {
        if (version_compare($PreviousVersion, "1.1.1", "<"))
        {
            $Mailer = $GLOBALS["G_PluginManager"]->GetPlugin("Mailer");
            $TemplateList = $Mailer->GetTemplateList();

            # if we don't have a subscription template, create one
            if (!in_array("MailingList Subscription", $TemplateList))
            {
                global $G_SysConfig;

                $FromAddr = $G_SysConfig->PortalName()
                          ." <".$G_SysConfig->AdminEmail().">";
                $TemplateId = $Mailer->AddTemplate(
                    "MailingList Subscription",
                    $FromAddr,
                    $G_SysConfig->PortalName()." Mailing list confirmation",
                    "", "",
                    "Thank you for subscribing to a mailing list from "
                    .$G_SysConfig->PortalName().".  To complete your subscription, "
                    ."please click the link below:\n\n"
                    ."    X-ACTIVATIONURL-X\n\n"
                    ."If the link doesn't work, you can also go to this address\n\n"
                    ."    X-MANUALACTIVATIONURL-X\n\n"
                    ."And enter your user name and activation code:\n\n"
                    ."    Name: X-USERNAME-X\n"
                    ."    Code: X-ACTIVATIONCODE-X",
                    "", "");

                $this->ConfigSetting("SubscriptionConfirmationTemplate", $TemplateId);
            }
        }

        return NULL;
    }

    /**
    * Uninstall the MailingList plugin, cleaning up created fields
    * @return null|string, NULL on success and error message on failure.
    */
    public function Uninstall()
    {
        $Schema = new MetadataSchema(MetadataSchema::SCHEMAID_USER);
        $MLSubsField = $Schema->GetFieldByName("Mailing List Subscriptions");

        if (FALSE === $Schema->DropField($MLSubsField->Id() ))
        {
            return "Unable to drop the Mailing List Subscriptions field "
                    ."from the User schema.";
        }

        return NULL;
    }

    /**
    * Make sure the Mailman configuration values are valid
    * @return null|string NULL if there are no errors or an error message
    */
    public function Initialize()
    {
        if (count(self::$Backends) != 1)
        {
            return "Exactly one mailing list backend must be enabled.";
        }

        if ($GLOBALS["G_PluginManager"]->PluginEnabled("MetricsRecorder"))
        {
            # register our events with metrics recorder
            $GLOBALS["G_PluginManager"]->GetPlugin("MetricsRecorder")->RegisterEventType(
                $this->Name, "NumberOfSubscribers");
        }
    }

    /**
    * Set up configration optiosn that come from external sources.
    */
    public function SetUpConfigOptions()
    {
        $Mailer = $GLOBALS["G_PluginManager"]->GetPlugin("Mailer");

        $TemplateOptions =  $Mailer->GetTemplateList();
        $this->CfgSetup["SubscriptionConfirmationTemplate"] = array(
            "Type" => "Option",
            "Label" => "Subscription E-mail Template",
            "Help" => "The Mailer template for subscription confirmation emails.",
            "Options" => $TemplateOptions);
    }

    /**
    * Hook the events into the application framework.
    * @return array events to be hooked into the application framework
    */
    public function HookEvents()
    {
        return array(
            "EVENT_DAILY" => "RunDaily",
            "EVENT_PAGE_LOAD" => "PageLoaded",
            "EVENT_RESOURCE_MODIFY" => "ResourceModify",
            "EVENT_USER_VERIFIED" => "UserVerified",
            "EVENT_USER_DELETED" => "UserDeleted",
            "EVENT_USER_EMAIL_CHANGED"  => "UserEmailChanged",
            "EVENT_USER_ADMINISTRATION_MENU" => "DeclareUserPages",
            "Mailer_EVENT_IS_TEMPLATE_IN_USE" => "MailerTemplateInUse",
            );
    }

    /**
    * Register mailing list support backend.
    * @param string $BackendName Internal name.
    * @param string $BackendLabel Label display in select menu
    * @param mixed $BackendObject Object instance to use
    */
    static public function RegisterBackend(
            $BackendName, $BackendLabel, $BackendObject)
    {
        if (array_key_exists( $BackendName, self::$Backends ) )
        {
            throw new Exception(
                    "Duplicate mailing list backend registered: ".$BackendName);
        }

        foreach (array("GetAllLists","GetSubscribers",
                       "GetUserSubscriptions", "Subscribe","Unsubscribe",
                       "UpdateUserEmail") as $Fn)
        {
            if ( !is_callable(array($BackendObject, $Fn)))
            {
                throw new Exception("Mailing list backend registered without "
                                    ."required function: ".$Fn);
            }
        }

        self::$Backends[$BackendName] = $BackendObject ;
    }

    /**
    * Record subscriber statistics and synchronize subscription information daily.
    * @param string $LastRunAt The date and time this method was last run.
    */
    public function RunDaily($LastRunAt)
    {
        # make sure our directory of lists is current
        $this->SynchronizeLists();

        if ($GLOBALS["G_PluginManager"]->PluginEnabled("MetricsRecorder"))
        {
            # record statistics for all the lists
            foreach ($this->GetAllLists() as $List)
            {
                $Statistics = $this->GetStatistics($List);

                $GLOBALS["G_PluginManager"]->GetPlugin("MetricsRecorder")->RecordEvent(
                    $this->Name,
                    "NumberOfSubscribers",
                    $Statistics["MailingList"],
                    $Statistics["NumSubscribers"],
                    NULL,
                    0,
                    FALSE);
            }
        }

        # update subscription information for all the users
        $UserFactory = new UserFactory();
        foreach ($UserFactory->GetMatchingUsers('.*') as $UserId => $UserName)
        {
            $User = new CWUser($UserId);

            if ($User->Get("RegistrationConfirmed"))
            {
                $this->PullSubsForUser( $User );
            }
        }
    }

    /**
    * Synchronize mailing lists with the backend on pageloads that
    * require it.
    * @param string $PageName Page name
    */
    public function PageLoaded($PageName)
    {
        if ($PageName == "Preferences")
        {
            if ($GLOBALS["G_User"]->IsLoggedIn())
            {
                $this->SynchronizeLists();
                $this->PullSubsForUser( $GLOBALS["G_User"] );
            }
        }
        elseif ($PageName == "RequestAccount")
        {
            $this->SynchronizeLists();
        }
        elseif ($PageName == "PluginConfig" &&
                isset($_GET["PN"]) && $_GET["PN"] == $this->Name)
        {
            $AllLists = $this->GetAllLists(FALSE);
            $OptionArray = array_combine($AllLists, $AllLists);

            $this->CfgSetup["ListsEnabled"] = array(
                "Type" => "Option",
                "Label" => "Mailing lists to manage",
                "Help" => "Select which mailing lists you would "
                          ."like to offer users of this site",
                "AllowMultiple" => TRUE,
                "Rows" => count($AllLists),
                "Options" => $OptionArray );

            $DefaultOptions = array_combine(
                $this->ConfigSetting("ListsEnabled"),
                $this->ConfigSetting("ListsEnabled"));

            $this->CfgSetup["SubscribeByDefault"] = array(
                "Type" => "Option",
                "Label" => "Default subscription selections",
                "Help" => "Lists which should be checked on the "
                    ."new user signup forms.  Must be enabled.",
                "AllowMultiple" => TRUE,
                "Rows" => count($DefaultOptions),
                "Options" => $DefaultOptions);
        }

        return array("PageName" => $PageName);
    }

    /**
    * When user subscription information is modified, push it to the backend.
    * @param Resource $Resource Resource that was modified.
    */
    public function ResourceModify(Resource $Resource)
    {
        if ($Resource->SchemaId() == MetadataSchema::SCHEMAID_USER)
        {
            $User = new CWUser($Resource->Get("UserId"));
            $this->PushSubsForUser($User);
        }
    }

    /**
    * Add a recently-verified user to the default mailing list from the
    * pending subscriptions list if he or she requested a subscription.
    * @param int|string $UserId User ID
    */
    public function UserVerified($UserId)
    {
        $User = new CWUser($UserId);
        $this->PushSubsForUser($User);
    }

    /**
    * Remove a recently-deleted user from the default mailing list if the
    * plugin is configured to do so.
    * @param int|string $UserId User ID.
    */
    public function UserDeleted($UserId)
    {
        $Database = new Database();

        if ($this->ConfigSetting("UnsubscribeWhenDeleted"))
        {
            $User = new CWUser($UserId);

            foreach ($this->GetAllLists() as $List)
            {
                $this->Unsubscribe($User, $List);
            }
        }
    }

    /**
    * Update the user's mailing list subscriptions when their e-mail
    * address changes.
    * @param int|string $UserId User ID
    * @param string $OldEmail Previous e-mail address for the user
    * @param string $NewEmail New e-mail address for the user
    */
    public function UserEmailChanged($UserId, $OldEmail, $NewEmail)
    {
        $this->GetBackend()->UpdateUserEmail($OldEmail, $NewEmail);
    }

    /**
    * Declare the user administration pages this plugin provides.
    * @return array page URLs and labels
    */
    public function DeclareUserPages()
    {
        return array("Information" => "Mailing List Metrics");
    }

    /**
    * Get all available mailing lists.
    * @param bool $Filter Should only enabled lists be returned (OPTIONAL, default TRUE)
    * @return array all available mailing lists
    */
    public function GetAllLists($Filter=TRUE)
    {
        $AllLists = array();

        $Lists = $this->GetBackend()->GetAllLists();
        if ($Lists !== NULL)
        {
            foreach ($Lists as $List)
            {
                if (in_array($List, $this->ConfigSetting("ListsEnabled")) ||
                    !$Filter)
                {
                    $AllLists[]= $List;
                }
            }
        }

        return $AllLists;
    }

    /**
    * Tell mailer which template we are using.
    * @param int $TemplateId TemplateId mailer asking about.
    * @param array $TemplateUsers List of template users.
    */
    public function MailerTemplateInUse($TemplateId, $TemplateUsers)
    {
        $MyTemplate = $this->ConfigSetting("SubscriptionConfirmationTemplate");

        if ($TemplateId == $MyTemplate)
        {
            $TemplateUsers[]= $this->Name;
        }

        return array(
            "TemplateId" => $TemplateId,
            "TemplateUsers" => $TemplateUsers);
    }

    /**
    * Get the mailing lists to which the given user is subscribed.
    * @param User $User Target user.
    * @return array mailing lists to which the user is subscribed
    */
    public function GetUserSubscriptions(User $User)
    {
        return $this->GetBackend()->GetUserSubscriptions(
            $User->Get("EMail") );
    }

    /**
    * Determine if the given user is subscribed to the given mailing list.
    * @param User $User Target user.
    * @param string $List Mailing list name.
    * @return bool TRUE if the user is subscribed to the mailing list
    */
    public function IsSubscribed(User $User, $List)
    {
        $ListSubscriptions = $this->GetUserSubscriptions($User);
        $List = $this->NormalizeListName($List);

        $IsSubscribed = in_array($List, $ListSubscriptions);

        return $IsSubscribed;
    }

    /**
    * Subscribe the given user to the given mailing list.
    * @param User $User Target user.
    * @param string $List Mailing list.
    */
    public function Subscribe(User $User, $List)
    {
        $AllLists = $this->GetAllLists();
        $List = $this->NormalizeListName($List);

        # don't try subscribing the user to a non-existent list
        if (!in_array($List, $AllLists))
        {
            return;
        }

        # don't try subscribing the user to a list they are already on
        if ($this->IsSubscribed($User, $List))
        {
            return;
        }

        $this->GetBackend()->Subscribe(
            $User->Get("EMail"), $List);
    }

    /**
    * Unsubscribe the given user from the given mailing list.
    * @param User $User Target user.
    * @param string $List Mailing list.
    */
    public function Unsubscribe(User $User, $List)
    {
        $AllLists = $this->GetAllLists();
        $List = $this->NormalizeListName($List);

        # don't try unsubscribing the user to a non-existent list
        if (!in_array($List, $AllLists))
        {
            return;
        }

        # don't try unsubscribe the user from a list they were not on
        if (!$this->IsSubscribed($User, $List))
        {
            return;
        }

        $this->GetBackend()->Unsubscribe(
            $User->Get("EMail"), $List);
    }

    /**
    * Get the statistics for the given mailing list.
    * @param string $List Mailing list name
    * @return array statistics for the mailing list
    */
    public function GetStatistics($List)
    {
        $Subscribers = $this->GetBackend()->GetSubscribers($List);

        $NumSubscribers = count($Subscribers);
        $ShowSubscribers =
            $NumSubscribers < $this->ConfigSetting("SubscriberListThreshold");

        # only attempt getting additional information for the subscribers if
        # the number of subscribers is below the threshold to avoid performance
        # issues
        $Information = $this->GetInformationForSubscriberList($Subscribers);
        $NumUsersInCwis = $Information["NumUsersInCwis"];

        if ($ShowSubscribers)
        {
            $SubscriberList = $Information["Subscribers"];
        }
        else
        {
            $SubscriberList = array();
        }

        return array(
            "MailingList" => $List,
            "Subscribers" => $SubscriberList,
            "NumSubscribers" => $NumSubscribers,
            "NumUsersInCwis" => $NumUsersInCwis,
            "ShowSubscribers" => $ShowSubscribers,
            "PluginVersion" => $this->Version );
    }

    /**
    * Get additional information for the given subscriber list.
    * @param array $Subscribers Subscriber list
    * @return array additional information for each subscriber
    */
    protected function GetInformationForSubscriberList(array $Subscribers)
    {
        $Database = new Database();
        $ExtraInformation = array(
            "Subscribers" => array(),
            "NumUsersInCwis" => 0);

        if (count($Subscribers) < $this->ConfigSetting("SubscriberListThreshold"))
        {
            foreach ($Subscribers as $Subscriber)
            {
                # so that each subscriber has these indices regardless
                $ExtraInformation["Subscribers"][$Subscriber] = array(
                "InCwis" => FALSE,
                "UserId" => NULL,
                "UserName" => NULL,
                "RealName" => NULL);

                $Database->Query("
                    SELECT * FROM APUsers
                    WHERE EMail = '".addslashes($Subscriber)."'");

                # if the user is in CWIS
                if ($Database->NumRowsSelected())
                {
                    $Row = $Database->FetchRow();

                    $ExtraInformation["NumUsersInCwis"] += 1;
                    $ExtraInformation["Subscribers"][$Subscriber] = array(
                        "InCwis" => TRUE,
                        "UserId" => $Row["UserId"],
                        "UserName" => $Row["UserName"],
                        "RealName" => $Row["RealName"]);
                }
            }
        }
        else
        {
            # too many subscribers to display the list
            # instead, we'll just count how many of them have an account here

            # make a look-up table where emails associated with a CWIS
            # account are set to 1
            $CwisAccounts = array();
            $Database->Query("SELECT EMail FROM APUsers");
            while ($Row = $Database->FetchRow() )
            {
                $CwisAccounts[$Row["EMail"]] = 1;
            }

            # iterate over the mailing list subscribers, counting up
            # how many have a corresponding CWIS account
            foreach ($Subscribers as $Subscriber)
            {
                if (isset($CwisAccounts[$Subscriber]))
                {
                    $ExtraInformation["NumUsersInCwis"] += 1;
                }
            }
        }

        return $ExtraInformation;
    }

    /**
    * Normalize the given mailing list name.
    * @param string $ListName Mailing list name
    * @return string normalized mailing list name
    */
    public function NormalizeListName($ListName)
    {
        return strtolower(trim($ListName));
    }

    /**
    * Transform the given display name to a string that is a valid display-name
    * token from the spec.
    * @param string $DisplayName E-mail display name
    * @return string Valid display-name token
    * @see http://tools.ietf.org/html/rfc5322#section-3.4
    */
    public function EscapeEmailDisplayName($DisplayName)
    {
        return preg_replace('/[^\x20\x23-\x5B\x5D-\x7E]/', "", $DisplayName);
    }

    /**
    * Retrieve the list of form fields for the subscription page.
    * @return array of FormFields as expected by FormTool.
    */
    public static function GetSubscriptionFormFields()
    {
        return  array(
                new TextFormField(
                        "F_EMail", TRUE, "E-Mail Address:", 30, 80),
                new TextFormField(
                        "F_EMailAgain", TRUE, "E-Mail Address (Again):", 30, 80),
                );
    }

    /**
    * Get the current backend plugin.
    * @return Plugin object.
    */
    private function GetBackend()
    {
        return current(self::$Backends);
    }

    /**
    * Synchronize our directory of available mailing lists with that
    * available from the backend.
    */
    private function SynchronizeLists()
    {
        # get all the lists that are enabled
        $AllLists = $this->GetAllLists();

        # pull out our subscriptions field
        $Schema = new MetadataSchema(MetadataSchema::SCHEMAID_USER);
        $MLSubsField = $Schema->GetFieldByName("Mailing List Subscriptions");

        # make sure that we have a CName for each list that exists
        $CNFactory = new ControlledNameFactory( $MLSubsField->Id() );
        foreach ($AllLists as $List)
        {
            if ($CNFactory->GetItemByName($List) === NULL)
            {
                new ControlledName( NULL, $List, $MLSubsField->Id() );
            }
        }

        # and remove cnames for lists that no longer exist
        foreach ($MLSubsField->GetPossibleValues() as $ListName)
        {
            if (!in_array($ListName, $AllLists))
            {
                $CName = $CNFactory->GetItemByName($ListName);
                $CName->Delete(TRUE);
            }
        }

        # remove non-existent lists from the default subscriptions
        $DefaultSubs = array();
        $DefaultSubsCN = array();
        foreach ($this->ConfigSetting("SubscribeByDefault") as $List)
        {
            if (in_array($List, $AllLists))
            {
                $DefaultSubs[]= $List;
                $CName = $CNFactory->GetItemByName($List);
                $DefaultSubsCN[]= $CName->Id();
            }
        }
        $this->ConfigSetting("SubscribeByDefault", $DefaultSubs);
        $MLSubsField->DefaultValue($DefaultSubsCN);
    }

    /**
    * Pull the list of subscriptions for a specified user from the backend.
    * @param CWUser $User Target user.
    */
    private function PullSubsForUser(CWUser $User)
    {
        # declare these guys as static in this function so that
        # repeated invocations in the daily maintenance don't burn a lot
        # of time creating them.
        static $Schema;
        static $MLSubsField;
        static $CNFactory;

        if (!isset($Schema))
        {
            $Schema = new MetadataSchema(MetadataSchema::SCHEMAID_USER);
        }

        if (!isset($MLSubsField))
        {
            $MLSubsField = $Schema->GetFieldByName("Mailing List Subscriptions");
        }

        if (!isset($CNFactory))
        {
            $CNFactory = new ControlledNameFactory( $MLSubsField->Id() );
        }

        $UserSubs = $this->GetUserSubscriptions( $User );
        $DesiredValue = array();
        foreach ($UserSubs as $List)
        {
            $CName = $CNFactory->GetItemByName($List);
            # CName may be null if they are subscribed to a list we don't manage
            if ($CName !== NULL)
            {
                $DesiredValue[$CName->Id()] = $List;
            }
        }

        $Resource = $User->GetResource();
        $Resource->Set( $MLSubsField, $DesiredValue, TRUE );
    }

    /**
    * Push update subscritpions to the backend for a specified user.
    * @param CWUser $User Target User.
    */
    private function PushSubsForUser(CWUser $User)
    {
        $Resource = $User->GetResource();
        # and grab both the desired and current set of subscriptions
        $DesiredSubs = $Resource->Get("Mailing List Subscriptions");
        $RemoteSubs = $this->GetUserSubscriptions( $User );

        # make sure that we're actually subscribed to all of the requested lists
        foreach ($DesiredSubs as $List)
        {
            if (!in_array($List, $RemoteSubs))
            {
                $this->Subscribe($User, $List);
            }
        }

        # and that we aren't subscribed to lists that weren't requested
        foreach ($RemoteSubs as $List)
        {
            if (!in_array($List, $DesiredSubs))
            {
                $this->Unsubscribe($User, $List);
            }
        }
    }


    static private $Backends = array();
    private $BackendOptions = array();
}
