Added test for spreadsheet-for-component.jq

import module namespace response = "http://www.28msec.com/modules/http-response";
import module namespace test = "http://apps.28.io/test";

declare %an:nondeterministic function local:test-spreadsheet($expected as integer, $params as object) as item
{
    let $endpoint := "spreadsheet-for-component"
    let $request := test:invoke($endpoint, $params)
    let $actual as integer := count($request[2].TableSet[].TableCells.Facts[][].Value)
    let $status as integer := $request[1]
    return test:assert-eq($expected, $actual, $status, test:url($endpoint, $params))
};

declare %an:sequential function local:check($o as object) as object
{
    if (not(every $k in (keys($o) ! $o.$$) satisfies ($k instance of boolean and $k)))
    then {
            response:status-code(500);
            $o
    } else
            $o
};

local:check({
    cocacola: local:test-spreadsheet(1, {
        ticker:"ko"
    }),
    tickerrole: local:test-spreadsheet(60, {
        ticker:"ko",
        networkIdentifier:"http://www.thecocacolacompany.com/role/ConsolidatedStatementsOfIncome"
    }),
    tickerconcept: local:test-spreadsheet(76, {
        ticker:"ko",
        fiscalYear:"2013",
        concept:"us-gaap:Assets"
    }),
    tickerfyfprole: local:test-spreadsheet(70, {
        ticker:"ko",
        fiscalYear:"2012",
        fiscalPeriod:"Q1",
        networkIdentifier:"http://www.thecoca-colacompany.com/role/CondensedConsolidatedBalanceSheets"
    }),
    generic: local:test-spreadsheet(76, {
        profile-name: "generic",
        aid: "0000021344-14-000008",
        role:"http://www.thecocacolacompany.com/role/ConsolidatedBalanceSheets"
    }),
    merge: local:test-spreadsheet(153, {
        ticker: "ko",
        fiscalYear: [ "2012", "2013" ],
        fiscalPeriod: "FY",
        disclosure: "BalanceSheet",
        merge: "true"
    })
})

import module namespace response = "http://www.28msec.com/modules/http-response";
import module namespace test = "http://apps.28.io/test";

declare %an:nondeterministic function local:test-spreadsheet($expected as integer, $params as object) as item
{
    let $endpoint := "spreadsheet-for-component"
    let $request := test:invoke($endpoint, $params)
    let $actual as integer := count($request[2].TableSet[].TableCells.Facts[][].Value)
    let $status as integer := $request[1]
    return test:assert-eq($expected, $actual, $status, test:url($endpoint, $params))
};

declare %an:sequential function local:check($o as object) as object
{
    if (not(every $k in (keys($o) ! $o.$$) satisfies ($k instance of boolean and $k)))
    then {
            response:status-code(500);
            $o
    } else
            $o
};

local:check({
    cocacola: local:test-spreadsheet(1, {
        ticker:"ko"
    }),
    tickerrole: local:test-spreadsheet(60, {
        ticker:"ko",
        networkIdentifier:"http://www.thecocacolacompany.com/role/ConsolidatedStatementsOfIncome"
    }),
    tickerconcept: local:test-spreadsheet(76, {
        ticker:"ko",
        fiscalYear:"2013",
        concept:"us-gaap:Assets"
    }),
    tickerfyfprole: local:test-spreadsheet(70, {
        ticker:"ko",
        fiscalYear:"2012",
        fiscalPeriod:"Q1",
        networkIdentifier:"http://www.thecoca-colacompany.com/role/CondensedConsolidatedBalanceSheets"
    }),
    generic: local:test-spreadsheet(76, {
        profile-name: "generic",
        aid: "0000021344-14-000008",
        role:"http://www.thecocacolacompany.com/role/ConsolidatedBalanceSheets"
    })
})


Fixed test.

Fixed test.

import module namespace request = "http://www.28msec.com/modules/http-request";
import module namespace config = "http://apps.28.io/config";

<Entities>
  <Entity>
    <_id>http://info.edinet-fsa.go.jp E01225-000</_id>
    <Archives>
    {
      "http://" || request:server-name() || ":" || request:server-port() ||
        "/v1/_queries/public/api/filings.jq?_method=POST&token=" || $config:test-token || "&eid=http%3A%2F%2Finfo.edinet-fsa.go.jp%20E01225-000&format=&profile-name=japan&fiscalYear=ALL&fiscalPeriod=ALL"
    }
    </Archives>
    <Name>FSA</Name>
    <EDINETCode>E01225-000</EDINETCode>
    <SubmitterName>新日鐵住金株式会社</SubmitterName>
    <SubmitterNameAlphabetic>NIPPON STEEL & SUMITOMO METAL CORPORATION</SubmitterNameAlphabetic>
    <SubmitterNamePhonetic>シンニッテツスミキンカブシキガイシャ</SubmitterNamePhonetic>
    <SubmitterType>内国法人・組合</SubmitterType>
    <SIC>5401</SIC>
    <Province>千代田区丸の内二丁目６番１号</Province>
    <Industry>Iron & Steel</Industry>
    <CapitalStock>419524</CapitalStock>
    <AccountClosingDate> 3.31</AccountClosingDate>
    <IsListed>true</IsListed>
    <IsConsolidated>true</IsConsolidated>
    <Tags>NIKKEI</Tags>
  </Entity>
</Entities>

<Entities>
  <Entity>
    <_id>http://info.edinet-fsa.go.jp E01225-000</_id>
    <Archives>http://edinet-dev.28.io:80/v1/_queries/public/api/filings.jq?_method=POST&token=foobar&eid=http%3A%2F%2Finfo.edinet-fsa.go.jp%20E01225-000&format=xml&profile-name=japan&fiscalYear=ALL&fiscalPeriod=ALL</Archives>
    <Name>FSA</Name>
    <EDINETCode>E01225-000</EDINETCode>
    <SubmitterName>新日鐵住金株式会社</SubmitterName>
    <SubmitterNameAlphabetic>NIPPON STEEL & SUMITOMO METAL CORPORATION</SubmitterNameAlphabetic>
    <SubmitterNamePhonetic>シンニッテツスミキンカブシキガイシャ</SubmitterNamePhonetic>
    <SubmitterType>内国法人・組合</SubmitterType>
    <SIC>5401</SIC>
    <Province>千代田区丸の内二丁目６番１号</Province>
    <Industry>Iron & Steel</Industry>
    <CapitalStock>419524</CapitalStock>
    <AccountClosingDate> 3.31</AccountClosingDate>
    <IsListed>true</IsListed>
    <IsConsolidated>true</IsConsolidated>
    <Tags>NIKKEI</Tags>
  </Entity>
</Entities>


fix #158 filings order regression

fix #158 filings order regression

import module namespace facts = "http://xbrl.io/modules/bizql/facts";

import module namespace companies = "http://xbrl.io/modules/bizql/profiles/sec/companies";
import module namespace filings = "http://xbrl.io/modules/bizql/profiles/sec/filings";
import module namespace archives = "http://xbrl.io/modules/bizql/archives";
import module namespace fiscal = "http://xbrl.io/modules/bizql/profiles/sec/fiscal/core";

import module namespace request = "http://www.28msec.com/modules/http-request";
import module namespace response = "http://www.28msec.com/modules/http-response";
import module namespace csv = "http://zorba.io/modules/json-csv";
import module namespace session = "http://apps.28.io/session";

declare function local:to-xml($filings as object*) as node()*
{
    ( session:comment("xml", {
        NumArchives: count($filings),
        TotalNumArchives: session:num-archives(),
        TotalNumEntities: session:num-entities()
    }),
    <Filings>{
        for $f in $filings
        return
        <Filing>
            <CIK>{$f.CIK}</CIK>
            <EntityRegistrantName>{$f.EntityRegistrantName}</EntityRegistrantName>
            <FormType>{$f.FormType}</FormType>
            <FiscalYear>{$f.FiscalYear}</FiscalYear>
            <FiscalPeriod>{$f.FiscalPeriod}</FiscalPeriod>
            <Accepted>{$f.Accepted}</Accepted>
            <Generator>{$f.Generator}</Generator>
            <AccessionNumber>{$f.AccessionNumber}</AccessionNumber>
            <SECFilingPage>{$f.SECFilingPage}</SECFilingPage>
            <XBRLInstanceURL>{$f.XBRLInstanceURL}</XBRLInstanceURL>
            <Networks>{$f.Networks}</Networks>
            <ReportElements>{$f.ReportElements}</ReportElements>
            <Tables>{$f.Tables}</Tables>
            <Axis>{$f.Axis}</Axis>
            <Members>{$f.Members}</Members>
            <LineItems>{$f.LineItems}</LineItems>
            <Concepts>{$f.Concepts}</Concepts>
            <Abstracts>{$f.Abstracts}</Abstracts>
            <Footnotes>{$f.Footnotes}</Footnotes>
            <Facts>{$f.Facts}</Facts>
            <ExtensionFacts>{$f.ExtensionFacts}</ExtensionFacts>
            <ExtensionConcepts>{$f.ExtensionConcepts}</ExtensionConcepts>
            <ExtensionAbstracts>{$f.ExtensionAbstracts}</ExtensionAbstracts>
        </Filing>
    }</Filings>)
};

declare function local:to-csv($filings as object*) as string*
{
    csv:serialize($filings, { serialize-null-as : "" })
};

declare function local:summary($a as object) as object
{
    {
        CIK : $a.Entity,
        EntityRegistrantName :
            facts:facts-for-archives-and-concepts($a, "dei:EntityRegistrantName").Value,
        FormType : filings:document-types($a),
        FiscalYear : fiscal:fiscal-year($a),
        FiscalPeriod : fiscal:fiscal-period($a),
        Accepted : filings:acceptance-dateTimes($a),
        Generator : filings:generators($a),
        AccessionNumber: $a._id,
        SECFilingPage : $a.Profiles.SEC.SECFilingPage,
        XBRLInstanceURL : $a.InstanceURL,
        Networks : filings:num-networks($a),
        ReportElements : filings:num-report-elements($a),
        Tables : filings:num-tables($a),
        Axis : filings:num-axes($a),
        Members : filings:num-members($a),
        LineItems : $a.Statistics.Profiles.SEC.NumDistinctReportElementNamesEndingWithLineItems,
        Concepts : filings:num-concepts($a),
        Abstracts : filings:num-abstracts($a),
        Footnotes : filings:num-footnotes($a),
        Facts: filings:num-facts($a),
        ExtensionFacts:  filings:num-extension-facts($a),
        ExtensionConcepts : filings:num-extension-concepts($a),
        ExtensionAbstracts : filings:num-extension-abstracts($a)
    }
};

let $format      := lower-case(request:param-values("format")[1])
let $ciks        := distinct-values(companies:eid(request:param-values("cik")))
let $tags        := distinct-values(request:param-values("tag") ! upper-case($$))
let $tickers     := distinct-values(request:param-values("ticker"))
let $sics        := distinct-values(request:param-values("sic"))
let $fiscalPeriods := distinct-values(let $fp := request:param-values("fiscalPeriod", "FY")
                      return
                        if (($fp ! lower-case($$)) = "all")
                        then $fiscal:ALL_FISCAL_PERIODS
                        else if (($fp ! lower-case($$)) = "fy")
                        then distinct-values(("FY", "Q4", $fp))
                        else $fp)
let $aids     := request:param-values("aid")
let $ciks := ($ciks,
    companies:companies-for-tags($tags),
    companies:companies-for-tickers($tickers),
    companies:companies-for-SIC($sics))
let $fiscalYears := distinct-values(
                    for $y in request:param-values("fiscalYear", "LATEST")
                    return
                        if ($y eq "ALL")
                        then $fiscal:ALL_FISCAL_YEARS
                        else if ($y eq "LATEST")
                          then for $cik in $ciks
                               for $fp in $fiscalPeriods
                               return
                                   if ($fiscal:ALL_FISCAL_PERIODS eq $fp)
                                   then
                                       (fiscal:latest-reported-fiscal-period($cik).year) ! ($$ cast as integer)
                                   else
                                       (fiscal:latest-reported-fiscal-period($cik, $fp).year) ! ($$ cast as integer)
                        else if ($y castable as integer)
                        then $y cast as integer
                        else ()
                )
let $archives := (archives:archives($aids),
                    for $fp in $fiscalPeriods, $fy in $fiscalYears
                    return
                      fiscal:filings-for-entities-and-fiscal-periods-and-years($ciks, $fp, $fy))
let $entities := companies:companies($archives.Entities)
return
    if (session:only-dow30($entities) or session:valid())
    then {
        let $archives :=
                for $a in $archives
                group by $a._id (: duplicate elimination :)
                order by filings:acceptance-dateTimes($a[1]) descending
                return local:summary($a[1])
        return
            switch ($format)
            case "xml"  return {
                response:serialization-parameters({"omit-xml-declaration" : false, indent : true });
                local:to-xml($archives)
            }
            case "csv" case "text" return {
                response:content-type("text/csv");
                response:header("Content-Disposition", "attachment; filename=filings.csv");
                string-join(local:to-csv($archives), "")
            }
            case "excel" return {
                response:content-type("application/vnd.ms-excel");
                response:header("Content-Disposition", "attachment; filename=filings.csv");
                string-join(local:to-csv($archives))
            }
            default return {
                response:content-type("application/json");
                response:serialization-parameters({"indent" : true});
                {|
                    { "Archives" : [ $archives ] },
                    session:comment("json", {
                        NumArchives: count($archives),
                        TotalNumArchives: session:num-archives(),
                        TotalNumEntities: session:num-entities()
                    })
                |}
            }
    } else {
        response:status-code(401);
        session:error("accessing filings of an entity that is not in the DOW30", $format)
    }

import module namespace facts = "http://xbrl.io/modules/bizql/facts";

import module namespace companies = "http://xbrl.io/modules/bizql/profiles/sec/companies";
import module namespace filings = "http://xbrl.io/modules/bizql/profiles/sec/filings";
import module namespace archives = "http://xbrl.io/modules/bizql/archives";
import module namespace fiscal = "http://xbrl.io/modules/bizql/profiles/sec/fiscal/core";

import module namespace request = "http://www.28msec.com/modules/http-request";
import module namespace response = "http://www.28msec.com/modules/http-response";
import module namespace csv = "http://zorba.io/modules/json-csv";
import module namespace session = "http://apps.28.io/session";

declare function local:to-xml($filings as object*) as node()*
{
    ( session:comment("xml", {
        NumArchives: count($filings),
        TotalNumArchives: session:num-archives(),
        TotalNumEntities: session:num-entities()
    }),
    <Filings>{
        for $f in $filings
        return
        <Filing>
            <CIK>{$f.CIK}</CIK>
            <EntityRegistrantName>{$f.EntityRegistrantName}</EntityRegistrantName>
            <FormType>{$f.FormType}</FormType>
            <FiscalYear>{$f.FiscalYear}</FiscalYear>
            <FiscalPeriod>{$f.FiscalPeriod}</FiscalPeriod>
            <Accepted>{$f.Accepted}</Accepted>
            <Generator>{$f.Generator}</Generator>
            <AccessionNumber>{$f.AccessionNumber}</AccessionNumber>
            <SECFilingPage>{$f.SECFilingPage}</SECFilingPage>
            <XBRLInstanceURL>{$f.XBRLInstanceURL}</XBRLInstanceURL>
            <Networks>{$f.Networks}</Networks>
            <ReportElements>{$f.ReportElements}</ReportElements>
            <Tables>{$f.Tables}</Tables>
            <Axis>{$f.Axis}</Axis>
            <Members>{$f.Members}</Members>
            <LineItems>{$f.LineItems}</LineItems>
            <Concepts>{$f.Concepts}</Concepts>
            <Abstracts>{$f.Abstracts}</Abstracts>
            <Footnotes>{$f.Footnotes}</Footnotes>
            <Facts>{$f.Facts}</Facts>
            <ExtensionFacts>{$f.ExtensionFacts}</ExtensionFacts>
            <ExtensionConcepts>{$f.ExtensionConcepts}</ExtensionConcepts>
            <ExtensionAbstracts>{$f.ExtensionAbstracts}</ExtensionAbstracts>
        </Filing>
    }</Filings>)
};

declare function local:to-csv($filings as object*) as string*
{
    csv:serialize($filings, { serialize-null-as : "" })
};

declare function local:summary($a as object) as object
{
    {
        CIK : $a.Entity,
        EntityRegistrantName :
            facts:facts-for-archives-and-concepts($a, "dei:EntityRegistrantName").Value,
        FormType : filings:document-types($a),
        FiscalYear : fiscal:fiscal-year($a),
        FiscalPeriod : fiscal:fiscal-period($a),
        Accepted : filings:acceptance-dateTimes($a),
        Generator : filings:generators($a),
        AccessionNumber: $a._id,
        SECFilingPage : $a.Profiles.SEC.SECFilingPage,
        XBRLInstanceURL : $a.InstanceURL,
        Networks : filings:num-networks($a),
        ReportElements : filings:num-report-elements($a),
        Tables : filings:num-tables($a),
        Axis : filings:num-axes($a),
        Members : filings:num-members($a),
        LineItems : $a.Statistics.Profiles.SEC.NumDistinctReportElementNamesEndingWithLineItems,
        Concepts : filings:num-concepts($a),
        Abstracts : filings:num-abstracts($a),
        Footnotes : filings:num-footnotes($a),
        Facts: filings:num-facts($a),
        ExtensionFacts:  filings:num-extension-facts($a),
        ExtensionConcepts : filings:num-extension-concepts($a),
        ExtensionAbstracts : filings:num-extension-abstracts($a)
    }
};

let $format      := lower-case(request:param-values("format")[1])
let $ciks        := distinct-values(companies:eid(request:param-values("cik")))
let $tags        := distinct-values(request:param-values("tag") ! upper-case($$))
let $tickers     := distinct-values(request:param-values("ticker"))
let $sics        := distinct-values(request:param-values("sic"))
let $fiscalPeriods := distinct-values(let $fp := request:param-values("fiscalPeriod", "FY")
                      return
                        if (($fp ! lower-case($$)) = "all")
                        then $fiscal:ALL_FISCAL_PERIODS
                        else if (($fp ! lower-case($$)) = "fy")
                        then distinct-values(("FY", "Q4", $fp))
                        else $fp)
let $aids     := request:param-values("aid")
let $ciks := ($ciks,
    companies:companies-for-tags($tags),
    companies:companies-for-tickers($tickers),
    companies:companies-for-SIC($sics))
let $fiscalYears := distinct-values(
                    for $y in request:param-values("fiscalYear", "LATEST")
                    return
                        if ($y eq "ALL")
                        then $fiscal:ALL_FISCAL_YEARS
                        else if ($y eq "LATEST")
                          then for $cik in $ciks
                               for $fp in $fiscalPeriods
                               return
                                   if ($fiscal:ALL_FISCAL_PERIODS eq $fp)
                                   then
                                       (fiscal:latest-reported-fiscal-period($cik).year) ! ($$ cast as integer)
                                   else
                                       (fiscal:latest-reported-fiscal-period($cik, $fp).year) ! ($$ cast as integer)
                        else if ($y castable as integer)
                        then $y cast as integer
                        else ()
                )
let $archives := (archives:archives($aids),
                    for $fp in $fiscalPeriods, $fy in $fiscalYears
                    return
                      fiscal:filings-for-entities-and-fiscal-periods-and-years($ciks, $fp, $fy))
let $entities := companies:companies($archives.Entities)
return
    if (session:only-dow30($entities) or session:valid())
    then {
        let $archives :=
                for $a in $archives
                order by filings:acceptance-dateTimes($a) descending
                group by $a._id
                return local:summary($a[1])
        return
            switch ($format)
            case "xml"  return {
                response:serialization-parameters({"omit-xml-declaration" : false, indent : true });
                local:to-xml($archives)
            }
            case "csv" case "text" return {
                response:content-type("text/csv");
                response:header("Content-Disposition", "attachment; filename=filings.csv");
                string-join(local:to-csv($archives), "")
            }
            case "excel" return {
                response:content-type("application/vnd.ms-excel");
                response:header("Content-Disposition", "attachment; filename=filings.csv");
                string-join(local:to-csv($archives))
            }
            default return {
                response:content-type("application/json");
                response:serialization-parameters({"indent" : true});
                {|
                    { "Archives" : [ $archives ] },
                    session:comment("json", {
                        NumArchives: count($archives),
                        TotalNumArchives: session:num-archives(),
                        TotalNumEntities: session:num-entities()
                    })
                |}
            }
    } else {
        response:status-code(401);
        session:error("accessing filings of an entity that is not in the DOW30", $format)
    }


Splitting global constraints.

Splitting global constraints.

jsoniq version "1.0";

module namespace layout = "http://28.io/modules/xbrl/layout";

import module namespace facts = "http://28.io/modules/xbrl/facts";
import module namespace hypercubes = "http://28.io/modules/xbrl/hypercubes";

(:
    TODO: roll-up layout nodes for open structural nodes if desired
:)

(:
    Converts a structural breakdown to a layout header group.
:)
declare %private function layout:breakdown-to-group(
    $breakdown as object,
    $actual-aspect-space as object) as object
{
    let $labels := $breakdown.BreakdownLabels
    let $trees := $breakdown.BreakdownTrees[]
    let $header-rows-for-each-tree :=
        $trees ! layout:structural-node-to-header-rows($$, $actual-aspect-space)
    let $concatenated-header-rows :=
        layout:concatenate-header-rows($header-rows-for-each-tree)
    return {
        GroupLabels: $labels,
        GroupCells: $concatenated-header-rows
    }
};

(:
    Converts a structural node to a header row, and recursively
    converts children as well to bottom header rows.
:)
declare %private function layout:structural-node-to-header-rows(
    $structural-node as object,
    $actual-aspect-space as object) as array
{
    let $bottom-rows :=
        if (exists($structural-node.Children))
        then layout:concatenate-header-rows(
            $structural-node.Children[] ! layout:structural-node-to-header-rows($$, $actual-aspect-space)
        )
        else ()
    let $span as integer :=
        if(exists($bottom-rows))
        then sum(($bottom-rows[[1]])[].CellSpan)
        else 1
    let $open-header-rows :=
        layout:concatenate-header-rows(
            let $constrained-aspect as string := $structural-node.Aspect
            return switch($constrained-aspect)
            case "xbrl:Period" return
                for $actual-value in $actual-aspect-space.($constrained-aspect)[]
                let $tokens := tokenize($actual-value, "/")
                order by $actual-value descending
                return [
                    [ {|
                        {
                            CellLabels: [ $structural-node.Labels[], $actual-value ],
                            CellConstraints: {
                                "": {$constrained-aspect : $actual-value},
                                "table.periodStart" : {
                                    $constrained-aspect :
                                        if(count($tokens) eq 2)
                                        then string(date($tokens[1]) - dayTimeDuration("P1D"))
                                        else ""
                                },
                                "table.periodEnd" : {
                                    $constrained-aspect :
                                        if(count($tokens) eq 2)
                                        then $tokens[2]
                                        else ""
                                }
                            },
                            TagSelectors: [ $structural-node.TagSelectors[] ],
                            CellSpan: $span,
                            RollUp: false
                        },
                        { Id: $structural-node.Id }[exists($structural-node.Id)]
                    |} ],
                    $bottom-rows[]
                ]
            default return
                for $actual-value in $actual-aspect-space.($constrained-aspect)[]
                order by $actual-value
                return [
                    [ {|
                        {
                            CellLabels: [ $structural-node.Labels[], $actual-value ],
                            CellConstraints: {
                                "": {$constrained-aspect : $actual-value}
                            },
                            TagSelectors: [ $structural-node.TagSelectors[] ],
                            CellSpan: $span,
                            RollUp: false
                        },
                        { Id: $structural-node.Id }[exists($structural-node.Id)]
                    |} ],
                    $bottom-rows[]
                ]

        )
    let $closed-header-rows := [
        [ {|
            {
                CellLabels: [ $structural-node.Labels[], values($structural-node.ConstraintSets."") ],
                CellConstraints: ($structural-node.ConstraintSets, {"" : {}})[1],
                TagSelectors: [ $structural-node.TagSelectors[] ],
                CellSpan: $span,
                RollUp: boolean($structural-node.RollUp),
                IsRollUp: ($structural-node.IsTotal, false)[1],
                IsNegated: ($structural-node.IsNegated, false)[1]
            },
           { Id: $structural-node.Id }[exists($structural-node.Id)]
        |} ],
        $bottom-rows[]
    ]
    return
        if($structural-node.Open)
        then $open-header-rows
        else $closed-header-rows
};

(:
    Concatenates groups of header rows to a single group of header rows.
:)
declare function layout:concatenate-header-rows($header-rows-groups as array*) as array
{
    [
        for $row in 1 to size($header-rows-groups[1])
        return [
            for $row-group in $header-rows-groups ! ($$[[$row]])
            return $row-group[]
        ]
    ]
};

(:
    Builds the hypercube to query the facts for layouting.
    It's based on the supplied hypercube if present (intersecting its domains
    with the domains of the table to reduce the result size).
    If no hypercube is given, it will take the table domain.
:)
declare function layout:build-hypercube(
    $structural-model as object,
    $hypercube as object?) as object
{
    let $slice-constraints := accumulate(values(descendant-objects($structural-model).ConstraintSets))
    let $global-constraints := $structural-model.GlobalConstraintSet
    let $open-dimensions := descendant-objects($structural-model).Aspect
    let $table-domain := {|
        for $aspect in distinct-values(keys(($slice-constraints, $global-constraints)))
        return {
            $aspect: [ flatten($slice-constraints.$aspect), flatten($global-constraints.$aspect) ]
        }
    |}
    return hypercubes:user-defined-hypercube(
            {|
                for $dimension in distinct-values((keys(($hypercube.Aspects, $table-domain)), $open-dimensions))
                let $dimension-type as string? := $dimension.Type
                let $hypercube-members as atomic* := (
                    descendant-objects($hypercube.Aspects.$dimension.Domains).Name,
                    $hypercube.Aspects.$dimension.DomainRestriction.Enumeration[])
                let $hypercube-default := ($hypercube.Aspects.$dimension.Default,
                                           $structural-model.DimensionDefaults.$dimension)[1]

                let $table-domain-members := $table-domain.$dimension[]

                let $actual-members :=
                    switch(true)
                    case exists($hypercube-members) and exists($table-domain-members)
                        return $hypercube-members[$$ = $table-domain-members]
                    case exists($hypercube-members)
                        return $hypercube-members
                    case exists($table-domain-members)
                        return $table-domain-members
                    default return ()

                let $inferred-type as string? :=
                    typeswitch($actual-members[not ($$ instance of null)])
                    case string* return
                            if(contains(($actual-members)[not ($$ instance of null)][1], ":"))
                            then ()
                            else "string"
                    case int* return "int"
                    case integer* return "integer"
                    case boolean* return "boolean"
                    default return "atomic"
                let $actual-type as string? := ($dimension-type, $inferred-type)[1]

                return {
                    $dimension : {|
                        { Type : $actual-type }[exists($actual-type) and exists($actual-members)],

                        if(exists($actual-members))
                        then {
                            Domain: [ distinct-values(($actual-members, $hypercube-default)) ]
                        }
                        else (),

                        if(exists($hypercube-default))
                        then {
                            Default: $hypercube-default
                        }
                        else ()
                    |}
                }
            |}
        )
};

(:
    Filters the facts that match the supplied aspect
    constraints. Filters for default if a participating
    aspect is not present in the aspect constraints.
:)
declare function layout:filter-aspects(
    $facts as object*,
    $aspect-constraints as object,
    $participating-aspects as string*,
    $defaults as object) as object*
{
    for $fact in $facts
    where layout:matches-aspects($fact, $aspect-constraints, $participating-aspects, $defaults)
    return $fact
};

(:
    Checks whether a fact matches the aspect constraints.
:)
declare function layout:matches-aspects(
    $fact as object,
    $aspect-constraints as object,
    $participating-aspects as string*,
    $defaults as object?) as boolean
{
    (
        every $aspect in $participating-aspects
        satisfies
            let $default-value := $defaults.$aspect
            let $allowed-value := $aspect-constraints.$aspect
            return (
                exists($allowed-value)
                or
                $fact.Aspects.$aspect eq $default-value
            )
    )
    and
    (
        every $aspect in keys($aspect-constraints)
        satisfies
            let $allowed-value := $aspect-constraints.$aspect
            return $fact.Aspects.$aspect eq $allowed-value
    )
};

(:
    Returns the slices implied by the headers with the given tag selectors.
:)
declare function layout:slices($table-headers as array, $tag-selectors as string*) as object*
{
    let $first-row-of-headers := $table-headers[[1]]
    let $bottom-rows-of-headers := [($table-headers[])[position() gt 1]]
    let $first-slices :=
        for $first-row-cell in $first-row-of-headers[]
        let $span := ($first-row-cell.CellSpan, 1)[1]
        let $tag-matches := $tag-selectors[$$ = keys($first-row-cell.CellConstraints)]
        return
            switch(count($tag-matches))
            case 0 return (1 to $span) ! ($first-row-cell.CellConstraints."", {})[1]
            case 1 return (1 to $span) ! ($first-row-cell.CellConstraints.$tag-matches, {})[1]
            default return error(
                QName("layout:MORE-THAN-ONE-MATCHING-TAG"),
                "Several tag selectors apply to a cell: " || string-join($tag-matches, " ")
            )
    return
    if(size($table-headers) le 1)
    then $first-slices
    else
        let $bottom-slices := layout:slices($bottom-rows-of-headers, $tag-selectors)
        for $i in 1 to count($first-slices)
        return
          facts:merge-objects($bottom-slices[$i], $first-slices[$i], true)
};

(:
    Returns the sequence of tag selector arrays implied by the headers.
:)
declare function layout:tag-selectors($table-headers as array) as array*
{
    let $first-row-of-headers := $table-headers[[1]]
    let $bottom-rows-of-headers := [($table-headers[])[position() gt 1]]
    let $first-tags :=
        for $first-row-cell in $first-row-of-headers[]
        let $span := ($first-row-cell.CellSpan, 1)[1]
        return (1 to $span) ! [ $first-row-cell.TagSelectors[] ]
    return
    if(size($table-headers) le 1)
    then $first-tags
    else
        let $bottom-tags := layout:tag-selectors($bottom-rows-of-headers)
        for $i in 1 to count($first-tags)
        return [($first-tags[$i])[], ($bottom-tags[$i])[]]
};

(:
    Merges the supplied header row groups by projecting them right-to-left.
:)
declare function layout:project($header-rows-groups as object*) as object*
{
    switch(count($header-rows-groups))
    case 0 return {
        GroupLabels: [],
        GroupCells: [
            [
                {
                    CellLabels: [],
                    CellConstraints: { "": {} },
                    CellSpan: 1
                }
                ]
        ]
    }
    case 1 return $header-rows-groups
    default return
        let $first := head($header-rows-groups)
        let $first-size := layout:header-rows-width($first)
        let $bottom := layout:project(tail($header-rows-groups))
        let $bottom-size as integer := layout:header-rows-width(head($bottom))
        return (
            layout:stretch-header-rows($first, $bottom-size),
            $bottom ! layout:duplicate-header-rows($$, $first-size)
        )
};

(:
    Returns the width of a header row group.
:)
declare function layout:header-rows-width($header-rows-group as object) as integer
{
    sum(($header-rows-group.GroupCells[[1]])[].CellSpan)
};

(:
    Stretches a header row group by the desired factor.
:)
declare function layout:stretch-header-rows($header-rows-group as object, $factor as integer) as object
{
    copy $g := $header-rows-group
    modify replace value of json $g.GroupCells with
    [
        for $row as array in $g.GroupCells[]
        return [
            for $cell in $row[]
            return copy $c := $cell
                   modify replace value of json $c.CellSpan with $c.CellSpan * $factor
                   return $c
        ]
    ]
    return $g
};

(:
    Duplicate the supplied rows the number of times specified in factor.
:)
declare function layout:duplicate-header-rows($header-rows-group as object, $factor as integer) as object
{
    copy $g := $header-rows-group
    modify replace value of json $g.GroupCells with
    [
        for $row as array in $g.GroupCells[]
        return [
            (1 to $factor) ! $row[]
        ]
    ]
    return $g
};

(:
   Returns the numbers of empty rows and columns
:)
declare function layout:empty-rows-and-columns($cells as array) as object
{
    {
        Rows: [
            for $i in 1 to size($cells)
            let $row := ($cells[[$i]])[]
            where every $cell in $row satisfies empty($cell.Value)
            return $i
        ],
        Columns: [
            for $j in 1 to size($cells[[1]])
            let $column := $cells[][[$j]]
            where every $cell in $column satisfies empty($cell.Value)
            return $j
        ]
    }
};

(:
    Eliminates empty rows/columns in the table headers.
:)
declare function layout:eliminate-table-headers($table-headers as object, $empty-rows-and-columns as object) as object
{
    copy $th := $table-headers
    modify let $x-headers as array* := $th.x[].GroupCells
           let $x-eliminated-headers as array* := layout:eliminate($x-headers, $empty-rows-and-columns.Columns[])
           let $y-headers as array* := $th.y[].GroupCells
           let $y-eliminated-headers as array* := layout:eliminate($y-headers, $empty-rows-and-columns.Rows[])
           return (
               for $i in 1 to size($th.x)
               return replace value of json $th.x[[$i]].GroupCells with $x-eliminated-headers[$i],
               for $j in 1 to size($th.y)
               return replace value of json $th.y[[$j]].GroupCells with $y-eliminated-headers[$j]
           )
    return $th
};

(:
    Eliminates empty rows/columns in the cells.
:)
declare function layout:eliminate-cells($cells as array, $empty-rows-and-columns as object) as array
{
    [
        for $row at $i in $cells[]
        where not $i = $empty-rows-and-columns.Rows[]
        return [
            for $cell as item at $j in $row[]
            where not $j = $empty-rows-and-columns.Columns[]
            return $cell
        ]
    ]
};

(:
    Eliminates empty rows/columns in the table header's group cells.
:)
declare function layout:eliminate($header-groups as array*, $empty-columns as integer*) as array*
{
    let $first-group as array? := head($header-groups)
    let $other-groups as array* := tail($header-groups)
    return if(empty($first-group))
           then ()
           else
               let $new-first-group as array := [
                  for $row as array in $first-group[]
                  return [
                       for $i in 1 to size($row)
                       let $lower-bound as integer :=  sum((0, $row[][position() lt $i].CellSpan))
                       let $upper-bound as integer :=  sum($row[][position() le $i].CellSpan)
                       let $cell as object := $row[[$i]]
                       let $matching-empty-columns := $empty-columns[$lower-bound lt $$ and $$ le $upper-bound]
                       let $number := count($matching-empty-columns)
                       return switch(true)
                              case $cell.CellSpan eq $number
                              return ()
                              case $cell.CellSpan gt $number
                              return copy $c := $cell modify replace value of json $c.CellSpan with $c.CellSpan - $number return $c
                              default return error(QName("layout:ELIMINATION-ERROR"), ($number || " > " || $cell.CellSpan))
                    ]
               ]
               return (
                   $new-first-group,
                   layout:eliminate($other-groups, $empty-columns)
               )
};

(:~
 : Converts a structural model to a layout model.
 :
 : @param $structural-model a structural model.
 : @options - hypercube: a hypercube (null for using table domain, default: dimensionless hypercube)
 :          - filter
 :
 : @error layout:MORE-THAN-ONE-MATCHING-TAG in case of a tag selector conflict.
 : @error layout:ELIMINATION_ERROR an error occurred during elimination of empty rows/columns.
 :
 : @return a layout model.
 :)
declare function layout:layout(
    $structural-model as object,
    $options as object?
) as object
{
    let $elimination as boolean := ($options.Eliminate, false)[1]
    let $original-hypercube :=
        if($options.Hypercube instance of null)
        then ()
        else ($options.Hypercube, hypercubes:dimensionless-hypercube())[1]
    let $hypercube := layout:build-hypercube(
        $structural-model,
        $original-hypercube
    )
    let $facts := facts:facts-for({|
        {Hypercube: $hypercube},
        trim($options, "Hypercube")
    |})
    let $actual-aspect-space :=
    {|
        for $aspect in distinct-values(keys($facts.Aspects))
        return { $aspect : [ distinct-values($facts.Aspects.$aspect) ] }
    |}
    let $defaults :=
    {|
        for $aspect in keys($hypercube.Aspects)
        let $default := $hypercube.Aspects.$aspect.Default
        where exists($default)
        return { $aspect : $default }
    |}
    return {
        ModelKind: "LayoutModel",
        ComponentAndHypercubeInformation: {
            Component: $structural-model.Component,
            Hypercube: {
                Name: $hypercube.Name[1],
                Label: $hypercube.Label[1]
            }
        },
        TableSetLabels: $structural-model.TableSetLabels,
        TableSet: [
            for $table in $structural-model.TableSet[]
            let $table-headers as object := {|
                for $axis in keys($table.Breakdowns)
                return {
                    $axis: [
                        layout:project(
                            for $breakdown in $table.Breakdowns.$axis[]
                            return layout:breakdown-to-group($breakdown, $actual-aspect-space)
                        )
                    ]
                }
            |}
            let $x-tag-selectors := layout:tag-selectors([$table-headers.x[].GroupCells[]])
            let $y-tag-selectors := layout:tag-selectors([$table-headers.y[].GroupCells[]])
            let $default-x-slices := layout:slices([$table-headers.x[].GroupCells[]], ())
            let $default-y-slices := layout:slices([$table-headers.y[].GroupCells[]], ())
            let $period-start-x-slices := layout:slices([$table-headers.x[].GroupCells[]], "table.periodStart")
            let $period-start-y-slices := layout:slices([$table-headers.y[].GroupCells[]], "table.periodStart")
            let $period-end-x-slices := layout:slices([$table-headers.x[].GroupCells[]], "table.periodEnd")
            let $period-end-y-slices := layout:slices([$table-headers.y[].GroupCells[]], "table.periodEnd")
            let $cells := [
                for $y-tag-selector at $i in $y-tag-selectors
                return [
                    for $x-tag-selector at $j in $x-tag-selectors
                    let $selectors as string* := ($x-tag-selector[], $y-tag-selector[])
                    let $x-slices as object* :=
                        switch(true)
                        case empty($selectors) return $default-x-slices
                        case deep-equal($selectors, "table.periodStart") return $period-start-x-slices
                        case deep-equal($selectors, "table.periodEnd") return $period-end-x-slices
                        default return layout:slices([$table-headers.x[].GroupCells[]], $selectors)
                    let $participating-x := distinct-values(keys($x-slices))
                    let $y-slices as object* :=
                        switch(true)
                        case empty($selectors) return $default-y-slices
                        case deep-equal($selectors, "table.periodStart") return $period-start-y-slices
                        case deep-equal($selectors, "table.periodEnd") return $period-end-y-slices
                        default return layout:slices([$table-headers.y[].GroupCells[]], $selectors)
                    let $participating-y := distinct-values(keys($y-slices))
                    let $row-facts := layout:filter-aspects(
                        $facts,
                        $y-slices[$i],
                        $participating-y,
                        $defaults)
                    let $cell-facts := layout:filter-aspects(
                        $row-facts,
                        $x-slices[$j],
                        $participating-x,
                        $defaults)
                    return typeswitch($cell-facts)
                           case object return $cell-facts
                           case () return {
                               Aspects: facts:merge-objects(
                                   facts:merge-objects($x-slices[$j], $y-slices[$i], false),
                                   $structural-model.GlobalConstraintSet,
                                   false
                               )
                           }
                           case object+ return [ $cell-facts ]
                           default return {
                               Aspects: facts:merge-objects(
                                   facts:merge-objects($x-slices[$j], $y-slices[$i], false),
                                   $structural-model.GlobalConstraintSet,
                                   false
                               )
                           }
                ]
            ]
            let $empty-rows-and-columns := layout:empty-rows-and-columns($cells)
            let $table-headers := if($elimination)
                                  then layout:eliminate-table-headers($table-headers, $empty-rows-and-columns)
                                  else $table-headers
            let $cells := if($elimination)
                          then layout:eliminate-cells($cells, $empty-rows-and-columns)
                          else $cells
            return
            {
                TableHeaders: $table-headers,
                TableCells: {
                    AxisOrder: [ "y", "x" ],
                    Facts: $cells
                }
            }
        ],
        GlobalConstraints: [
          let $filters := $structural-model.GlobalConstraintSet
          for $key in keys($filters)
          return project($filters, $key)
        ],
        GlobalConstraintLabels: $structural-model.GlobalConstraintLabels (:),
        DebugInfo: {
            Hypercube: $hypercube,
            OriginalHypercube: $original-hypercube
        }:)
    }
};

jsoniq version "1.0";

module namespace layout = "http://28.io/modules/xbrl/layout";

import module namespace facts = "http://28.io/modules/xbrl/facts";
import module namespace hypercubes = "http://28.io/modules/xbrl/hypercubes";

(:
    TODO: roll-up layout nodes for open structural nodes if desired
:)

(:
    Converts a structural breakdown to a layout header group.
:)
declare %private function layout:breakdown-to-group(
    $breakdown as object,
    $actual-aspect-space as object) as object
{
    let $labels := $breakdown.BreakdownLabels
    let $trees := $breakdown.BreakdownTrees[]
    let $header-rows-for-each-tree :=
        $trees ! layout:structural-node-to-header-rows($$, $actual-aspect-space)
    let $concatenated-header-rows :=
        layout:concatenate-header-rows($header-rows-for-each-tree)
    return {
        GroupLabels: $labels,
        GroupCells: $concatenated-header-rows
    }
};

(:
    Converts a structural node to a header row, and recursively
    converts children as well to bottom header rows.
:)
declare %private function layout:structural-node-to-header-rows(
    $structural-node as object,
    $actual-aspect-space as object) as array
{
    let $bottom-rows :=
        if (exists($structural-node.Children))
        then layout:concatenate-header-rows(
            $structural-node.Children[] ! layout:structural-node-to-header-rows($$, $actual-aspect-space)
        )
        else ()
    let $span as integer :=
        if(exists($bottom-rows))
        then sum(($bottom-rows[[1]])[].CellSpan)
        else 1
    let $open-header-rows :=
        layout:concatenate-header-rows(
            let $constrained-aspect as string := $structural-node.Aspect
            return switch($constrained-aspect)
            case "xbrl:Period" return
                for $actual-value in $actual-aspect-space.($constrained-aspect)[]
                let $tokens := tokenize($actual-value, "/")
                order by $actual-value descending
                return [
                    [ {|
                        {
                            CellLabels: [ $structural-node.Labels[], $actual-value ],
                            CellConstraints: {
                                "": {$constrained-aspect : $actual-value},
                                "table.periodStart" : {
                                    $constrained-aspect :
                                        if(count($tokens) eq 2)
                                        then string(date($tokens[1]) - dayTimeDuration("P1D"))
                                        else ""
                                },
                                "table.periodEnd" : {
                                    $constrained-aspect :
                                        if(count($tokens) eq 2)
                                        then $tokens[2]
                                        else ""
                                }
                            },
                            TagSelectors: [ $structural-node.TagSelectors[] ],
                            CellSpan: $span,
                            RollUp: false
                        },
                        { Id: $structural-node.Id }[exists($structural-node.Id)]
                    |} ],
                    $bottom-rows[]
                ]
            default return
                for $actual-value in $actual-aspect-space.($constrained-aspect)[]
                order by $actual-value
                return [
                    [ {|
                        {
                            CellLabels: [ $structural-node.Labels[], $actual-value ],
                            CellConstraints: {
                                "": {$constrained-aspect : $actual-value}
                            },
                            TagSelectors: [ $structural-node.TagSelectors[] ],
                            CellSpan: $span,
                            RollUp: false
                        },
                        { Id: $structural-node.Id }[exists($structural-node.Id)]
                    |} ],
                    $bottom-rows[]
                ]

        )
    let $closed-header-rows := [
        [ {|
            {
                CellLabels: [ $structural-node.Labels[], values($structural-node.ConstraintSets."") ],
                CellConstraints: ($structural-node.ConstraintSets, {"" : {}})[1],
                TagSelectors: [ $structural-node.TagSelectors[] ],
                CellSpan: $span,
                RollUp: boolean($structural-node.RollUp),
                IsRollUp: ($structural-node.IsTotal, false)[1],
                IsNegated: ($structural-node.IsNegated, false)[1]
            },
           { Id: $structural-node.Id }[exists($structural-node.Id)]
        |} ],
        $bottom-rows[]
    ]
    return
        if($structural-node.Open)
        then $open-header-rows
        else $closed-header-rows
};

(:
    Concatenates groups of header rows to a single group of header rows.
:)
declare function layout:concatenate-header-rows($header-rows-groups as array*) as array
{
    [
        for $row in 1 to size($header-rows-groups[1])
        return [
            for $row-group in $header-rows-groups ! ($$[[$row]])
            return $row-group[]
        ]
    ]
};

(:
    Builds the hypercube to query the facts for layouting.
    It's based on the supplied hypercube if present (intersecting its domains
    with the domains of the table to reduce the result size).
    If no hypercube is given, it will take the table domain.
:)
declare function layout:build-hypercube(
    $structural-model as object,
    $hypercube as object?) as object
{
    let $slice-constraints := accumulate(values(descendant-objects($structural-model).ConstraintSets))
    let $global-constraints := $structural-model.GlobalConstraintSet
    let $open-dimensions := descendant-objects($structural-model).Aspect
    let $table-domain := {|
        for $aspect in distinct-values(keys(($slice-constraints, $global-constraints)))
        return {
            $aspect: [ flatten($slice-constraints.$aspect), flatten($global-constraints.$aspect) ]
        }
    |}
    return hypercubes:user-defined-hypercube(
            {|
                for $dimension in distinct-values((keys(($hypercube.Aspects, $table-domain)), $open-dimensions))
                let $dimension-type as string? := $dimension.Type
                let $hypercube-members as atomic* := (
                    descendant-objects($hypercube.Aspects.$dimension.Domains).Name,
                    $hypercube.Aspects.$dimension.DomainRestriction.Enumeration[])
                let $hypercube-default := ($hypercube.Aspects.$dimension.Default,
                                           $structural-model.DimensionDefaults.$dimension)[1]

                let $table-domain-members := $table-domain.$dimension[]

                let $actual-members :=
                    switch(true)
                    case exists($hypercube-members) and exists($table-domain-members)
                        return $hypercube-members[$$ = $table-domain-members]
                    case exists($hypercube-members)
                        return $hypercube-members
                    case exists($table-domain-members)
                        return $table-domain-members
                    default return ()

                let $inferred-type as string? :=
                    typeswitch($actual-members[not ($$ instance of null)])
                    case string* return
                            if(contains(($actual-members)[not ($$ instance of null)][1], ":"))
                            then ()
                            else "string"
                    case int* return "int"
                    case integer* return "integer"
                    case boolean* return "boolean"
                    default return "atomic"
                let $actual-type as string? := ($dimension-type, $inferred-type)[1]

                return {
                    $dimension : {|
                        { Type : $actual-type }[exists($actual-type) and exists($actual-members)],

                        if(exists($actual-members))
                        then {
                            Domain: [ distinct-values(($actual-members, $hypercube-default)) ]
                        }
                        else (),

                        if(exists($hypercube-default))
                        then {
                            Default: $hypercube-default
                        }
                        else ()
                    |}
                }
            |}
        )
};

(:
    Filters the facts that match the supplied aspect
    constraints. Filters for default if a participating
    aspect is not present in the aspect constraints.
:)
declare function layout:filter-aspects(
    $facts as object*,
    $aspect-constraints as object,
    $participating-aspects as string*,
    $defaults as object) as object*
{
    for $fact in $facts
    where layout:matches-aspects($fact, $aspect-constraints, $participating-aspects, $defaults)
    return $fact
};

(:
    Checks whether a fact matches the aspect constraints.
:)
declare function layout:matches-aspects(
    $fact as object,
    $aspect-constraints as object,
    $participating-aspects as string*,
    $defaults as object?) as boolean
{
    (
        every $aspect in $participating-aspects
        satisfies
            let $default-value := $defaults.$aspect
            let $allowed-value := $aspect-constraints.$aspect
            return (
                exists($allowed-value)
                or
                $fact.Aspects.$aspect eq $default-value
            )
    )
    and
    (
        every $aspect in keys($aspect-constraints)
        satisfies
            let $allowed-value := $aspect-constraints.$aspect
            return $fact.Aspects.$aspect eq $allowed-value
    )
};

(:
    Returns the slices implied by the headers with the given tag selectors.
:)
declare function layout:slices($table-headers as array, $tag-selectors as string*) as object*
{
    let $first-row-of-headers := $table-headers[[1]]
    let $bottom-rows-of-headers := [($table-headers[])[position() gt 1]]
    let $first-slices :=
        for $first-row-cell in $first-row-of-headers[]
        let $span := ($first-row-cell.CellSpan, 1)[1]
        let $tag-matches := $tag-selectors[$$ = keys($first-row-cell.CellConstraints)]
        return
            switch(count($tag-matches))
            case 0 return (1 to $span) ! ($first-row-cell.CellConstraints."", {})[1]
            case 1 return (1 to $span) ! ($first-row-cell.CellConstraints.$tag-matches, {})[1]
            default return error(
                QName("layout:MORE-THAN-ONE-MATCHING-TAG"),
                "Several tag selectors apply to a cell: " || string-join($tag-matches, " ")
            )
    return
    if(size($table-headers) le 1)
    then $first-slices
    else
        let $bottom-slices := layout:slices($bottom-rows-of-headers, $tag-selectors)
        for $i in 1 to count($first-slices)
        return
          facts:merge-objects($bottom-slices[$i], $first-slices[$i], true)
};

(:
    Returns the sequence of tag selector arrays implied by the headers.
:)
declare function layout:tag-selectors($table-headers as array) as array*
{
    let $first-row-of-headers := $table-headers[[1]]
    let $bottom-rows-of-headers := [($table-headers[])[position() gt 1]]
    let $first-tags :=
        for $first-row-cell in $first-row-of-headers[]
        let $span := ($first-row-cell.CellSpan, 1)[1]
        return (1 to $span) ! [ $first-row-cell.TagSelectors[] ]
    return
    if(size($table-headers) le 1)
    then $first-tags
    else
        let $bottom-tags := layout:tag-selectors($bottom-rows-of-headers)
        for $i in 1 to count($first-tags)
        return [($first-tags[$i])[], ($bottom-tags[$i])[]]
};

(:
    Merges the supplied header row groups by projecting them right-to-left.
:)
declare function layout:project($header-rows-groups as object*) as object*
{
    switch(count($header-rows-groups))
    case 0 return {
        GroupLabels: [],
        GroupCells: [
            [
                {
                    CellLabels: [],
                    CellConstraints: { "": {} },
                    CellSpan: 1
                }
                ]
        ]
    }
    case 1 return $header-rows-groups
    default return
        let $first := head($header-rows-groups)
        let $first-size := layout:header-rows-width($first)
        let $bottom := layout:project(tail($header-rows-groups))
        let $bottom-size as integer := layout:header-rows-width(head($bottom))
        return (
            layout:stretch-header-rows($first, $bottom-size),
            $bottom ! layout:duplicate-header-rows($$, $first-size)
        )
};

(:
    Returns the width of a header row group.
:)
declare function layout:header-rows-width($header-rows-group as object) as integer
{
    sum(($header-rows-group.GroupCells[[1]])[].CellSpan)
};

(:
    Stretches a header row group by the desired factor.
:)
declare function layout:stretch-header-rows($header-rows-group as object, $factor as integer) as object
{
    copy $g := $header-rows-group
    modify replace value of json $g.GroupCells with
    [
        for $row as array in $g.GroupCells[]
        return [
            for $cell in $row[]
            return copy $c := $cell
                   modify replace value of json $c.CellSpan with $c.CellSpan * $factor
                   return $c
        ]
    ]
    return $g
};

(:
    Duplicate the supplied rows the number of times specified in factor.
:)
declare function layout:duplicate-header-rows($header-rows-group as object, $factor as integer) as object
{
    copy $g := $header-rows-group
    modify replace value of json $g.GroupCells with
    [
        for $row as array in $g.GroupCells[]
        return [
            (1 to $factor) ! $row[]
        ]
    ]
    return $g
};

(:
   Returns the numbers of empty rows and columns
:)
declare function layout:empty-rows-and-columns($cells as array) as object
{
    {
        Rows: [
            for $i in 1 to size($cells)
            let $row := ($cells[[$i]])[]
            where every $cell in $row satisfies empty($cell.Value)
            return $i
        ],
        Columns: [
            for $j in 1 to size($cells[[1]])
            let $column := $cells[][[$j]]
            where every $cell in $column satisfies empty($cell.Value)
            return $j
        ]
    }
};

(:
    Eliminates empty rows/columns in the table headers.
:)
declare function layout:eliminate-table-headers($table-headers as object, $empty-rows-and-columns as object) as object
{
    copy $th := $table-headers
    modify let $x-headers as array* := $th.x[].GroupCells
           let $x-eliminated-headers as array* := layout:eliminate($x-headers, $empty-rows-and-columns.Columns[])
           let $y-headers as array* := $th.y[].GroupCells
           let $y-eliminated-headers as array* := layout:eliminate($y-headers, $empty-rows-and-columns.Rows[])
           return (
               for $i in 1 to size($th.x)
               return replace value of json $th.x[[$i]].GroupCells with $x-eliminated-headers[$i],
               for $j in 1 to size($th.y)
               return replace value of json $th.y[[$j]].GroupCells with $y-eliminated-headers[$j]
           )
    return $th
};

(:
    Eliminates empty rows/columns in the cells.
:)
declare function layout:eliminate-cells($cells as array, $empty-rows-and-columns as object) as array
{
    [
        for $row at $i in $cells[]
        where not $i = $empty-rows-and-columns.Rows[]
        return [
            for $cell as item at $j in $row[]
            where not $j = $empty-rows-and-columns.Columns[]
            return $cell
        ]
    ]
};

(:
    Eliminates empty rows/columns in the table header's group cells.
:)
declare function layout:eliminate($header-groups as array*, $empty-columns as integer*) as array*
{
    let $first-group as array? := head($header-groups)
    let $other-groups as array* := tail($header-groups)
    return if(empty($first-group))
           then ()
           else
               let $new-first-group as array := [
                  for $row as array in $first-group[]
                  return [
                       for $i in 1 to size($row)
                       let $lower-bound as integer :=  sum((0, $row[][position() lt $i].CellSpan))
                       let $upper-bound as integer :=  sum($row[][position() le $i].CellSpan)
                       let $cell as object := $row[[$i]]
                       let $matching-empty-columns := $empty-columns[$lower-bound lt $$ and $$ le $upper-bound]
                       let $number := count($matching-empty-columns)
                       return switch(true)
                              case $cell.CellSpan eq $number
                              return ()
                              case $cell.CellSpan gt $number
                              return copy $c := $cell modify replace value of json $c.CellSpan with $c.CellSpan - $number return $c
                              default return error(QName("layout:ELIMINATION-ERROR"), ($number || " > " || $cell.CellSpan))
                    ]
               ]
               return (
                   $new-first-group,
                   layout:eliminate($other-groups, $empty-columns)
               )
};

(:~
 : Converts a structural model to a layout model.
 :
 : @param $structural-model a structural model.
 : @options - hypercube: a hypercube (null for using table domain, default: dimensionless hypercube)
 :          - filter
 :
 : @error layout:MORE-THAN-ONE-MATCHING-TAG in case of a tag selector conflict.
 : @error layout:ELIMINATION_ERROR an error occurred during elimination of empty rows/columns.
 :
 : @return a layout model.
 :)
declare function layout:layout(
    $structural-model as object,
    $options as object?
) as object
{
    let $elimination as boolean := ($options.Eliminate, false)[1]
    let $original-hypercube :=
        if($options.Hypercube instance of null)
        then ()
        else ($options.Hypercube, hypercubes:dimensionless-hypercube())[1]
    let $hypercube := layout:build-hypercube(
        $structural-model,
        $original-hypercube
    )
    let $facts := facts:facts-for({|
        {Hypercube: $hypercube},
        trim($options, "Hypercube")
    |})
    let $actual-aspect-space :=
    {|
        for $aspect in distinct-values(keys($facts.Aspects))
        return { $aspect : [ distinct-values($facts.Aspects.$aspect) ] }
    |}
    let $defaults :=
    {|
        for $aspect in keys($hypercube.Aspects)
        let $default := $hypercube.Aspects.$aspect.Default
        where exists($default)
        return { $aspect : $default }
    |}
    return {
        ModelKind: "LayoutModel",
        ComponentAndHypercubeInformation: {
            Component: $structural-model.Component,
            Hypercube: {
                Name: $hypercube.Name[1],
                Label: $hypercube.Label[1]
            }
        },
        TableSetLabels: $structural-model.TableSetLabels,
        TableSet: [
            for $table in $structural-model.TableSet[]
            let $table-headers as object := {|
                for $axis in keys($table.Breakdowns)
                return {
                    $axis: [
                        layout:project(
                            for $breakdown in $table.Breakdowns.$axis[]
                            return layout:breakdown-to-group($breakdown, $actual-aspect-space)
                        )
                    ]
                }
            |}
            let $x-tag-selectors := layout:tag-selectors([$table-headers.x[].GroupCells[]])
            let $y-tag-selectors := layout:tag-selectors([$table-headers.y[].GroupCells[]])
            let $default-x-slices := layout:slices([$table-headers.x[].GroupCells[]], ())
            let $default-y-slices := layout:slices([$table-headers.y[].GroupCells[]], ())
            let $period-start-x-slices := layout:slices([$table-headers.x[].GroupCells[]], "table.periodStart")
            let $period-start-y-slices := layout:slices([$table-headers.y[].GroupCells[]], "table.periodStart")
            let $period-end-x-slices := layout:slices([$table-headers.x[].GroupCells[]], "table.periodEnd")
            let $period-end-y-slices := layout:slices([$table-headers.y[].GroupCells[]], "table.periodEnd")
            let $cells := [
                for $y-tag-selector at $i in $y-tag-selectors
                return [
                    for $x-tag-selector at $j in $x-tag-selectors
                    let $selectors as string* := ($x-tag-selector[], $y-tag-selector[])
                    let $x-slices as object* :=
                        switch(true)
                        case empty($selectors) return $default-x-slices
                        case deep-equal($selectors, "table.periodStart") return $period-start-x-slices
                        case deep-equal($selectors, "table.periodEnd") return $period-end-x-slices
                        default return layout:slices([$table-headers.x[].GroupCells[]], $selectors)
                    let $participating-x := distinct-values(keys($x-slices))
                    let $y-slices as object* :=
                        switch(true)
                        case empty($selectors) return $default-y-slices
                        case deep-equal($selectors, "table.periodStart") return $period-start-y-slices
                        case deep-equal($selectors, "table.periodEnd") return $period-end-y-slices
                        default return layout:slices([$table-headers.y[].GroupCells[]], $selectors)
                    let $participating-y := distinct-values(keys($y-slices))
                    let $row-facts := layout:filter-aspects(
                        $facts,
                        $y-slices[$i],
                        $participating-y,
                        $defaults)
                    let $cell-facts := layout:filter-aspects(
                        $row-facts,
                        $x-slices[$j],
                        $participating-x,
                        $defaults)
                    return typeswitch($cell-facts)
                           case object return $cell-facts
                           case () return {
                               Aspects: facts:merge-objects(
                                   facts:merge-objects($x-slices[$j], $y-slices[$i], false),
                                   $structural-model.GlobalConstraintSet,
                                   false
                               )
                           }
                           case object+ return [ $cell-facts ]
                           default return {
                               Aspects: facts:merge-objects(
                                   facts:merge-objects($x-slices[$j], $y-slices[$i], false),
                                   $structural-model.GlobalConstraintSet,
                                   false
                               )
                           }
                ]
            ]
            let $empty-rows-and-columns := layout:empty-rows-and-columns($cells)
            let $table-headers := if($elimination)
                                  then layout:eliminate-table-headers($table-headers, $empty-rows-and-columns)
                                  else $table-headers
            let $cells := if($elimination)
                          then layout:eliminate-cells($cells, $empty-rows-and-columns)
                          else $cells
            return
            {
                TableHeaders: $table-headers,
                TableCells: {
                    AxisOrder: [ "y", "x" ],
                    Facts: $cells
                }
            }
        ],
        GlobalConstraints: $structural-model.GlobalConstraintSet,
        GlobalConstraintLabels: $structural-model.GlobalConstraintLabels,
        DebugInfo: {
            Hypercube: $hypercube,
            OriginalHypercube: $original-hypercube
        }
    }
};