/**
 * Reddit post reader with threaded comment tree.
 *
 * Replaces the original flat read.yaml with recursive comment traversal:
 * - Top-K comments by score at each level
 * - Configurable depth and replies-per-level
 * - Indented output showing conversation threads
 * - Optional --expand-more to follow Reddit's "more comments" stubs via
 *   /api/morechildren.json (rdt-cli parity, PR B of #1481 follow-up)
 */
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';

const REDDIT_EXPAND_ROUNDS_MIN = 1;
const REDDIT_EXPAND_ROUNDS_MAX = 5;
const DEFAULT_EXPAND_ROUNDS = 2;
const REDDIT_POST_ID_RE = /^[a-z0-9]+$/i;

function normalizeBareRedditPostId(value) {
    const postId = String(value || '').trim();
    if (!REDDIT_POST_ID_RE.test(postId)) {
        throw new ArgumentError(
            'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
            'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
        );
    }
    return postId.toLowerCase();
}

export function normalizeRedditPostId(value) {
    const raw = String(value || '').trim();
    if (!raw) {
        throw new ArgumentError(
            'Post ID is required.',
            'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
        );
    }

    const fullname = raw.match(/^t3_([a-z0-9]+)$/i);
    if (fullname) return normalizeBareRedditPostId(fullname[1]);

    if (/^https?:\/\//i.test(raw)) {
        let parsed;
        try {
            parsed = new URL(raw);
        } catch {
            throw new ArgumentError(`Invalid Reddit post URL: ${raw}`);
        }
        const host = parsed.hostname.toLowerCase();
        if (parsed.protocol !== 'https:' || (host !== 'reddit.com' && !host.endsWith('.reddit.com'))) {
            throw new ArgumentError(
                'Post URL must be an https reddit.com URL.',
                'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
            );
        }
        const parts = parsed.pathname.split('/').filter(Boolean);
        const commentsIndex = parts.indexOf('comments');
        const postIndex = commentsIndex + 1;
        if (commentsIndex < 0 || parts.length <= postIndex) {
            throw new ArgumentError(
                'Post URL must include the target post id.',
                'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
            );
        }
        if (parts.length > postIndex + 3) {
            throw new ArgumentError(
                'Post URL must end at the post slug or comment permalink id.',
                'Remove extra path segments after the post slug or comment id.',
            );
        }
        if (parts.length === postIndex + 3) normalizeBareRedditPostId(parts[postIndex + 2]);
        return normalizeBareRedditPostId(parts[postIndex]);
    }

    if (raw.includes('/') || raw.startsWith('t1_')) {
        throw new ArgumentError(
            'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
            'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
        );
    }

    return normalizeBareRedditPostId(raw);
}

export function parseExpandRounds(raw) {
    if (raw === undefined || raw === null || raw === '') return DEFAULT_EXPAND_ROUNDS;
    const n = Number(raw);
    if (
        !Number.isFinite(n) || !Number.isInteger(n)
        || n < REDDIT_EXPAND_ROUNDS_MIN || n > REDDIT_EXPAND_ROUNDS_MAX
    ) {
        throw new ArgumentError(
            `expand-rounds must be an integer in [${REDDIT_EXPAND_ROUNDS_MIN}, ${REDDIT_EXPAND_ROUNDS_MAX}].`,
            `Got: ${raw}`,
        );
    }
    return n;
}

cli({
    site: 'reddit',
    name: 'read',
    access: 'read',
    description: 'Read a Reddit post and its comments',
    domain: 'reddit.com',
    strategy: Strategy.COOKIE,
    browser: true,
    args: [
        { name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
        { name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
        { name: 'limit', type: 'int', default: 25, help: 'Number of top-level comments' },
        { name: 'depth', type: 'int', default: 2, help: 'Max reply depth (1=no replies, 2=one level of replies, etc.)' },
        { name: 'replies', type: 'int', default: 5, help: 'Max replies shown per comment at each level (sorted by score)' },
        { name: 'max-length', type: 'int', default: 2000, help: 'Max characters per comment body (min 100)' },
        {
            name: 'expand-more',
            type: 'bool',
            default: false,
            help: 'Follow Reddit "more comments" stubs by calling /api/morechildren.json',
        },
        {
            name: 'expand-rounds',
            type: 'int',
            default: DEFAULT_EXPAND_ROUNDS,
            help: `Max expansion passes when --expand-more is on (${REDDIT_EXPAND_ROUNDS_MIN}–${REDDIT_EXPAND_ROUNDS_MAX}; each round can fan out new "more" stubs)`,
        },
    ],
    columns: ['type', 'author', 'score', 'text', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
    func: async (page, kwargs) => {
        // Note: --limit / --depth / --replies / --max-length keep their original
        // Math.max-style behaviour for backward compatibility (grandfathered in
        // the typed-error-lint baseline). The new --expand-rounds argument is
        // strictly validated via parseExpandRounds — no silent clamp.
        const sort = kwargs.sort ?? 'best';
        const limit = Math.max(1, kwargs.limit ?? 25);
        const maxDepth = Math.max(1, kwargs.depth ?? 2);
        const maxReplies = Math.max(1, kwargs.replies ?? 5);
        const maxLength = Math.max(100, kwargs['max-length'] ?? 2000);
        const expandMore = Boolean(kwargs['expand-more']);
        const expandRounds = parseExpandRounds(kwargs['expand-rounds']);
        const postId = normalizeRedditPostId(kwargs['post-id']);

        // 不再显式导航首页：框架 navigateBefore（domain=reddit.com）已把页面带到 reddit origin，
        // 下面的相对 fetch(/comments/<id>.json) 照常可用。省掉每条评论命令一次冗余首页导航。

        // The in-browser script returns a discriminated union so we can map
        // each failure mode to its proper typed error on the Node side
        // (page.evaluate boundary can't carry typed error instances). Kinds:
        //   - inaccessible: 401/403/404 on /comments/<id>.json (post-specific,
        //     not session auth — same session works for other posts)
        //   - auth:         /api/morechildren.json 401/403 (session-level on
        //     the write-like expand endpoint — see two-pronged auth detection
        //     sediment from PR #1428)
        //   - http:         5xx or other non-ok
        //   - malformed:    200 but Reddit shape is unexpected (schema drift)
        //   - parser-drift: tree non-empty but walk produced 0 rows
        //   - expand-failed: morechildren returned errors
        //   - ok:           rows array
        //
        // Intermediate keys (`rows` / `detail` / `httpStatus` / `where`)
        // deliberately avoid the declared columns (`type`/`author`/`score`/
        // `text`) to sidestep the silent-column-drop audit (PR #1329).
        const result = await page.evaluate(`
      (async function() {
        function decodeHtml(s) {
          if (typeof s !== 'string' || !s) return '';
          return s
            .replace(/&amp;/g, '&')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&quot;/g, '"')
            .replace(/&#x27;/gi, "'")
            .replace(/&#39;/g, "'");
        }
        function extractRedditMedia(d) {
          var post_hint = (d && d.post_hint) || '';
          var url_overridden_by_dest = decodeHtml((d && d.url_overridden_by_dest) || '');
          var preview_image_url = decodeHtml(
            (d && d.preview && d.preview.images && d.preview.images[0] && d.preview.images[0].source && d.preview.images[0].source.url) || ''
          );
          var gallery_urls = [];
          var items = d && d.gallery_data && d.gallery_data.items;
          var meta = d && d.media_metadata;
          if (Array.isArray(items) && meta) {
            for (var gi = 0; gi < items.length; gi++) {
              var it = items[gi];
              var m = it && meta[it.media_id];
              var u = m && m.s && (m.s.u || m.s.gif || m.s.mp4);
              if (u) gallery_urls.push(decodeHtml(u));
            }
          }
          return {
            post_hint: post_hint,
            url_overridden_by_dest: url_overridden_by_dest,
            preview_image_url: preview_image_url,
            gallery_urls: gallery_urls,
          };
        }
        var postId = ${JSON.stringify(postId)};
        var linkFullname = 't3_' + postId;

        var sort = ${JSON.stringify(sort)};
        var limit = ${limit};
        var maxDepth = ${maxDepth};
        var maxReplies = ${maxReplies};
        var maxLength = ${maxLength};
        var expandMore = ${JSON.stringify(expandMore)};
        var expandRounds = ${expandRounds};

        // ---------------------------------------------------------------
        // Step 1: fetch the post + initial comment tree
        // ---------------------------------------------------------------
        // Request more from API than top-level limit to get inline replies.
        // depth param tells Reddit how deep to inline replies vs "more" stubs.
        var apiLimit = Math.max(limit * 3, 100);
        var res = await fetch(
          '/comments/' + postId + '.json?sort=' + sort + '&limit=' + apiLimit + '&depth=' + (maxDepth + 1) + '&raw_json=1',
          { credentials: 'include' }
        );
        if (res.status === 401 || res.status === 403 || res.status === 404) {
          return { kind: 'inaccessible', detail: 'Reddit post ' + postId + ' is not accessible (HTTP ' + res.status + ').' };
        }
        if (!res.ok) {
          return { kind: 'http', httpStatus: res.status, where: '/comments/' + postId + '.json' };
        }
        var data;
        try { data = await res.json(); } catch (e) {
          return { kind: 'malformed', detail: 'Failed to parse Reddit /comments/' + postId + '.json response: ' + (e && e.message || e) };
        }
        if (!Array.isArray(data) || data.length < 2) {
          return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had unexpected envelope shape (length ' + (Array.isArray(data) ? data.length : typeof data) + ').' };
        }

        var post = data[0] && data[0].data && data[0].data.children && data[0].data.children[0] && data[0].data.children[0].data;
        if (!post) {
          return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no post body.' };
        }
        var topListing = data[1] && data[1].data && Array.isArray(data[1].data.children) ? data[1].data.children : null;
        if (!topListing) {
          return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no comment listing.' };
        }

        // ---------------------------------------------------------------
        // Step 2: optionally follow "more" stubs via /api/morechildren.json
        // ---------------------------------------------------------------
        // Each "more" thing has a .data.children array (t1 ids to fetch).
        // The morechildren API returns a FLAT list of things; we re-thread
        // them by parent_id (either t3_<postId> for top-level or t1_<id>
        // for nested). Each round may surface new "more" stubs (because
        // expansion is bounded by Reddit's depth param), so we iterate up
        // to expandRounds times.
        var expandMeta = { rounds: 0, fetched: 0, capped: false, errors: [] };

        if (expandMore) {
          // Index every existing t1 node so we can splice replies onto it.
          var t1Index = {};
          function indexT1(arr) {
            if (!Array.isArray(arr)) return;
            for (var i = 0; i < arr.length; i++) {
              var node = arr[i];
              if (node && node.kind === 't1' && node.data && node.data.id) {
                t1Index[node.data.name || ('t1_' + node.data.id)] = node;
                if (node.data.replies && node.data.replies.data && node.data.replies.data.children) {
                  indexT1(node.data.replies.data.children);
                }
              }
            }
          }
          indexT1(topListing);

          // Collect "more" stubs (with non-empty children) from anywhere in
          // the tree. Each stub knows its host array via a closure-bound
          // reference we attach.
          function collectMoreStubs(parentArr, parentT1) {
            var out = [];
            if (!Array.isArray(parentArr)) return out;
            for (var i = 0; i < parentArr.length; i++) {
              var n = parentArr[i];
              if (!n || !n.data) continue;
              if (n.kind === 'more' && Array.isArray(n.data.children) && n.data.children.length > 0) {
                out.push({ stub: n, hostArr: parentArr, hostT1: parentT1 });
              } else if (n.kind === 't1' && n.data.replies && n.data.replies.data && n.data.replies.data.children) {
                var nested = collectMoreStubs(n.data.replies.data.children, n);
                for (var k = 0; k < nested.length; k++) out.push(nested[k]);
              }
            }
            return out;
          }

          for (var r = 0; r < expandRounds; r++) {
            var stubs = collectMoreStubs(topListing, null);
            if (stubs.length === 0) break;

            // Build the union of t1 ids to request this round. Reddit's
            // morechildren API caps at ~100 ids per call; batch accordingly.
            var allIds = [];
            for (var s = 0; s < stubs.length; s++) {
              var st = stubs[s].stub;
              for (var c = 0; c < st.data.children.length; c++) allIds.push(st.data.children[c]);
            }
            if (allIds.length === 0) break;

            // dedupe preserving order
            var seen = {};
            var uniqIds = [];
            for (var j = 0; j < allIds.length; j++) {
              if (!seen[allIds[j]]) { seen[allIds[j]] = 1; uniqIds.push(allIds[j]); }
            }

            var fetchedThings = [];
            var batchSize = 100;
            var batchFailed = false;
            for (var b = 0; b < uniqIds.length; b += batchSize) {
              var batch = uniqIds.slice(b, b + batchSize);
              var body = 'api_type=json'
                + '&link_id=' + encodeURIComponent(linkFullname)
                + '&children=' + encodeURIComponent(batch.join(','))
                + '&sort=' + encodeURIComponent(sort)
                + '&raw_json=1';
              var mcRes;
              try {
                mcRes = await fetch('/api/morechildren', {
                  method: 'POST',
                  credentials: 'include',
                  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                  body: body,
                });
              } catch (e) {
                return { kind: 'expand-failed', detail: 'morechildren request threw: ' + (e && e.message || e), expandMeta: expandMeta };
              }
              if (mcRes.status === 401 || mcRes.status === 403) {
                return { kind: 'auth', detail: '/api/morechildren returned HTTP ' + mcRes.status + ' (write/expand likely requires login)' };
              }
              if (!mcRes.ok) {
                return { kind: 'http', httpStatus: mcRes.status, where: '/api/morechildren (round ' + (r + 1) + ', batch ' + ((b / batchSize) + 1) + ')' };
              }
              var mcData;
              try { mcData = await mcRes.json(); } catch (e) {
                return { kind: 'malformed', detail: 'Failed to parse /api/morechildren response: ' + (e && e.message || e) };
              }
              var errs = mcData && mcData.json && mcData.json.errors;
              if (Array.isArray(errs) && errs.length > 0) {
                return { kind: 'expand-failed', detail: 'Reddit /api/morechildren rejected: ' + errs.map(function(e) { return e.join(': '); }).join('; '), expandMeta: expandMeta };
              }
              var things = mcData && mcData.json && mcData.json.data && mcData.json.data.things;
              if (!Array.isArray(things)) {
                return { kind: 'malformed', detail: '/api/morechildren returned no things array.' };
              }
              for (var t = 0; t < things.length; t++) fetchedThings.push(things[t]);
            }
            expandMeta.rounds = r + 1;
            expandMeta.fetched += fetchedThings.length;

            var fetchedById = {};
            for (var t = 0; t < fetchedThings.length; t++) {
              var thing = fetchedThings[t];
              if (!thing || !thing.data) continue;
              if (thing.data.id) fetchedById[thing.data.id] = thing;
              if (thing.data.name) fetchedById[thing.data.name] = thing;
            }

            var inserted = {};
            function thingKey(thing) {
              return thing && thing.data && (thing.data.name || (thing.kind + '_' + thing.data.id));
            }

            // Replace each collected stub in-place so expansion preserves the
            // surrounding tree order instead of appending fetched comments at
            // the end of the parent array.
            for (var s = 0; s < stubs.length; s++) {
              var rec = stubs[s];
              var idx = rec.hostArr.indexOf(rec.stub);
              if (idx < 0) continue;
              var expectedParent = rec.hostT1
                ? (rec.hostT1.data.name || ('t1_' + rec.hostT1.data.id))
                : linkFullname;
              var replacements = [];
              for (var c = 0; c < rec.stub.data.children.length; c++) {
                var childId = rec.stub.data.children[c];
                var replacement = fetchedById[childId] || fetchedById['t1_' + childId];
                if (!replacement || !replacement.data) {
                  expandMeta.errors.push('missing: ' + childId + ' parent=' + expectedParent);
                  continue;
                }
                var key = thingKey(replacement);
                if (key && inserted[key]) continue;
                if (replacement.data.parent_id !== expectedParent) {
                  expandMeta.errors.push('orphan: ' + (replacement.data.id || '?') + ' parent=' + (replacement.data.parent_id || '?'));
                  continue;
                }
                replacements.push(replacement);
                if (key) inserted[key] = 1;
                if (replacement.kind === 't1' && replacement.data && replacement.data.id) {
                  t1Index[replacement.data.name || ('t1_' + replacement.data.id)] = replacement;
                }
              }
              rec.hostArr.splice(idx, 1, ...replacements);
            }

            for (var t = 0; t < fetchedThings.length; t++) {
              var unplaced = fetchedThings[t];
              var unplacedKey = thingKey(unplaced);
              if (unplacedKey && inserted[unplacedKey]) continue;
              if (unplaced && unplaced.data) {
                expandMeta.errors.push('unplaced: ' + (unplaced.data.id || '?') + ' parent=' + (unplaced.data.parent_id || '?'));
                continue;
              }
            }

            if (r + 1 >= expandRounds) {
              // If after the last round there are still "more" stubs, mark capped.
              var remaining = collectMoreStubs(topListing, null);
              if (remaining.length > 0) expandMeta.capped = true;
            }
          }

          if (expandMeta.errors.length > 0) {
            return { kind: 'expand-failed', detail: 'Reddit /api/morechildren returned unplaceable comments: ' + expandMeta.errors.slice(0, 5).join('; '), expandMeta: expandMeta };
          }
        }

        // ---------------------------------------------------------------
        // Step 3: walk the (possibly augmented) tree into indented rows
        // ---------------------------------------------------------------
        var rows = [];

        // Post header row.
        var body = post.selftext || '';
        if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
        var postMedia = extractRedditMedia(post);
        rows.push({
          type: 'POST',
          author: post.author || '[deleted]',
          score: post.score || 0,
          text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
          post_hint: postMedia.post_hint,
          url_overridden_by_dest: postMedia.url_overridden_by_dest,
          preview_image_url: postMedia.preview_image_url,
          gallery_urls: postMedia.gallery_urls,
        });

        // Recursive comment walker.
        function walkComment(node, depth) {
          if (!node || node.kind !== 't1') return;
          var d = node.data;
          var cBody = d.body || '';
          if (cBody.length > maxLength) cBody = cBody.slice(0, maxLength) + '...';

          var indent = '';
          for (var i = 0; i < depth; i++) indent += '  ';
          var prefix = depth === 0 ? '' : indent + '> ';
          var indentedBody = depth === 0
            ? cBody
            : cBody.split('\\n').map(function(line) { return prefix + line; }).join('\\n');

          rows.push({
            type: depth === 0 ? 'L0' : 'L' + depth,
            author: d.author || '[deleted]',
            score: d.score || 0,
            text: indentedBody,
            post_hint: '',
            url_overridden_by_dest: '',
            preview_image_url: '',
            gallery_urls: [],
          });

          var t1Children = [];
          var moreCount = 0;
          if (d.replies && d.replies.data && d.replies.data.children) {
            var children = d.replies.data.children;
            for (var i = 0; i < children.length; i++) {
              if (children[i].kind === 't1') {
                t1Children.push(children[i]);
              } else if (children[i].kind === 'more') {
                moreCount += children[i].data.count || 0;
              }
            }
          }

          if (depth + 1 >= maxDepth) {
            var totalHidden = t1Children.length + moreCount;
            if (totalHidden > 0) {
              var cutoffIndent = '';
              for (var j = 0; j <= depth; j++) cutoffIndent += '  ';
              rows.push({
                type: 'L' + (depth + 1),
                author: '',
                score: '',
                text: cutoffIndent + '[+' + totalHidden + ' more replies]',
                post_hint: '',
                url_overridden_by_dest: '',
                preview_image_url: '',
                gallery_urls: [],
              });
            }
            return;
          }

          t1Children.sort(function(a, b) { return (b.data.score || 0) - (a.data.score || 0); });
          var toProcess = Math.min(t1Children.length, maxReplies);
          for (var i = 0; i < toProcess; i++) {
            walkComment(t1Children[i], depth + 1);
          }

          var hidden = t1Children.length - toProcess + moreCount;
          if (hidden > 0) {
            var moreIndent = '';
            for (var j = 0; j <= depth; j++) moreIndent += '  ';
            rows.push({
              type: 'L' + (depth + 1),
              author: '',
              score: '',
              text: moreIndent + '[+' + hidden + ' more replies]',
              post_hint: '',
              url_overridden_by_dest: '',
              preview_image_url: '',
              gallery_urls: [],
            });
          }
        }

        var t1TopLevel = [];
        for (var i = 0; i < topListing.length; i++) {
          if (topListing[i].kind === 't1') t1TopLevel.push(topListing[i]);
        }

        // Detect parser drift: tree had content but the walker produced nothing.
        // We must check this AFTER the walk because top-level may be only "more"
        // stubs (legitimate empty case for a brand-new post).
        var preWalkSize = topListing.length;
        for (var i = 0; i < Math.min(t1TopLevel.length, limit); i++) {
          walkComment(t1TopLevel[i], 0);
        }

        var moreTopLevel = topListing.filter(function(c) { return c.kind === 'more'; })
          .reduce(function(sum, c) { return sum + (c.data.count || 0); }, 0);
        var hiddenTopLevel = Math.max(0, t1TopLevel.length - limit) + moreTopLevel;
        if (hiddenTopLevel > 0) {
          rows.push({
            type: '',
            author: '',
            score: '',
            text: '[+' + hiddenTopLevel + ' more top-level comments]',
            post_hint: '',
            url_overridden_by_dest: '',
            preview_image_url: '',
            gallery_urls: [],
          });
        }

        // If we produced nothing beyond the POST row but the comment listing
        // wasn't empty, that's parser drift (e.g. Reddit changed t1/more
        // schema). Surface as CommandExecutionError on the Node side.
        if (rows.length <= 1 && preWalkSize > 0 && t1TopLevel.length > 0) {
          return { kind: 'parser-drift', detail: 'Reddit comment listing for post ' + postId + ' had ' + t1TopLevel.length + ' t1 entries but walker produced no rows.' };
        }

        return { kind: 'ok', rows: rows, expandMeta: expandMeta };
      })()
    `);

        if (!result || typeof result !== 'object') {
            throw new CommandExecutionError('Reddit /comments fetch returned no result envelope.');
        }
        if (result.kind === 'inaccessible') {
            throw new EmptyResultError(result.detail);
        }
        if (result.kind === 'auth') {
            throw new AuthRequiredError('reddit.com', result.detail);
        }
        if (result.kind === 'http') {
            throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
        }
        if (result.kind === 'malformed') {
            throw new CommandExecutionError(result.detail);
        }
        if (result.kind === 'parser-drift') {
            throw new CommandExecutionError(result.detail);
        }
        if (result.kind === 'expand-failed') {
            throw new CommandExecutionError(result.detail);
        }
        if (result.kind !== 'ok' || !Array.isArray(result.rows)) {
            throw new CommandExecutionError(`Unexpected result from reddit read: ${JSON.stringify(result)}`);
        }
        return result.rows;
    },
});
