function response = getSolverSpecReport(varargin)
%GETSOLVERSPECREPORT  End-to-end /solver-spec runner (mirrors Get-SolverSpecReport.ps1).
%
%   response = getSolverSpecReport() prints a compact console summary.
%
%   response = getSolverSpecReport('Format', fmt, ...) also writes the
%   result to disk. Options mirror the PowerShell wrapper 1-for-1:
%
%     'Format'        'none' | 'json' | 'csv' | 'both'   (default 'none')
%     'JsonPath'      char/string       (default './solver-spec.json')
%     'CsvPath'       char/string       (default './solver-spec.csv')
%     'FailOnNextUp'  logical           (default false — throws if true and
%                                        any scoreboard row has status "next-up")
%     'Strict'        logical           (default true — throws on any
%                                        schema drift vs the /api-docs
%                                        contract; set false to downgrade
%                                        drift to warnings)
%     'BaseUrl'       char/string       (default env DOWNHOLE_BASE_URL or
%                                        'https://wellboregenius.com')
%
%   Auth: reads DOWNHOLE_API_KEY from the environment. Use
%   setDownholeApiKey() first.
%
%   Output schema validation (locked to /api-docs):
%     * Top-level JSON keys: scoreboard (array), count (number matching
%       numel(scoreboard)), generatedAt (non-empty string).
%     * Row required keys: id, title, today, roadmap (non-empty strings).
%     * Row optional keys: status ∈ {shipped, next-up}, coupling (string),
%       newtonWired (boolean), newtonWiredDetail (string).
%     * CSV column order pinned to EXPECTED_CSV_COLUMNS, verified by
%       re-reading the header row after write. Any drift throws
%       'getSolverSpecReport:schema' under Strict, else warns.
%
%   Examples:
%       setDownholeApiKey();
%       r = getSolverSpecReport('Format', 'both');
%       r = getSolverSpecReport('Format', 'json', 'JsonPath', 'out/spec.json');
%       getSolverSpecReport('FailOnNextUp', true);   % CI gate
%       getSolverSpecReport('Strict', false);        % soft-warn on drift

    EXPECTED_CSV_COLUMNS = {'id','title','status','coupling', ...
        'newtonWired','newtonWiredDetail','today','roadmap'};
    EXPECTED_STATUS      = {'shipped','next-up'};
    REQUIRED_ROW_FIELDS  = {'id','title','today','roadmap'};

    p = inputParser();
    p.addParameter('Format', 'none', @(s) any(strcmpi(char(s), {'none','json','csv','both'})));
    p.addParameter('JsonPath', fullfile('.', 'solver-spec.json'), @(s) ischar(s) || isstring(s));
    p.addParameter('CsvPath',  fullfile('.', 'solver-spec.csv'),  @(s) ischar(s) || isstring(s));
    p.addParameter('FailOnNextUp', false, @(x) islogical(x) || isnumeric(x));
    p.addParameter('Strict',       true,  @(x) islogical(x) || isnumeric(x));
    defaultBase = getenv('DOWNHOLE_BASE_URL');
    if isempty(defaultBase); defaultBase = 'https://wellboregenius.com'; end
    p.addParameter('BaseUrl', defaultBase, @(s) ischar(s) || isstring(s));
    p.parse(varargin{:});

    apiKey = getenv('DOWNHOLE_API_KEY');
    if isempty(apiKey)
        error('getSolverSpecReport:auth', 'DOWNHOLE_API_KEY is not set — call setDownholeApiKey() first.');
    end

    fmt      = lower(char(p.Results.Format));
    baseUrl  = char(p.Results.BaseUrl);
    jsonPath = char(p.Results.JsonPath);
    csvPath  = char(p.Results.CsvPath);
    failGate = logical(p.Results.FailOnNextUp);
    strict   = logical(p.Results.Strict);

    uri = [baseUrl '/api/public/sdk/v1/solver-spec'];
    fprintf('[..] GET %s\n', uri);

    opts = weboptions( ...
        'HeaderFields', {'Authorization', ['Bearer ' apiKey]}, ...
        'ContentType',  'json', ...
        'Timeout',      60);
    response = webread(uri, opts);

    % --- Schema validation: top-level envelope ---------------------------
    issues = {};
    for k = {'scoreboard','count','generatedAt'}
        if ~isfield(response, k{1})
            issues{end+1} = sprintf('missing top-level key "%s"', k{1}); %#ok<AGROW>
        end
    end
    if isfield(response, 'generatedAt') && ~(ischar(response.generatedAt) || isstring(response.generatedAt)) ...
            || (isfield(response, 'generatedAt') && strlength(string(response.generatedAt)) == 0)
        issues{end+1} = 'generatedAt must be a non-empty ISO-8601 string';
    end

    rows = [];
    if isfield(response, 'scoreboard')
        rows = response.scoreboard;
        if iscell(rows); rows = [rows{:}]; end
    end
    if isempty(rows)
        error('getSolverSpecReport:empty', 'solver-spec response contained no scoreboard rows.');
    end

    if isfield(response, 'count') && isnumeric(response.count) && response.count ~= numel(rows)
        issues{end+1} = sprintf('count (%d) does not match numel(scoreboard) (%d)', ...
            response.count, numel(rows));
    end

    % --- Schema validation: per-row -------------------------------------
    for i = 1:numel(rows)
        r = rows(i);
        if iscell(r); r = r{1}; end
        rowLabel = sprintf('row %d', i);
        if isstruct(r) && isfield(r, 'id') && ~isempty(r.id)
            rowLabel = sprintf('row %d (id="%s")', i, char(string(r.id)));
        end
        for k = REQUIRED_ROW_FIELDS
            if ~isfield(r, k{1}) || isempty(r.(k{1}))
                issues{end+1} = sprintf('%s: missing required field "%s"', rowLabel, k{1}); %#ok<AGROW>
            end
        end
        if isfield(r, 'status') && ~isempty(r.status) && ~any(strcmp(char(string(r.status)), EXPECTED_STATUS))
            issues{end+1} = sprintf('%s: status "%s" not in {%s}', ...
                rowLabel, char(string(r.status)), strjoin(EXPECTED_STATUS, ', ')); %#ok<AGROW>
        end
        if isfield(r, 'newtonWired') && ~isempty(r.newtonWired) ...
                && ~(islogical(r.newtonWired) || (isnumeric(r.newtonWired) && (r.newtonWired == 0 || r.newtonWired == 1)))
            issues{end+1} = sprintf('%s: newtonWired must be boolean', rowLabel); %#ok<AGROW>
        end
        for k = {'coupling','newtonWiredDetail'}
            if isfield(r, k{1}) && ~isempty(r.(k{1})) && ~(ischar(r.(k{1})) || isstring(r.(k{1})))
                issues{end+1} = sprintf('%s: %s must be a string', rowLabel, k{1}); %#ok<AGROW>
            end
        end
    end

    if ~isempty(issues)
        reportSchemaIssues(issues, strict);
    end

    % --- Console summary -------------------------------------------------
    statuses    = arrayfun(@(r) localField(r, 'status', 'shipped'), rows, 'UniformOutput', false);
    newtonWired = arrayfun(@(r) logical(localField(r, 'newtonWired', false)), rows);
    nRows       = numel(rows);
    nNextUp     = sum(strcmp(statuses, 'next-up'));
    nShipped    = nRows - nNextUp;
    nWired      = sum(newtonWired);

    fprintf('[ok] %d rows | shipped=%d next-up=%d newton-wired=%d | generatedAt=%s\n', ...
        nRows, nShipped, nNextUp, nWired, localField(response, 'generatedAt', ''));

    if any(strcmp(fmt, {'json','both'}))
        localEnsureParent(jsonPath);
        fid = fopen(jsonPath, 'w');
        if fid < 0
            error('getSolverSpecReport:io', 'Could not open %s for writing.', jsonPath);
        end
        cleanupJson = onCleanup(@() fclose(fid));
        fwrite(fid, jsonencode(response, 'PrettyPrint', true), 'char');
        clear cleanupJson
        fprintf('[ok] JSON -> %s\n', jsonPath);
    end

    if any(strcmp(fmt, {'csv','both'}))
        localEnsureParent(csvPath);
        T = table( ...
            arrayfun(@(r) string(localField(r, 'id', '')),                rows(:)), ...
            arrayfun(@(r) string(localField(r, 'title', '')),             rows(:)), ...
            string(statuses(:)), ...
            arrayfun(@(r) string(localField(r, 'coupling', '')),          rows(:)), ...
            newtonWired(:), ...
            arrayfun(@(r) string(localField(r, 'newtonWiredDetail', '')), rows(:)), ...
            arrayfun(@(r) string(localField(r, 'today', '')),             rows(:)), ...
            arrayfun(@(r) string(localField(r, 'roadmap', '')),           rows(:)), ...
            'VariableNames', EXPECTED_CSV_COLUMNS);
        writetable(T, csvPath, 'QuoteStrings', true, 'Encoding', 'UTF-8');

        % Round-trip verify header row matches EXPECTED_CSV_COLUMNS exactly.
        verifyCsvHeader(csvPath, EXPECTED_CSV_COLUMNS, strict);
        fprintf('[ok] CSV  -> %s (columns verified)\n', csvPath);
    end

    if failGate && nNextUp > 0
        labels = strjoin(arrayfun(@(r) localField(r, 'id', ''), ...
            rows(strcmp(statuses, 'next-up')), 'UniformOutput', false), ', ');
        error('getSolverSpecReport:nextUp', ...
            '%d scoreboard row(s) still ''next-up'': %s', nNextUp, labels);
    end
end

function reportSchemaIssues(issues, strict)
    header = sprintf('%d schema drift issue(s) vs /api-docs contract:', numel(issues));
    body   = strjoin(cellfun(@(s) ['  - ' s], issues, 'UniformOutput', false), newline);
    msg    = [header newline body];
    if strict
        error('getSolverSpecReport:schema', '%s', msg);
    else
        warning('getSolverSpecReport:schema', '%s', msg);
    end
end

function verifyCsvHeader(csvPath, expected, strict)
    fid = fopen(csvPath, 'r');
    if fid < 0
        warning('getSolverSpecReport:schema', ...
            'Could not re-open %s to verify CSV header.', csvPath);
        return;
    end
    cleanup = onCleanup(@() fclose(fid));
    headerLine = fgetl(fid);
    clear cleanup
    if ~ischar(headerLine)
        reportSchemaIssues({sprintf('CSV %s has no header row', csvPath)}, strict);
        return;
    end
    actual = strsplit(headerLine, ',');
    actual = cellfun(@(s) strtrim(regexprep(s, '^"|"$', '')), actual, 'UniformOutput', false);
    if ~isequal(actual, expected)
        reportSchemaIssues({sprintf( ...
            'CSV header mismatch in %s:\n      expected: %s\n      actual:   %s', ...
            csvPath, strjoin(expected, ','), strjoin(actual, ','))}, strict);
    end
end

function v = localField(s, name, defaultVal)
    if isstruct(s) && isfield(s, name) && ~isempty(s.(name))
        v = s.(name);
    else
        v = defaultVal;
    end
end

function localEnsureParent(pathStr)
    d = fileparts(char(pathStr));
    if ~isempty(d) && ~isfolder(d)
        mkdir(d);
    end
end

