import datetime
import json
import pathlib
import sys
import typing
import webbrowser
from warnings import warn

import requests
from bs4 import BeautifulSoup as Soup

from .data import (
    DATA_DIR,
    DEFAULT_YEAR,
    HEADERS,
    LEADERBOARD_URL,
    PRACTICE_DATA_DIR,
    RANK,
    TODAY,
    URL,
    WAIT_TIME,
    get_cookie,
)
from .formatting import BLUE, GOLD, GREEN, RED, RESET, YELLOW, print, wait, work

T = typing.TypeVar("T")
U = typing.TypeVar("U")


def _open_page(page: str) -> None:
    """Open the page if the user hasn't opted out"""
    if not (DATA_DIR / ".nobrowser").exists():
        webbrowser.open(page)


def _make(folder: pathlib.Path) -> None:
    """Create folder if it doesn't exist."""
    if not folder.exists():
        folder.mkdir(parents=True)


def _pretty_print(message: str) -> None:
    """Analyse and print message"""
    if message.startswith("That's the"):
        print(GREEN + message + RESET)
    elif message.startswith("You don't"):
        print(YELLOW + message + RESET)
    elif message.startswith("That's not"):
        print(RED + message + RESET)
    elif message.startswith("You got rank"):
        print(GOLD + message + RESET)
    else:
        raise ValueError("Failed to parse response.")


def validate_token(allow_prompt: bool = True) -> bool:
    cookie = get_cookie(not allow_prompt)
    if cookie is None:
        return False
    resp = requests.get(
        URL.format(day=1, year=2015) + "/input",
        cookies=get_cookie(),
        headers=HEADERS,
    )
    if not resp.ok:
        if resp.status_code != 400:
            raise ValueError("Received bad response")
        if not allow_prompt:
            return False
        token_file = DATA_DIR / "token.txt"
        print(f"{RED}Your token has expired. Please enter your new" f" token.{RESET}")
        token = input(">>> ")
        token_file.write_text(token)
        return validate_token(True)
    return True


def fetch(day: int = TODAY, year: int = DEFAULT_YEAR, never_print: bool = False) -> str:
    """Fetch and return the input for `day` of `year`.

    If `--practice` is provided on the command line, pretend that today is the
    day of the puzzle and wait for puzzle unlock accordingly. 'today' is
    determined by UTC; from 0:00 to 5:00 UTC, this puzzle will block until 5:00
    UTC - after that, until 0:00 UTC the next day, input fetching will be
    instant.

    All inputs are cached in `aoc_helper.DATA_DIR`."""
    import sys

    day_ = str(day)
    year_ = str(year)

    _make(DATA_DIR / year_)
    input_path = DATA_DIR / year_ / (day_ + ".in")

    if input_path.exists():
        should_print = False
        if "--practice" in sys.argv:
            now = datetime.datetime.utcnow()
            unlock = datetime.datetime(now.year, now.month, now.day, 5)
            if now < unlock:
                should_print = True
                wait(
                    f"{YELLOW}Waiting for puzzle unlock...{RESET}",
                    (unlock - now).total_seconds(),
                )
                print(GREEN + "Fetching input!" + RESET)
                _open_page(URL.format(day=day, year=year))
        input_data = input_path.read_text()
        if "--practice" in sys.argv and should_print:
            print(input_data)
        return input_data
    else:
        unlock = datetime.datetime(year, 12, day, 5)
        now = datetime.datetime.utcnow()
        if "--practice" in sys.argv:
            unlock = unlock.replace(year=now.year, month=now.month, day=now.day)
        if now < unlock:
            if day == 1:
                # If we're waiting for the first puzzle, the user's token may
                # have expired. We should check that now.
                validate_token()
                now = datetime.datetime.utcnow()
            wait(
                f"{YELLOW}Waiting for puzzle unlock...{RESET}",
                (unlock - now).total_seconds(),
            )
            print(GREEN + "Fetching input!" + RESET)
            _open_page(URL.format(day=day, year=year))
        resp = requests.get(
            URL.format(day=day, year=year) + "/input",
            cookies=get_cookie(),
            headers=HEADERS,
        )
        if not resp.ok:
            if resp.status_code == 400:
                token_file = DATA_DIR / "token.txt"
                print(
                    f"{RED}Your token has expired. Please enter your new token.{RESET}"
                )
                token = input(">>> ")
                token_file.write_text(token)
                return fetch(day, year, never_print)
            raise ValueError("Received bad response")
        data = resp.text.strip("\n")
        input_path.write_text(data)
        if not never_print:
            print(data)
        return data


def _load_leaderboard_times(
    day: int, year: int = DEFAULT_YEAR
) -> typing.Tuple[typing.List[datetime.timedelta], typing.List[datetime.timedelta]]:
    day_ = str(day)
    year_ = str(year)

    day_dir = DATA_DIR / year_ / day_
    _make(day_dir)

    # Load cached leaderboards
    leaderboards = day_dir / "leaderboards.json"
    if leaderboards.exists():
        with leaderboards.open() as f:
            # ([seconds], [seconds])
            data: typing.List[typing.List[int]] = json.load(f)
            return [datetime.timedelta(seconds=t) for t in data[0]], [datetime.timedelta(seconds=t) for t in data[1]]  # type: ignore
    else:
        leaderboard_page = requests.get(
            LEADERBOARD_URL.format(day=day, year=year), headers=HEADERS
        )
        soup = Soup(leaderboard_page.text, "html.parser")
        times = soup.select(".leaderboard-entry")
        part_1_times: typing.List[datetime.timedelta] = []
        part_2_times: typing.List[datetime.timedelta] = []
        in_part_2 = False
        for leaderboard_time in times:
            if leaderboard_time.span.text == "  1)":  # type: ignore
                in_part_2 = not in_part_2
            time_to_solve = datetime.datetime.strptime(
                leaderboard_time.select_one(".leaderboard-time").text,  # type: ignore
                "%b %d  %H:%M:%S",
            ) - datetime.datetime(1900, 12, day)
            if in_part_2:
                part_2_times.append(time_to_solve)
            else:
                part_1_times.append(time_to_solve)
        if not part_1_times:
            # no part 2 leaderboard, so boards were read in backwards
            part_2_times, part_1_times = part_1_times, part_2_times
        if len(part_1_times) == len(part_2_times) == 100:
            # both leaderboards are full, cache them
            with leaderboards.open("w") as f:
                json.dump(
                    (
                        [t.total_seconds() for t in part_1_times],
                        [t.total_seconds() for t in part_2_times],
                    ),
                    f,
                )
        return part_1_times, part_2_times


def _practice_result_for(day: int, year: int) -> typing.List[int]:
    practice_data_dir = PRACTICE_DATA_DIR / str(year) / str(day)
    _make(practice_data_dir)
    try:
        with open(
            practice_data_dir
            / f"{datetime.datetime.utcnow().year:04}-{datetime.datetime.utcnow().month:02}-{datetime.datetime.utcnow().day:02}.json",
            "r",
        ) as f:
            return json.load(f)
    except FileNotFoundError:
        return []


def _calculate_practice_result(day: int, part: int, year: int) -> None:
    if "--practice" not in sys.argv:
        return
    now = datetime.datetime.utcnow()
    solve_time = datetime.timedelta(
        hours=now.hour - 5,
        minutes=now.minute,
        seconds=now.second,
        microseconds=now.microsecond,
    )
    practice_data_dir = PRACTICE_DATA_DIR / str(year) / str(day)
    _make(practice_data_dir)

    filename = f"{now.year:04}-{now.month:02}-{now.day:02}.json"

    try:
        with open(practice_data_dir / filename) as f:
            data: typing.List[float] = json.load(f)
    except (json.decoder.JSONDecodeError, FileNotFoundError):
        data = []
    with open(practice_data_dir / filename, "w") as f:
        data.append(solve_time.total_seconds())
        json.dump(data, f)
    _report_practice_result(day, part, year, solve_time)


def _estimate_practice_rank(
    day: int, part: int, year: int, solve_time: datetime.timedelta
) -> typing.Optional[typing.Tuple[int, int, int]]:
    import bisect

    leaderboard = _load_leaderboard_times(day, year)[part - 1]
    # aoc truncates solve times, so we do too for the purpose of sorting
    truncated_solve_time = datetime.timedelta(seconds=int(solve_time.total_seconds()))
    best_possible_rank = bisect.bisect_left(leaderboard, truncated_solve_time) + 1
    worst_possible_rank = bisect.bisect_right(leaderboard, truncated_solve_time) + 1
    if best_possible_rank > 100:
        return None
    span = worst_possible_rank - best_possible_rank
    approx_rank = best_possible_rank + round(span * solve_time.microseconds / 1_000_000)
    return approx_rank, best_possible_rank, worst_possible_rank


def _format_timedelta(solve_time: datetime.timedelta) -> str:
    minutes, seconds = divmod(solve_time.seconds, 60)
    hours, minutes = divmod(minutes, 60)
    if hours > 0:
        return f"{hours:02}:{minutes:02}:{seconds:02}"
    else:
        return f"{minutes:02}:{seconds:02}.{solve_time.microseconds // 10_000:02}"


def _report_practice_result(
    day: int, part: int, year: int, solve_time: datetime.timedelta
) -> None:
    print(
        f"{GREEN}You solved the puzzle in"
        f" {BLUE}{_format_timedelta(solve_time)}{GREEN}!{RESET}"
    )
    result = _estimate_practice_rank(day, part, year, solve_time)
    if not result:
        print(f"{YELLOW}You would not have achieved a leaderboard position.{RESET}")
    else:
        likely_rank, best_possible_rank, worst_possible_rank = result
        if best_possible_rank == worst_possible_rank:
            print(f"{GOLD}You would have achieved rank {best_possible_rank}!{RESET}")
        else:
            if worst_possible_rank > 100:
                worst_possible_rank = "100+"
            print(
                f"{GOLD}You would have achieved approximately rank"
                f" {likely_rank} ({best_possible_rank} to"
                f" {worst_possible_rank})!{RESET}"
            )


def submit(day: int, part: int, answer: typing.Any, year: int = DEFAULT_YEAR) -> None:
    """Submit a solution.

    Submissions are cached; submitting an already submitted solution will return the
    previous response.
    """
    day_ = str(day)
    year_ = str(year)
    part_ = str(part)
    answer_ = str(answer)

    submission_dir = DATA_DIR / year_ / day_
    _make(submission_dir)

    # Load cached solutions
    submissions = submission_dir / "submissions.json"
    if submissions.exists():
        with submissions.open() as f:
            solutions = json.load(f)
    else:
        solutions = {"1": {}, "2": {}}

    # Check if solved
    solution_file = submission_dir / f"{part}.solution"
    if solution_file.exists():
        solution = solution_file.read_text()
        if "--practice" in sys.argv:
            if solution == answer_:
                _calculate_practice_result(day, part, year)
            else:
                print(
                    f"{RED}Submitted {BLUE}{answer_}{RESET}; that's not the right"
                    f" answer.{RESET}\n"
                )
            return
        print(
            f"Day {BLUE}{day}{RESET} part {BLUE}{part}{RESET} "
            "has already been solved.\nThe solution was: "
            f"{BLUE}{solution}{RESET}"
        )
        _print_rank(solutions[part_][solution])
        return

    # Check if answer has already been submitted
    if answer_ in solutions[part_]:
        print(
            f"{YELLOW}Solution: {BLUE}{answer}{RESET} to part "
            f"{BLUE}{part}{RESET} has already been submitted.\n"
            f"Response was:{RESET}"
        )
        return _pretty_print(solutions[part_][answer_])

    while True:
        print(
            f"Submitting {BLUE}{answer}{RESET} as the solution to part "
            f"{BLUE}{part}{RESET}..."
        )
        resp = requests.post(
            url=URL.format(day=day, year=year) + "/answer",
            cookies=get_cookie(),
            data={"level": part_, "answer": answer_},
            headers=HEADERS,
        )
        if not resp.ok:
            if resp.status_code == 400:
                token_file = DATA_DIR / "token.txt"
                token = input(
                    "Your token has expired. Please enter your new token\n>>> "
                )
                token_file.write_text(token)
                return submit(day, part, answer, year)
            raise ValueError("Received bad response")

        article = Soup(resp.text, "html.parser").article
        assert article is not None
        msg = article.text

        if msg.startswith("You gave"):
            print(RED + msg + RESET)
            wait_match = WAIT_TIME.search(msg)
            assert wait_match is not None
            pause = 60 * int(wait_match[1] or 0) + int(wait_match[2])
            wait(
                f"{YELLOW}Waiting {BLUE}{pause}{RESET} seconds to retry...{RESET}",
                pause,
            )
        else:
            break

    if msg.startswith("That's the"):
        _print_rank(msg)
        solution_file.write_text(answer_)
        _calculate_practice_result(day, part, year)
        if part == 1:
            if not resp.url.endswith("#part2"):
                resp.url += "#part2"  # scroll to part 2
            _open_page(resp.url)  # open part 2 in the user's browser
    else:
        _pretty_print(msg)

    # Cache submission
    solutions[part_][answer_] = msg
    with submissions.open("w") as f:
        json.dump(solutions, f)


def _print_rank(msg: str) -> None:
    match = RANK.search(msg)
    if match:
        _pretty_print(f"You got rank {match.group(1)} for this puzzle")
    else:
        _pretty_print(msg)


def submit_25(year: str):
    print(f"{GREEN}Finishing Advent of Code {BLUE}{year}{RESET}!{RESET}")
    resp = requests.post(
        url=URL.format(day="25", year=year) + "/answer",
        cookies=get_cookie(),
        data={"level": "2", "answer": "0"},
        headers=HEADERS,
    )
    if not resp.ok:
        if resp.status_code == 400:
            token_file = DATA_DIR / "token.txt"
            token = input("Your token has expired. Please enter your new token\n>>> ")
            token_file.write_text(token)
            return submit_25(year)
        raise ValueError("Received bad response")

    print("Response from the server:")
    article = Soup(resp.text, "html.parser").article
    assert article is not None
    print(article.text.strip())
    if len(_practice_result_for(25, int(year))) < 2:
        _calculate_practice_result(25, 2, int(year))


def lazy_submit(
    day: int,
    solution: typing.Callable[[U], typing.Any],
    data: U,
    year: int = DEFAULT_YEAR,
) -> None:
    """Run the function only if we haven't seen a solution.

    Will also run the solution if `--force-run` or `--practice` is passed on the
    command line.

    solution is expected to be named 'part_one' or 'part_two'
    """
    import sys

    part = 1 if solution.__name__ == "part_one" else 2
    submission_dir = DATA_DIR / str(year) / str(day)
    if day == 25 and part == 2:
        # Don't try to submit part 2 if part 1 isn't solved
        if (submission_dir / "1.solution").exists():
            submit_25(str(year))
        else:
            return
    solution_file = submission_dir / f"{part}.solution"
    # Check if solved
    if (
        solution_file.exists()
        and "--force-run" not in sys.argv
        and (
            "--practice" not in sys.argv  # not in practice mode
            or len(_practice_result_for(day, year))
            >= part  # or solved today in practice
        )
    ):
        # Load cached solutions
        submissions = submission_dir / "submissions.json"
        with submissions.open() as f:
            solutions = json.load(f)

        solution_ = solution_file.read_text()
        print(
            f"Day {BLUE}{day}{RESET} part {BLUE}{part}{RESET} "
            "has already been solved.\nThe solution was: "
            f"{BLUE}{solution_}{RESET}"
        )
        _print_rank(solutions[str(part)][solution_])
    else:
        answer = work(
            f"{YELLOW}Running part"
            f" {RESET}{BLUE}{part}{RESET}{YELLOW} solution...{RESET}",
            solution,
            data,
        )
        if answer is not None:
            submit(day, part, answer, year)


def get_sample_input(
    day: int, part: int, year: int = DEFAULT_YEAR
) -> typing.Optional[typing.Tuple[str, str]]:
    """Retrieves the example input and answer for the corresponding AOC challenge."""
    testing_dir = DATA_DIR / str(year) / str(day)
    _make(testing_dir)
    testing_file = testing_dir / "tests.json"

    if testing_file.exists():
        test_info: typing.Dict[str, typing.Optional[typing.Tuple[str, str]]] = (
            json.loads(testing_file.read_text())
        )
    else:
        test_info: typing.Dict[str, typing.Optional[typing.Tuple[str, str]]] = {}

    if str(part) in test_info:
        return test_info[str(part)]

    resp = requests.post(
        url=URL.format(day=day, year=year), cookies=get_cookie(), headers=HEADERS
    )
    soup = Soup(resp.text, "html.parser")

    example_test_inputs = []
    # Find the example test input for that day.
    for possible_test_input in soup.find_all("pre"):
        preceding_text = (
            possible_test_input.previous_element.previous_element.text.lower()
        )
        if (
            "for example" in preceding_text
            or "consider" in preceding_text
            or "given" in preceding_text
        ) and ":" in preceding_text:
            example_test_inputs.append(possible_test_input.text.strip())

    if not example_test_inputs:
        test_info[str(part)] = None
        testing_file.write_text(json.dumps(test_info))
        warn(
            f"An issue occurred while fetching test data for {year} day"
            f" {day} part {part}. You may either ignore this message, or pass"
            " custom test data to lazy_test.",
            RuntimeWarning,
        )
        return

    try:
        test_input = example_test_inputs[-1]

        # Attempt to retrieve answer to said example data from puzzle part.
        current_part = soup.find_all("article")[part - 1]
        last_sentence = current_part.find_all("p")[-2]
        answer = last_sentence.find_all("code")[-1]
    except IndexError:
        test_info[str(part)] = None
        testing_file.write_text(json.dumps(test_info))
        warn(
            f"An issue occurred while fetching test data for {year} day"
            f" {day} part {part}. You may either ignore this message, or pass"
            " custom test data to lazy_test.",
            RuntimeWarning,
        )
        return
    if not answer.em:
        try:
            answer = last_sentence.find_all("em")[-1]
        except IndexError:
            pass

    try:
        answer = answer.text.strip("\n").split()[-1]
    except IndexError:
        test_info[str(part)] = None
        testing_file.write_text(json.dumps(test_info))
        warn(
            f"An issue occurred while fetching test data for {year} day"
            f" {day} part {part}. You may either ignore this message, or pass"
            " custom test data to lazy_test.",
            RuntimeWarning,
        )
        return

    test_data = test_input, answer
    test_info[str(part)] = test_data
    testing_file.write_text(json.dumps(test_info))
    return test_data


def _test(part: int, answer: str, expected_answer: str) -> None:
    assert answer == expected_answer, (
        f"The expected answer for the example test input was {expected_answer} but"
        f" your answer was {answer}."
    )
    print(
        f"{GREEN}Test for part {BLUE}{part}{RESET} succeeded!"
        f" The answer for part {BLUE}{part}{RESET} with the test data was:"
        f" {BLUE}{answer}{RESET}{RESET}"
    )


def lazy_test(
    day: int,
    parse: typing.Callable[[str], T],
    solution: typing.Callable[[T], typing.Any],
    year: int = DEFAULT_YEAR,
    test_data: typing.Optional[typing.Tuple[str, typing.Any]] = None,
) -> None:
    """Test the function with AOC's example data only if we haven't tested it already.

    Solution is expected to be named 'part_one' or 'part_two'
    """
    part = 1 if solution.__name__ == "part_one" else 2
    testing_dir = DATA_DIR / str(year) / str(day)
    _make(testing_dir)

    # If this is part 2, skip fetching/running tests if part 1 hasn't been submitted
    if part == 2 and not (testing_dir / "1.solution").exists():
        return

    # If this part has been submitted, skip running tests
    if not (testing_dir / f"{part}.solution").exists():
        if test_data is None:  # No test data passed (most common)
            test_data = get_sample_input(day, part, year)
            if test_data is None:  # No test data scraped (uncommon)
                return
        test_input, test_answer = test_data

        answer = work(
            f"{YELLOW}Running the test for part {BLUE}{part}{RESET} solution...{RESET}",
            solution,
            parse(test_input),
        )
        if answer is not None:
            _test(part, str(answer).strip(), str(test_answer).strip())
