#!/usr/bin/perl
# This code was forked from the LiveJournal project owned and operated
# by Live Journal, Inc. The code has been modified and expanded by
# Dreamwidth Studios, LLC. These files were originally licensed under
# the terms of the license supplied by Live Journal, Inc, which can
# currently be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.

package LJ::Faq;

use strict;
use Carp;

# Initially built in a hackathon, so this is only moderately awesome
# -- whitaker 2006/06/23

# FIXME: singletons?

# <LJFUNC>
# name: LJ::Faq::new
# class: general
# des: Creates a LJ::Faq object from supplied information.
# args: opts
# des-opts: Hash of initial field values for the new Faq. Allowed keys are:
#           faqid, question, summary, answer, faqcat, lastmoduserid, sortorder,
#           lastmodtime, unixmodtime, and lang. Default for lang is
#           $LJ::DEFAULT_LANG, all others undef.
# returns: The new LJ::Faq object.
# </LJFUNC>
sub new {
    my $class = shift;
    my $self  = bless {}, $class;

    my %opts = @_;

    $self->{$_} = delete $opts{$_}
        foreach
        qw(faqid question summary answer faqcat lastmoduserid sortorder lastmodtime unixmodtime);

    # FIXME: shouldn't that be the root language of the faq domain instead?
    $self->{lang} = delete $opts{lang} || $LJ::DEFAULT_LANG;

    croak( "unknown parameters: " . join( ", ", keys %opts ) )
        if %opts;

    return $self;
}

# <LJFUNC>
# name: LJ::Faq::load
# class: general
# des: Creates a LJ::Faq object and populates it from the database.
# args: faqid, opts?
# des-faqid: The integer id of the FAQ to load.
# des-opts: Hash of option key => value.
#           lang => language, xx or xx_YY. Defaults to $LJ::DEFAULT_LANG.
# returns: The newly populated LJ::Faq object.
# </LJFUNC>
sub load {
    my $class = shift;
    my $faqid = int(shift);
    croak("invalid faqid: $faqid")
        unless $faqid > 0;

    my %opts = @_;
    my $lang = delete $opts{lang} || $LJ::DEFAULT_LANG;
    croak( "unknown parameters: " . join( ", ", keys %opts ) )
        if %opts;

    my $dbr = LJ::get_db_reader()
        or die "Unable to contact global reader";

    my $faq;

    # FIXME: shouldn't that be the root language of the faq domain instead?
    if ( $lang eq $LJ::DEFAULT_LANG ) {
        my $f = $dbr->selectrow_hashref(
            "SELECT faqid, question, summary, answer, faqcat, lastmoduserid, "
                . "DATE_FORMAT(lastmodtime, '%M %D, %Y') AS lastmodtime, "
                . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder "
                . "FROM faq WHERE faqid=?",
            undef, $faqid
        );
        die $dbr->errstr if $dbr->err;
        return undef unless $f;
        $faq = $class->new( %$f, lang => $lang );

    }
    else {    # Don't load fields that lang_update_in_place will overwrite.
        my $f = $dbr->selectrow_hashref(
            "SELECT faqid, faqcat, "
                . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder "
                . "FROM faq WHERE faqid=?",
            undef, $faqid
        );
        die $dbr->errstr if $dbr->err;
        return undef unless $f;
        $faq = $class->new( %$f, lang => $lang );
        $faq->lang_update_in_place;
    }

    return $faq;
}

# <LJFUNC>
# name: LJ::Faq::load_all
# class: general
# des: Creates LJ::Faq objects from all FAQs in the database.
# args: opts?
# des-opts: Hash of option key => value.
#           lang => language, xx or xx_YY. Defaults to $LJ::DEFAULT_LANG.
#           cat => category to load (loads FAQs in all cats if absent).
# returns: Array of populated LJ::Faq objects, one per FAQ loaded.
# </LJFUNC>
sub load_all {
    my $class = shift;

    my $dbr = LJ::get_db_reader()
        or die "Unable to contact global reader";

    my %opts         = @_;
    my $lang         = delete $opts{lang} || $LJ::DEFAULT_LANG;
    my $faqcat       = delete $opts{cat};
    my $allow_no_cat = delete $opts{allow_no_cat} || 0;

    my $wherecat = "";
    if ($allow_no_cat) {
        $wherecat = "WHERE faqcat = " . $dbr->quote($faqcat) if defined $faqcat;
    }
    else {
        $wherecat = "WHERE faqcat "
            . ( defined $faqcat && length $faqcat ? "= " . $dbr->quote($faqcat) : "!= ''" );
    }

    croak( "unknown parameters: " . join( ", ", keys %opts ) )
        if %opts;

    my $sth;
    if ( $lang eq $LJ::DEFAULT_LANG ) {
        $sth =
            $dbr->prepare( "SELECT faqid, question, summary, answer, faqcat, lastmoduserid, "
                . "DATE_FORMAT(lastmodtime, '%M %D, %Y') AS lastmodtime, "
                . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder "
                . "FROM faq $wherecat" );

    }
    else {    # Don't load fields that lang_update_in_place will overwrite.
        $sth =
            $dbr->prepare( "SELECT faqid, faqcat, "
                . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder "
                . "FROM faq $wherecat" );
    }
    $sth->execute;
    die $sth->errstr if $sth->err;

    my @faqs;
    while ( my $f = $sth->fetchrow_hashref ) {
        push @faqs, $class->new(%$f);
    }

    # FIXME: shouldn't that be the root language of the faq domain instead?
    if ( $lang ne $LJ::DEFAULT_LANG ) {
        $class->lang_update_in_place( $lang => @faqs );
    }

    return @faqs;
}

sub faqid {
    my $self = shift;
    return $self->{faqid};
}
*id = \&faqid;

sub lang {
    my $self = shift;
    return LJ::Lang::get_lang( $self->{lang} ) ? $self->{lang} : $LJ::DEFAULT_LANG;
}

sub question_raw {
    my $self = shift;
    return $self->{question};
}

sub question_html {
    my $self = shift;
    return LJ::ehtml( $self->{question} );
}

sub summary_raw {
    my $self = shift;
    return $self->{summary};
}

sub summary_html {
    my $self = shift;
    return LJ::ehtml( $self->{summary} );
}

sub answer_raw {
    my $self = shift;
    return $self->{answer};
}

sub answer_html {
    my $self = shift;
    return LJ::ehtml( $self->{answer} );
}

sub faqcat {
    my $self = shift;
    return $self->{faqcat};
}

sub lastmoduserid {
    my $self = shift;
    return $self->{lastmoduserid};
}

sub lastmodtime {
    my $self = shift;
    return $self->{lastmodtime};
}

sub unixmodtime {
    my $self = shift;
    return $self->{unixmodtime};
}

sub sortorder {
    my $self = shift;
    return $self->{sortorder};
}

sub url {
    my ( $class, $faqid ) = @_;
    $faqid = $class->{faqid} if ref $class;
    return "$LJ::SITEROOT/support/faqbrowse?faqid=$faqid";
}

sub url_full {
    my ( $class, $faqid ) = @_;
    $faqid = $class->{faqid} if ref $class;
    return "$LJ::SITEROOT/support/faqbrowse?faqid=$faqid&view=full";
}

# <LJFUNC>
# name: LJ::Faq::has_summary
# class: general
# des: Tests whether instance has a summary
# args:
# returns: True value if instance has a summary, false value otherwise
# </LJFUNC>
sub has_summary {
    my $self = shift;

    # Translators can't save empty strings, so "-" means "empty" too.
    return !( $self->summary_raw eq "" || $self->summary_raw eq "-" );
}

# <LJFUNC>
# name: LJ::Faq::lang_update_in_place
# class: general
# des: Fill in question, summary and answer from database for one or more FAQs.
# info: May be called either as a class method or an object method, ie:
#       - $self->lang_update_in_place;
#       - LJ::Faq->lang_update_in_place($lang, @faqs);
# args: lang?, faqs?
# des-lang: Language to fetch strings for (as a class method).
# des-faqs: Array of LJ::Faq objects to fetch strings for (as a class method).
# returns: True value if successful.
# </LJFUNC>
sub lang_update_in_place {
    my $class = shift;

    my ( $lang, @faqs );
    if ( ref $class ) {
        $lang = $class->{lang};
        @faqs = ($class);
        croak("superfluous parameters") if @_;
    }
    else {
        $lang = shift;
        @faqs = @_;
        croak("invalid parameters") if grep { ref $_ ne 'LJ::Faq' } @faqs;
    }

    my $faqd = LJ::Lang::get_dom("faq");
    my $l    = LJ::Lang::get_lang($lang) || LJ::Lang::get_lang($LJ::DEFAULT_LANG);
    croak("missing domain")          unless $faqd;
    croak("invalid language: $lang") unless $l;

    my @load;
    foreach (@faqs) {
        push @load, "$_->{faqid}.1question";
        push @load, "$_->{faqid}.3summary";
        push @load, "$_->{faqid}.2answer";
    }

    my $res = LJ::Lang::get_text_multi( $l->{'lncode'}, $faqd->{'dmid'}, \@load );
    foreach (@faqs) {
        my $id = $_->{faqid};
        $_->{question} = $res->{"$id.1question"} if $res->{"$id.1question"};
        $_->{summary}  = $res->{"$id.3summary"}  if $res->{"$id.3summary"};
        $_->{answer}   = $res->{"$id.2answer"}   if $res->{"$id.2answer"};

        $_->{summary} = $LJ::_T_FAQ_SUMMARY_OVERRIDE if $LJ::_T_FAQ_SUMMARY_OVERRIDE;

        # FIXME?: the join can probably be avoided, eg by using something like
        # LJ::Lang::get_chgtime_unix for time of last change and a single-table
        # "SELECT userid FROM ml_text WHERE t.lnid=? AND t.dmid=? AND t.itid=?
        # ORDER BY t.txtid DESC LIMIT 1" for userid.
        my $itid = LJ::Lang::get_itemid( $faqd->{'dmid'}, "$id.2answer" );
        if ($itid) {
            my $sql =
                  "SELECT DATE_FORMAT(l.chgtime, '%Y-%m-%d'), t.userid "
                . "FROM ml_latest AS l, ml_text AS t WHERE l.dmid = t.dmid AND l.lnid = t.lnid AND l.itid = t.itid "
                . "AND l.lnid=? AND l.dmid=? AND l.itid=? ORDER BY t.txtid DESC LIMIT 1";

            my $dbr = LJ::get_db_reader()
                or die "Unable to contact global reader";
            my $sth = $dbr->prepare($sql);
            $sth->execute( $l->{'lnid'}, $faqd->{'dmid'}, $itid );
            die $sth->errstr if $sth->err;
            @{$_}{ 'lastmodtime', 'lastmoduserid' } = $sth->fetchrow_array;
        }
    }

    return 1;
}

# <LJFUNC>
# name: LJ::Faq::render_in_place
# class: general
# des: Render one or more FAQs by expanding FAQ-specific mark-up.
# info: May be called either as a class method or an object method, ie:
#       - $self->render_in_place;
#       - LJ::Faq->render_in_place($lang, @faqs);
#       Note that username, journalurl, and journalurl:* aren't expanded here.
# args: opts, faqs?
# des-opts: Hashref (not hash) of options:
#           - lang => language to render FAQs in (as a class method).
#           - skipfaqs => true to skip [[faqtitle:#]] markup.
#           - user => what to expand [[username]] to.
#           - url => what to expand [[journalurl]] to.
# des-faqs: Array of LJ::Faq objects to render (as a class method).
# returns: True value if successful.
# </LJFUNC>
sub render_in_place {
    my ( $class, $opts, @faqs ) = @_;

    my $lang;
    if ( ref $class ) {
        $lang = $class->{lang};
        croak("superfluous parameters") if @faqs;
        @faqs = ($class);
    }
    else {
        $lang = delete $opts->{"lang"};
        croak("invalid parameters") if grep { ref $_ ne 'LJ::Faq' } @faqs;
    }
    my $user     = delete $opts->{"user"};
    my $user_url = delete $opts->{"url"};
    my $skipfaqs = delete $opts->{"skipfaqs"};
    croak( "unknown parameters: " . join( ", ", keys %$opts ) )
        if %$opts;

    # (letter => ["name", mandatory])
    my %dom_data = ( g => [ "general", 1 ], f => [ "faq", 1 ], w => [ "widget", 0 ] );
    my %dom      = ();
    my %load     = ();
    while ( my ( $k, $d ) = each %dom_data ) {
        my ( $n, $m ) = @$d;
        $dom{$k}  = LJ::Lang::get_dom($n) or $m && croak("missing $n domain");
        $load{$k} = [];
    }

    my $l = LJ::Lang::get_lang($lang) || LJ::Lang::get_lang($LJ::DEFAULT_LANG);
    croak("invalid language: $lang") unless $l;

    my %seen;

    # Collect item codes: \[\[faqtitle:\d+\]\], \[\[[gfw]mlitem:[\w/.-]+\]\]
    my $collect_item_codes = sub {
        my $text = shift;

        unless ($skipfaqs) {
            while ( $text =~ /\[\[faqtitle:(\d+)\]\]/g ) {
                push @{ $load{"f"} }, "${1}.1question"
                    unless $seen{"f:${1}.1question"}++;
            }
        }

        while ( $text =~ m!\[\[([gfw])mlitem:([\w/.-]+)\]\]!g ) {
            push @{ $load{$1} }, $2 unless $seen{"$1:$2"}++;
        }
    };

    foreach my $faq (@faqs) {
        $collect_item_codes->( $faq->question_raw );
        $collect_item_codes->( $faq->summary_raw ) if $faq->has_summary;
        $collect_item_codes->( $faq->answer_raw );
    }

    my %res;
    foreach my $k ( keys %dom ) {
        $res{$k} = LJ::Lang::get_text_multi( $l->{'lncode'}, $dom{$k}->{'dmid'}, $load{$k} );
    }

    my $err_bad_variable = sub {
        my $var = LJ::ehtml(shift);
        return "<b>[Unknown or improper variable: $var]</b>";
    };

    # Replace a variable like [[var]] or [[var:arg]] with the correct text
    my $replace_var = sub {
        my ( $var, $arg, $skipfaqs ) = @_;
        if ( $var eq "journalurl" ) {
            return $user_url unless $arg;
            my $u_arg = LJ::load_user($arg)
                or return "<b>[Unknown username: " . LJ::ehtml($arg) . "]</b>";
            return $u_arg->journal_base || $err_bad_variable->("${var}:${arg}");
        }
        elsif ( $var eq "username" ) {
            return $user unless $arg;
            my $u_arg = LJ::load_user($arg)
                or return "<b>[Unknown username: " . LJ::ehtml($arg) . "]</b>";
            return $u_arg->user || $err_bad_variable->("${var}:${arg}");
        }
        elsif ( $arg && $var eq "faqtitle" ) {
            return $skipfaqs
                ? "[[faqtitle:$arg]]"
                : ( LJ::ehtml( $res{"f"}->{"${arg}.1question"} )
                    || "<b>[Unknown FAQ id: " . LJ::ehtml($arg) . "]</b>" );
        }
        elsif ( $arg && $var =~ /^([gfw])mlitem$/ ) {

            # ML item (gfw = general/FAQ/widget)
            return $res{$1}->{$arg}
                || "<b>[Unknown item code: "
                . LJ::ehtml($arg)
                . " in domain "
                . LJ::ehtml( $dom_data{$1}->[0] ) . "]</b>";
        }
        else {
            # Error
            return $err_bad_variable->( $arg ? "${var}:${arg}" : $var );
        }
    };

    # Change [[faqtitle:id]] to the FAQ id's title/question unless $skipfaqs
    # Change [[(g|f|w)mlitem:code]] to that item's text in general/faq/widget
    my $replace_all_vars = sub {
        my ( $text, $skipfaqs ) = @_;
        $text =~ s!\[\[(\w+)(?::([\w/.-]+?))?\]\]!$replace_var->($1, $2, $skipfaqs)!eg;
        return $text;
    };

    foreach my $faq (@faqs) {
        $faq->{question} = $replace_all_vars->( $faq->question_raw, 1 );
        $faq->{summary}  = $replace_all_vars->( $faq->summary_raw,  $skipfaqs )
            if $faq->has_summary;
        $faq->{answer} = $replace_all_vars->( $faq->answer_raw, $skipfaqs );
    }

    return 1;
}

# <LJFUNC>
# name: LJ::Faq::load_matching
# class: general
# des: Finds all FAQs containing a search term and ranks them by relevance.
# args: term, opts?
# des-term: The string to search for (case-insensitive).
# des-opts: Hash of option key => value.
#           - lang => language to render FAQs in.
#           - user => what to expand [[username]] to.
#           - url => what to expand [[journalurl]] to.
# returns: A list of LJ::Faq objects matching the search term, sorted by
#          decreasing relevance.
# </LJFUNC>
sub load_matching {
    my $class = shift;
    my $term  = shift;
    croak("search term required") unless length( $term . "" );

    my %opts     = @_;
    my $lang     = delete $opts{"lang"} || $LJ::DEFAULT_LANG;
    my $user     = delete $opts{"user"};
    my $user_url = delete $opts{"url"};
    croak( "unknown parameters: " . join( ", ", keys %opts ) )
        if %opts;

    my @faqs = $class->load_all( lang => $lang, allow_no_cat => 0 );
    die "unable to load faqs" unless @faqs;

    my %scores  = ();    # faqid => score
    my @results = ();    # array of faq objects

    # Render FAQs, leaving [[faqtitle:#]] intact. This is to let users search
    # for user interface strings without FAQ titles getting in the way.
    # FIXME: This also expands [[username(:foo)?]] and [[journalurl(:bar)?]].
    # Should it?
    $class->render_in_place( { skipfaqs => 1, lang => $lang, user => $user, url => $user_url },
        @faqs )
        or die "initial FAQ rendering failed";

    foreach my $f (@faqs) {
        my $score = 0;

        $score += 3 if $f->question_raw =~ /\Q$term\E/i;
        $score += 5 if $f->question_raw =~ /\b\Q$term\E\b/i;

        $score += 2 if $f->summary_raw =~ /\Q$term\E/i;
        $score += 4 if $f->summary_raw =~ /\b\Q$term\E\b/i;

        $score += 1 if $f->answer_raw =~ /\Q$term\E/i;
        $score += 3 if $f->answer_raw =~ /\b\Q$term\E\b/i;

        next unless $score;

        $scores{ $f->{faqid} } = $score;

        push @results, $f;
    }

    return sort { $scores{ $b->{faqid} } <=> $scores{ $a->{faqid} } } @results;
}

1;
