import ceylon.time.base {
    ReadableDate,
    ReadableTime,
    sunday
}
import ceylon.time {
    Date,
    date,
    Time,
    time,
    now
}
import ceylon.time.timezone {
    TimeZone,
    OffsetTimeZone
}

"Date, time, currency, and numeric formats for a certain
 [[Locale]]."
shared sealed class Formats(
    shortDateFormat, 
    mediumDateFormat, 
    longDateFormat, 
    shortTimeFormat, 
    mediumTimeFormat, 
    longTimeFormat, 
    integerFormat, 
    floatFormat, 
    percentageFormat, 
    currencyFormat,
    monthNames,
    monthAbbreviations,
    weekdayNames,
    weekdayAbbreviations,
    ampm) {
    
    shared String shortDateFormat;
    shared String mediumDateFormat;
    shared String longDateFormat;
    
    shared String shortTimeFormat;
    shared String mediumTimeFormat;
    shared String longTimeFormat;
    
    shared String integerFormat;
    shared String floatFormat;
    shared String percentageFormat;
    shared String currencyFormat;
    
    [String,String] ampm;
    shared String am => ampm[0];
    shared String pm => ampm[1]; 
    
    shared String[] weekdayNames;
    shared String[] weekdayAbbreviations;
    shared String[] monthNames;
    shared String[] monthAbbreviations;
    
    shared String shortFormatDate(ReadableDate date) 
            => formatDate(shortDateFormat, date);
    shared String mediumFormatDate(ReadableDate date) 
            => formatDate(mediumDateFormat, date);
    shared String longFormatDate(ReadableDate date) 
            => formatDate(longDateFormat, date);
    
    shared String shortFormatTime(ReadableTime time) 
            => formatTime(shortTimeFormat, time);
    shared String mediumFormatTime(ReadableTime time) 
            => formatTime(mediumTimeFormat, time);
    shared String longFormatTime(ReadableTime time, TimeZone timeZone) 
            => formatTimeWithTZ(longTimeFormat, time, timeZone);
    
    String formatDate(String format, ReadableDate date) 
            => applyFormat(format, formatDateToken, date);
    
    String formatTime(String format, ReadableTime time)
            => applyFormat(format, formatTimeToken, time);
    
    String formatTimeWithTZ(String format, ReadableTime time, TimeZone timeZone)
            => applyFormatWithTZ(format, formatTimeToken, time, timeZone);
    
    String applyFormat<Value>(String format, 
        String formatToken(String token, Value val), 
        Value val) {
        function interpolateToken(Integer->String token) 
                => 2.divides(token.key) 
                    then formatToken(token.item, val) 
                    else token.item;
        value tokens = format.split('\''.equals, true, false);
        return String(tokens.indexed.flatMap(interpolateToken));
    }
    
    String applyFormatWithTZ<Value>(String format, 
        String formatToken(String token, Value val, TimeZone timeZone), 
        Value val, TimeZone timeZone) {
        function interpolateToken(Integer->String token) 
                => 2.divides(token.key) 
        then formatToken(token.item, val, timeZone) 
        else token.item;
        value tokens = format.split('\''.equals, true, false);
        return String(tokens.indexed.flatMap(interpolateToken));
    }
    
    String formatDateToken(String token, ReadableDate date) {
        
        value weekdayName 
                => weekdayNames[date.dayOfWeek.integer-1] 
                else date.dayOfWeek.string;
        value weekdayAbbr 
                => weekdayAbbreviations[date.dayOfWeek.integer-1] 
                else date.dayOfWeek.string.initial(3);
        value monthName 
                => monthNames[date.month.integer-1] 
                else date.month.string;
        value monthAbbr 
                => monthAbbreviations[date.month.integer-1] 
                else date.month.string.initial(3);
        value month 
                => date.month.integer.string;
        value twoDigitMonth 
                => month.padLeading(2,'0');
        value day 
                => date.day.string;
        value twoDigitDay 
                => day.padLeading(2,'0');
        value fourDigitYear 
                => date.year.string.padLeading(4,'0');
        value twoDigitYear 
                => date.year.string.padLeading(2,'0').terminal(2);
        value weekOfYear 
                => date.weekOfYear.string;
        value twoDigitWeekOfYear 
                => weekOfYear.padLeading(2,'0');
        value dayNumberInWeek 
                => let (dow=date.dayOfWeek) 
                (dow==sunday then 7 else dow.integer).string;
        
        value result = StringBuilder();
        for (run in runs(token)) {
            value replacement =
                    switch (run)
                    case ("EEEE") weekdayName
                    case ("EEE") weekdayAbbr
                    case ("EE") weekdayAbbr
                    case ("E") weekdayAbbr
                    case ("MMMM") monthName
                    case ("MMM") monthAbbr
                    case ("MM") twoDigitMonth
                    case ("M") month
                    case ("dd") twoDigitDay
                    case ("d") day
                    case ("u") dayNumberInWeek
                    case ("yyyy") fourDigitYear
                    case ("yyy") fourDigitYear
                    case ("yy") twoDigitYear
                    case ("y") fourDigitYear //yes)really
                    case ("W") "" //TODO: week of month
                    case ("F") "" //TODO: day of week in month
                    case ("ww") twoDigitWeekOfYear
                    case ("w") weekOfYear
                    case ("G") "" //TODO: era           
                    else run;
            result.append(replacement);
        }
        return result.string;
    }
    
    function twelveHour(Integer hour)
              => if (hour==0) then [12,ampm[0]]
            else if (hour<=12) then [hour,ampm[0]]
            else [hour-12,ampm[1]];
    
    String formatTimeToken(String token, 
        ReadableTime time, TimeZone? timeZone=null) {
        
        value ampm => twelveHour(time.hours)[1];
        value twelvehour 
                => twelveHour(time.hours)[0].string;
        value weirdTwelvehour 
                => (time.hours<12 then time.hours else time.hours-12).string;
        value twoDigitTwelvehour 
                => twelvehour.padLeading(2, '0');
        value twoDigitWeirdTwelvehour 
                => weirdTwelvehour.padLeading(2, '0');
        value hour 
                => time.hours.string;
        value twoDigitHour 
                => hour.padLeading(2, '0');
        value weirdHour 
                => (time.hours+1).string;
        value twoDigitWeirdHour 
                => weirdHour.padLeading(2, '0');
        value mins 
                => time.minutes.string;
        value twoDigitMins 
                => mins.padLeading(2, '0');
        value secs 
                => time.seconds.string;
        value twoDigitSecs 
                => secs.padLeading(2, '0');
        value millis 
                => time.milliseconds.string;
        value threeDigitMillis 
                => millis.padLeading(3,'0');
        value twoDigitMillis 
                => millis.padLeading(2,'0');
        
        value generalTimeZone 
                => if (exists timeZone) 
                then "GMT" + timeZone.string
                else "";
        
        value simpleTimeZone
                => if (exists timeZone) 
                then timeZone.string
                else "";
        
        value longTimeZone
                => if (exists timeZone) 
                then if (is OffsetTimeZone timeZone, 
                         timeZone.offsetMilliseconds==0)
                    then "Z" 
                    else timeZone.string 
                else "";
        
        value mediumTimeZone
                => longTimeZone.replaceFirst(":", "");
        
        value shortTimeZone
                => let (tz=longTimeZone,
                        col=tz.firstOccurrence(':')) 
                    if (exists col) 
                    then tz[0:col]
                    else tz;
        
        value result = StringBuilder();
        for (run in runs(token)) {
            value replacement =
                    switch (run)
                    case ("hh") twoDigitTwelvehour
                    case ("h") twelvehour
                    case ("KK") twoDigitWeirdTwelvehour
                    case ("K") weirdTwelvehour
                    case ("HH") twoDigitHour
                    case ("H") hour
                    case ("kk") twoDigitWeirdHour
                    case ("k") weirdHour
                    case ("a") ampm
                    case ("mm") twoDigitMins
                    case ("m") mins
                    case ("ss") twoDigitSecs
                    case ("s") secs
                    case ("SSS") threeDigitMillis
                    case ("SS") twoDigitMillis
                    case ("S") millis
                    case ("XXX") longTimeZone
                    case ("XX") mediumTimeZone
                    case ("X") shortTimeZone
                    case ("Z") simpleTimeZone
                    case ("z") generalTimeZone
                    else run;
            result.append(replacement);
        }
        return result.string;
    }
    
    "Given a [[string|text]] expected to represent a 
     formatted date, comprising day, month, and year fields, 
     the [[order|dateOrder]] of the fields in the formatted 
     date, and a list of [[delimiting characters|separators]], 
     return a [[Date]], or `null` if the string cannot be 
     interpreted as a date with the given field order and 
     delimiters. If [[twoDigitCutoffYear]] is non-null, then 
     formatted dates may be written with two-digit years.
     
     For example
     
         au.formats.parseDate(\"3 June 2010\")
     
     produces the date `2010-06-03`, if `au` is the `Locale`
     for `en-AU`. And
     
         us.formats.parseDate(\"01/02/03\", monthDayYear)
     
     produces the date `2003-01-02`, if `us` is the `Locale`
     for `en-US`."
    throws (`class AssertionError`, 
            "if the given [[order|dateOrder]] does not 
             include the day, month, and year")
    shared Date? parseDate(
        "The formatted date."
        String text,
        "The order of the fields of the fomatted date,
         usually [[dayMonthYear]], [[yearMonthDay]], or
         [[monthDayYear]]."
        DateField.Order dateOrder
                //TODO: get the default from the locale! 
                = dayMonthYear,
        "The earliest year which can be written in two-digit
         form, or `null` if two-digit years should be 
         interpreted literally. Defaults to 80 years before
         the current year."
        Integer? twoDigitCutoffYear 
                = now().date().year-80,
        "The characters to recognize as field separators."
        String separators = "/-., ") {
        
        "field order must include day"
        assert (exists dayIndex = 
                dateOrder.firstIndexWhere(DateField.day.equals));
        "field order must include month"
        assert (exists monthIndex = 
                dateOrder.firstIndexWhere(DateField.month.equals));
        "field order must include year"
        assert (exists yearIndex = 
                dateOrder.firstIndexWhere(DateField.year.equals));
        
        value bits = 
                text.split(separators.contains)
                    .map(String.trimmed)
                    .sequence();
            
        Integer day;
        if (exists dayBit = bits[dayIndex]) {
            if (is Integer d = Integer.parse(dayBit)) {
                day = d;
            }
            else {
                return null;
            }
        }
        else {
            return null;
        }
        
        Integer month;
        if (exists monthBit = bits[monthIndex]) {
            if (is Integer m = Integer.parse(monthBit)) {
                month = m;
            }
            else if (exists m = monthNames.locate(monthBit.equalsIgnoringCase)) {
                month = m.key+1;
            }
            else if (exists m = monthAbbreviations.locate(monthBit.equalsIgnoringCase)) {
                month = m.key+1;
            }
            else {
                return null;
            }
        }
        else {
            return null;
        }
        
        Integer year;
        if (exists yearBit = bits[yearIndex]) {
            if (is Integer y = Integer.parse(yearBit)) {
                if (exists twoDigitCutoffYear, y<100) {
                    year = let (century = twoDigitCutoffYear/100,
                                cutoff = twoDigitCutoffYear%100)
                            if (y > cutoff)
                            then century*100 + y 
                            else (century+1)*100 + y;
                }
                else {
                    year = y;
                }
            }
            else {
                return null;
            }
        }
        else {
            return null;
        }
        
        return date {
            year = year;
            month = month;
            day = day;
        };
    }
    
    "Given a [[string|text]] expected to represent a 
     formatted time, comprising an hour, followed by 
     optional minute, second, and millisecond fields, 
     followed by an optional AM or PM marker, and a list of 
     [[delimiting characters|separators]], return a [[Time]], 
     or `null` if the string cannot be interpreted as a time 
     with the given delimiters."
    shared Time? parseTime(
    "The formatted time."
        String text,
        "The characters to recognize as field separators."
        String separators = ":. ") {
        
        value bitsWithAmPm = 
                text.split(separators.contains)
                    .map(String.trimmed)
                    .sequence();
        
        String[] bits;
        Boolean adjust;
        if (nonempty bitsWithAmPm) {
            value last = bitsWithAmPm.last;
            value eqPm = pm.equalsIgnoringCase(last);
            value eqAm = am.equalsIgnoringCase(last);
            if (eqPm || eqAm) {
                bits = bitsWithAmPm[0:bitsWithAmPm.size-1];
                adjust = eqPm;
            }
            else {
                bits = bitsWithAmPm;
                adjust = false;
            }
        }
        else {
            return null;
        }
        
        Integer hour;
        if (exists hourBit = bits[0]) {
            if (is Integer d = Integer.parse(hourBit)) {
                hour = d;
            }
            else {
                return null;
            }
        }
        else {
            return null;
        }
        
        Integer minute;
        if (exists minuteBit = bits[1]) {
            if (is Integer m = Integer.parse(minuteBit)) {
                minute = m;
            }
            else {
                return null;
            }
        }
        else {
            minute = 0;
        }
        
        Integer second;
        if (exists secondBit = bits[2]) {
            if (is Integer y = Integer.parse(secondBit)) {
                second = y;
            }
            else {
                return null;
            }
        }
        else {
            second = 0;
        }
        
        Integer millis;
        if (exists msBit = bits[3]) {
            if (is Integer y = Integer.parse(msBit)) {
                millis = y;
            }
            else {
                return null;
            }
        }
        else {
            millis = 0;
        }
        
        return time {
            hours = adjust then hour+12 else hour;
            minutes = minute;
            seconds = second;
            milliseconds = millis;
        };
    }

}

Formats parseFormats(Iterator<String> lines) {
    
    assert (!is Finished ampmLine = lines.next());
    value ampmCols = columns(ampmLine).iterator();
    assert (is String am=ampmCols.next(), 
        is String pm=ampmCols.next());
    
    assert (!is Finished monthsNameLine = lines.next());
    value monthNames = columns(monthsNameLine).coalesced.sequence();
    assert (!is Finished monthsAbbrLine = lines.next());
    value monthAbbreviations = columns(monthsAbbrLine).coalesced.sequence();
    
    assert (!is Finished dayNameLine = lines.next());
    value dayNames = columns(dayNameLine).coalesced.sequence();
    assert (!is Finished dayAbbrLine = lines.next());
    value dayAbbreviations = columns(dayAbbrLine).coalesced.sequence();
    
    assert (!is Finished dateFormats = lines.next());
    value dateCols = columns(dateFormats).iterator();
    assert (is String shortDateFormat = dateCols.next());
    assert (is String mediumDateFormat = dateCols.next());
    assert (is String longDateFormat = dateCols.next());
    assert (!is Finished timeFormats = lines.next());
    
    value timeCols = columns(timeFormats).iterator();
    assert (is String shortTimeFormat = timeCols.next());
    assert (is String mediumTimeFormat = timeCols.next());
    assert (is String longTimeFormat = timeCols.next());
    assert (!is Finished numberFormats = lines.next());
    
    value numCols = columns(numberFormats).iterator();
    assert (is String integerFormat = numCols.next());
    assert (is String floatFormat = numCols.next());
    assert (is String percentageFormat = numCols.next());
    assert (is String currencyFormat = numCols.next());
    
    assert (!is Finished blankLine1 = lines.next(), 
            blankLine1.empty);
    
    return Formats {
        shortDateFormat = shortDateFormat;
        mediumDateFormat = mediumDateFormat;
        longDateFormat = longDateFormat;
        shortTimeFormat = shortTimeFormat;
        mediumTimeFormat = mediumTimeFormat;
        longTimeFormat = longTimeFormat;
        integerFormat = integerFormat;
        floatFormat = floatFormat;
        percentageFormat = percentageFormat;
        currencyFormat = currencyFormat;
        ampm = [am,pm];
        monthNames = monthNames;
        monthAbbreviations = monthAbbreviations;
        weekdayNames = dayNames;
        weekdayAbbreviations = dayAbbreviations;
    };
}

"Splits a string into 'runs' of the same character."
{String*} runs(String text) 
        => object satisfies {String*} {
    iterator() 
            => object satisfies Iterator<String> {
        variable value i = 0;
        shared actual String|Finished next() {
            value start = i;
            if (exists ch = text[i++]) {
                while (exists next = text[i], 
                        next==ch) {
                    i++;
                }
                return text[start..i-1];
            }
            else {
                return finished;
            }
        }
    };
};